- tech drawing and render improv

This commit is contained in:
bklronin
2026-08-18 15:06:51 +02:00
parent 813ddc3596
commit 67b73c13b8
10 changed files with 1886 additions and 54 deletions
+75 -18
View File
@@ -16,8 +16,35 @@ from fluency.geometry.base import (
Point3D,
)
logger = logging.getLogger(__name__)
def _curve_is_linear(occ_edge: Any) -> bool:
"""Return True if *occ_edge* has a linear or chamferable curve type.
``BRepFilletAPI_MakeChamfer`` and ``BRepFilletAPI_MakeFillet`` crash
(segfault) on circular/elliptical curves. We pre-filter those out.
"""
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_CurveType
try:
ad = BRepAdaptor_Curve(occ_edge)
ct = ad.GetType()
except Exception:
# If we can't classify, assume it's safe (will be caught later).
return True
# Chamfer/fillet only support linear curves reliably.
return ct in (
GeomAbs_CurveType.GeomAbs_Line,
GeomAbs_CurveType.GeomAbs_BSplineCurve,
GeomAbs_CurveType.GeomAbs_BezierCurve,
GeomAbs_CurveType.GeomAbs_OffsetCurve,
GeomAbs_CurveType.GeomAbs_Parabola,
GeomAbs_CurveType.GeomAbs_Hyperbola,
)
class OCCGeometryObject(GeometryObject):
"""Geometry object wrapper for OpenCASCADE shapes."""
@@ -446,52 +473,82 @@ class OCGeometryKernel(GeometryKernel):
def fillet(
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply fillet to edges."""
shape = self._get_shape(body)
"""Apply fillet to edges. Skips edges that cannot be filleted."""
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
fillet = BRepFilletAPI_MakeFillet(shape)
shape: Any = self._get_shape(body)
if shape is None:
return OCCGeometryObject(None, {"type": "fillet"})
if edges:
for edge in edges:
fillet.Add(radius, edge)
# Collect candidate edges
if edges is not None:
candidates = list(edges)
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
candidates = []
while explorer.More():
fillet.Add(radius, TopoDS.Edge_s(explorer.Current()))
e = TopoDS.Edge_s(explorer.Current())
if _curve_is_linear(e):
candidates.append(e)
explorer.Next()
fillet.Build()
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
# Add all edges, then Build once — avoids OCC internal crashes
fl = BRepFilletAPI_MakeFillet(shape)
for edge in candidates:
try:
fl.Add(radius, edge)
except Exception:
pass # skip edges that fail to add
fl.Build()
if fl.IsDone():
return OCCGeometryObject(fl.Shape(), {"type": "fillet"})
# If Build failed, return original shape (no fillet applied)
return OCCGeometryObject(shape, {"type": "fillet"})
def chamfer(
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply chamfer to edges."""
shape = self._get_shape(body)
"""Apply chamfer to edges. Skips edges that cannot be chamfered."""
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
chamfer = BRepFilletAPI_MakeChamfer(shape)
shape: Any = self._get_shape(body)
if shape is None:
return OCCGeometryObject(None, {"type": "chamfer"})
if edges:
for edge in edges:
chamfer.Add(size, edge)
# Collect candidate edges
if edges is not None:
candidates = list(edges)
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
candidates = []
while explorer.More():
chamfer.Add(size, TopoDS.Edge_s(explorer.Current()))
e = TopoDS.Edge_s(explorer.Current())
if _curve_is_linear(e):
candidates.append(e)
explorer.Next()
chamfer.Build()
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
# Add all edges, then Build once — avoids OCC internal crashes
mc = BRepFilletAPI_MakeChamfer(shape)
for edge in candidates:
try:
mc.Add(size, edge)
except Exception:
pass # skip edges that fail to add
mc.Build()
if mc.IsDone():
return OCCGeometryObject(mc.Shape(), {"type": "chamfer"})
# If Build failed, return original shape (no chamfer applied)
return OCCGeometryObject(shape, {"type": "chamfer"})
def shell(
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
+24 -12
View File
@@ -150,7 +150,8 @@ def build_source_parts(
) -> Tuple[Tuple[DrawingSourcePart, ...], Tuple[str, ...]]:
"""Collect visible solid bodies as source parts for projection.
Returns ``(parts, warnings)``.
Returns ``(parts, warnings)``. For assemblies all bodies are fused
into a single shape so the drawing treats the assembly as one part.
"""
warnings: List[str] = []
parts: List[DrawingSourcePart] = []
@@ -182,6 +183,8 @@ def build_source_parts(
asm = project.assemblies.get(source_id)
if asm is None:
return (), (f"Assembly {source_id} not found",)
# Collect all transformed shapes, then fuse into a single part.
shapes: List[Any] = []
for ac_id, ac in sorted(asm.components.items()):
comp = project.get_component_by_id(ac.component_id)
if comp is None:
@@ -194,20 +197,29 @@ def build_source_parts(
if shape is None:
warnings.append(f"Body {body.name} ({bid}) has no extractable shape")
continue
transformed = _apply_ocp_transform(shape, ac.position, ac.rotation)
parts.append(
DrawingSourcePart(
part_id=f"{ac_id}/{bid}",
display_name=f"{comp.name}:{body.name}",
shape=transformed,
color=body.color,
component_id=ac.component_id,
assembly_instance_id=ac_id,
)
shapes.append(_apply_ocp_transform(shape, ac.position, ac.rotation))
if shapes:
# Fuse all shapes into one solid.
fused = shapes[0]
for s in shapes[1:]:
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
fuse_op = BRepAlgoAPI_Fuse(fused, s)
fuse_op.Build()
if fuse_op.IsDone():
fused = fuse_op.Shape()
parts.append(
DrawingSourcePart(
part_id=source_id,
display_name=asm.name,
shape=fused,
color=(0.5, 0.5, 0.5),
component_id=source_id,
)
)
if not parts:
warnings.append("Assembly has no visible solid geometry")
else:
return (), (f"Unknown source kind: {source_kind}",)
+76 -6
View File
@@ -1751,6 +1751,7 @@ class MainWindow(QMainWindow):
self._assembly_component_buttons: List[QPushButton] = []
self._assembly_component_group: Optional[QButtonGroup] = None
self._assembly_view_active: bool = False
self._render_mode: str = "component"
self._selected_assembly_component_id: Optional[str] = None
# Connector two-click state
@@ -2549,6 +2550,7 @@ class MainWindow(QMainWindow):
if idx < len(comp_ids):
self._current_component = self._project.components[comp_ids[idx]]
self._assembly_view_active = False
self._render_mode = "component"
self._refresh_lists()
self._redraw_bodies()
# Propagate the new selection to the drawing tab.
@@ -2557,6 +2559,8 @@ class MainWindow(QMainWindow):
self._drawing_tab.set_active_component(self._current_component)
except Exception as e:
logger.warning(f"Failed to update drawing tab source: {e}")
# Re-load the render tab to reflect the new selection.
self._load_render_tab_shape()
# Scroll to the selected button.
if 0 <= idx < len(self._component_buttons):
_scroll_to_button(self._component_buttons[idx], self._component_scroll)
@@ -3457,9 +3461,13 @@ class MainWindow(QMainWindow):
self._selected_assembly_component_id = active_id
self._assembly_view_active = True
self._render_mode = "assembly"
self._show_assembly_in_viewer(fit=True)
# Re-load the render tab to show the full assembly.
self._load_render_tab_shape()
# Scroll to the selected button.
for btn in self._assembly_component_buttons:
if getattr(btn, "_assembly_component_id", None) == active_id:
@@ -7299,6 +7307,7 @@ class MainWindow(QMainWindow):
self._selected_body = None
self._selected_assembly_component_id = None
self._assembly_view_active = False
self._render_mode = "component"
for btn in self._component_buttons:
btn.deleteLater()
@@ -7553,6 +7562,7 @@ class MainWindow(QMainWindow):
self._body_highlight_id = None
self._body_highlight_original_color = None
self._assembly_view_active = False
self._render_mode = "component"
self._sketch_widget.clear_source_face()
self._sketch_widget.set_sketch(None)
self._viewer_3d.clear_scene()
@@ -7652,6 +7662,7 @@ class MainWindow(QMainWindow):
self._show_assembly_in_viewer(fit=True)
else:
self._assembly_view_active = False
self._render_mode = "component"
self._redraw_bodies()
# Restore camera + active tab.
@@ -7801,11 +7812,26 @@ class MainWindow(QMainWindow):
def _open_render_window(self):
"""Populate the render tab with the selected body or assembly and switch to it."""
# Collect all visible bodies across all components
# Determine which bodies to render based on selection state.
assembly_parts = [] # list of (TopoDS_Shape, Optional[str])
single_shape = None
for comp in self._project.components.values():
if self._assembly_view_active:
# Assembly view: render the full assembly (all component instances).
assembly = self._get_assembly()
if assembly:
for ac in assembly.components.values():
comp = self._project.get_component_by_id(ac.component_id)
if comp:
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
elif self._current_component:
# Single component selected via button.
comp = self._current_component
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
@@ -7814,6 +7840,18 @@ class MainWindow(QMainWindow):
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
else:
# Fallback: use the project's active component.
comp = self._project.get_active_component()
if comp:
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
if not assembly_parts:
QMessageBox.information(
@@ -7851,9 +7889,26 @@ class MainWindow(QMainWindow):
def _load_render_tab_shape(self) -> None:
"""Auto-load the selected body or assembly component into the render tab."""
# Collect all visible bodies across all components
# Determine which bodies to render based on selection state.
assembly_parts = []
for comp in self._project.components.values():
if self._render_mode == "assembly":
# Assembly view: render the full assembly (all component instances).
assembly = self._get_assembly()
if assembly:
for ac in assembly.components.values():
comp = self._project.get_component_by_id(ac.component_id)
if comp:
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
elif self._current_component:
# Single component selected via button.
comp = self._current_component
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
@@ -7862,8 +7917,23 @@ class MainWindow(QMainWindow):
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
else:
# Fallback: use the project's active component.
comp = self._project.get_active_component()
if comp:
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
try:
occ_shape = self._kernel._get_shape(body.geometry)
assembly_parts.append((occ_shape, body.render_material))
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
if not assembly_parts:
# No drawable geometry in the selection — keep the render tab in
# sync with the (empty) model view instead of showing a stale image.
self._render_tab.clear()
return
# Capture the viewport camera
+189 -9
View File
@@ -602,8 +602,6 @@ class RenderWindow(QMainWindow):
"""Reset camera parameters to match the 3D viewport."""
if self._camera is None:
return
o = self._camera.origin
t = self._camera.target
u = self._camera.up
self._cam_origin_x.setValue(o[0])
self._cam_origin_y.setValue(o[1])
@@ -683,8 +681,6 @@ class RenderWindow(QMainWindow):
"""Fill camera spinboxes from the current RenderCamera."""
if self._camera is None:
return
o = self._camera.origin
t = self._camera.target
u = self._camera.up
self._cam_origin_x.setValue(o[0])
self._cam_origin_y.setValue(o[1])
@@ -1009,6 +1005,9 @@ class RenderTabContent(QWidget):
self._backend = None
self._mesh_path: Optional[str] = None
# Framing: percentage of screen the part should occupy (10-100).
self._framing_percentage: float = 80.0
# Assembly support: list of (mesh_path, RenderMaterial)
self._assembly_parts: list = []
@@ -1020,6 +1019,10 @@ class RenderTabContent(QWidget):
self._camera: Optional[RenderCamera] = None
self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5)
self._active_mode: Optional[str] = None
self._framing_slider: QSlider | None = None
self._framing_label: QLabel | None = None
# Combined bbox for assembly rendering (used by _compute_framed_origin)
self._assembly_bounds: Optional[tuple] = None
self._auto_preview_timer: Optional[QTimer] = None
self._init_ui()
@@ -1034,9 +1037,18 @@ class RenderTabContent(QWidget):
*camera* if provided, overrides the stored camera. Pass the
viewport\'s render camera to match the 3D view framing.
"""
# Cancel any in-progress render so the new shape gets a fresh preview.
self._cancel_active_thread()
# Drop any previously loaded assembly state so the single-shape
# render path is used (prevents re-rendering a stale assembly).
self._assembly_parts = []
self._assembly_bounds = None
# Reset the mesh path so a failed tessellation below cannot
# trigger an auto-preview of the previous shape's mesh.
self._mesh_path = None
self._shape = shape
if camera is not None:
self._camera = camera
self._camera = self._apply_framing(camera)
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
@@ -1062,18 +1074,23 @@ class RenderTabContent(QWidget):
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples
where the second element is an optional material preset name.
"""
# Cancel any in-progress render so the new assembly gets a fresh preview.
self._cancel_active_thread()
self._shape = None
self._mesh_path = None
self._assembly_parts = []
self._assembly_bounds = None
# Tessellate and compute combined bounds first so the framing below
# is based on this assembly, not a stale one.
self._prepare_assembly_mesh(parts)
if camera is not None:
self._camera = camera
self._camera = self._apply_framing(camera)
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
self._prepare_assembly_mesh(parts)
self._populate_camera_controls()
self._schedule_auto_preview()
@@ -1086,7 +1103,7 @@ class RenderTabContent(QWidget):
"""
if camera is None:
return
self._camera = camera
self._camera = self._apply_framing(camera)
self._cam_fov_spin.blockSignals(True)
try:
self._cam_fov_spin.setValue(camera.fov)
@@ -1100,6 +1117,20 @@ class RenderTabContent(QWidget):
# For full renders or idle: schedule a preview if auto-preview is on.
self._schedule_auto_preview()
def clear(self) -> None:
"""Remove any loaded shape/assembly and reset the display."""
self._cancel_active_thread()
self._shape = None
self._mesh_path = None
self._assembly_parts = []
self._assembly_bounds = None
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
def cleanup(self) -> None:
"""Stop threads and delete temp files. Call when the tab is hidden/closed."""
if self._auto_preview_timer and self._auto_preview_timer.isActive():
@@ -1248,6 +1279,23 @@ class RenderTabContent(QWidget):
layout.addWidget(camera_gb)
# ── Framing ───────────────────────────────────────────────
framing_gb = QGroupBox("Framing")
framing_layout = QVBoxLayout(framing_gb)
framing_layout.setSpacing(4)
self._framing_slider = QSlider(Qt.Horizontal)
self._framing_slider.setRange(10, 100)
self._framing_slider.setValue(80)
self._framing_slider.valueChanged.connect(self._on_framing_changed)
framing_layout.addWidget(self._framing_slider)
self._framing_label = QLabel("80 %")
self._framing_label.setAlignment(Qt.AlignCenter)
framing_layout.addWidget(self._framing_label)
layout.addWidget(framing_gb)
# ── Lighting ────────────────────────────────────────────────
light_gb = QGroupBox("Lighting")
light_layout = QVBoxLayout(light_gb)
@@ -1477,15 +1525,27 @@ class RenderTabContent(QWidget):
self._assembly_parts = []
first_bounds = None
all_mins: list[float] = []
all_maxs: list[float] = []
for shape, mat_name in parts:
try:
mesh_path = occ_shape_to_ply(shape, linear_deflection=0.1, angular_deflection=0.15)
material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel")
self._assembly_parts.append((mesh_path, material))
bounds = occ_shape_bounds(shape)
all_mins.append(list(bounds[0]))
all_maxs.append(list(bounds[1]))
if first_bounds is None:
first_bounds = occ_shape_bounds(shape)
first_bounds = bounds
except Exception as e:
logger.warning(f"Failed to tessellate assembly part: {e}")
# Compute combined bounding box from all parts.
if all_mins and all_maxs:
combined_min = [min(a[i] for a in all_mins) for i in range(3)]
combined_max = [max(a[i] for a in all_maxs) for i in range(3)]
self._assembly_bounds = (combined_min, combined_max)
else:
self._assembly_bounds = None
if first_bounds and self._camera is None:
mn, mx = first_bounds
self._camera = self._backend.default_camera_from_bounds(mn, mx)
@@ -1503,6 +1563,126 @@ class RenderTabContent(QWidget):
return
self._cam_fov_spin.setValue(self._camera.fov)
# ── Framing ──────────────────────────────────────────────────
def _on_framing_changed(self, value: int) -> None:
"""Slider moved — update label and re-frame if we have a camera."""
self._framing_percentage = float(value)
self._framing_label.setText(f"{value} %")
# Re-apply framing with current direction
if self._camera is not None:
self._camera = self._apply_framing(self._camera)
# Sync the FOV spinbox to match (important for preview consistency)
self._cam_fov_spin.blockSignals(True)
try:
self._cam_fov_spin.setValue(self._camera.fov)
finally:
self._cam_fov_spin.blockSignals(False)
self._schedule_auto_preview()
def _apply_framing(self, camera: RenderCamera) -> RenderCamera:
"""Apply framing to a camera, returning a new one with adjusted origin.
Keeps the direction and target from *camera*, adjusts distance
so the part fills _framing_percentage of the screen.
"""
eye_dir = np.array(camera.origin) - np.array(camera.target)
diag = float(np.linalg.norm(eye_dir))
if diag > 1e-9:
eye_dir /= diag
target = camera.target
new_origin = self._compute_framed_origin(
eye_dir, target, camera.fov,
)
return RenderCamera(
origin=tuple(new_origin),
target=target,
up=camera.up,
fov=camera.fov,
)
return camera
def _compute_framed_origin(
self, eye_dir: np.ndarray, target: tuple[float, float, float], fov: float
) -> np.ndarray:
"""Compute camera origin so the part fills _framing_percentage of screen.
The viewing direction (eye_dir) and target define the line of sight.
The bounding box is projected onto the view plane and the distance
is chosen so that its larger projected dimension occupies exactly
``_framing_percentage`` of the corresponding screen axis.
"""
# Get bounding box — from assembly or single shape.
if self._assembly_bounds is not None:
mn, mx = self._assembly_bounds
elif self._shape is not None:
mn, mx = occ_shape_bounds(self._shape)
else:
# Fallback: place camera at a large but safe distance.
return np.array(target, dtype=float) + eye_dir * 1000.0
mn_arr = np.asarray(mn, dtype=float)
mx_arr = np.asarray(mx, dtype=float)
diag = float(np.linalg.norm(mx_arr - mn_arr))
# View-plane basis vectors.
up_world = np.array([0.0, 0.0, 1.0], dtype=float)
right = np.cross(up_world, eye_dir)
right_norm = float(np.linalg.norm(right))
if right_norm < 1e-9:
# Eye dir is parallel to world up — pick arbitrary right.
right = np.array([1.0, 0.0, 0.0], dtype=float)
else:
right /= right_norm
screen_up = np.cross(eye_dir, right)
# Project bbox axes onto view plane.
dx = mx_arr[0] - mn_arr[0]
dy = mx_arr[1] - mn_arr[1]
dz = mx_arr[2] - mn_arr[2]
# Project all 8 bbox corners onto the view plane
# to get the actual bounding-box extent.
half_x = dx / 2.0
half_y = dy / 2.0
half_z = dz / 2.0
# Corner offsets from centre in local axes.
corner_offsets = [
(sx * half_x, sy * half_y, sz * half_z)
for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)
]
# Project each corner onto view-plane basis vectors.
proj_right_vals = [
ox * right[0] + oy * right[1] + oz * right[2]
for ox, oy, oz in corner_offsets
]
proj_up_vals = [
ox * screen_up[0] + oy * screen_up[1] + oz * screen_up[2]
for ox, oy, oz in corner_offsets
]
proj_right = max(proj_right_vals) - min(proj_right_vals)
proj_up = max(proj_up_vals) - min(proj_up_vals)
half_fov_rad = np.radians(fov / 2.0)
tan_half_fov = float(np.tan(half_fov_rad))
if tan_half_fov < 1e-9:
return np.array(target, dtype=float) + eye_dir * 1000.0
# Distance: D = full_extent / (2 * pct * tan(fov/2))
pct = self._framing_percentage / 100.0
dist_x = proj_right / (2.0 * pct * tan_half_fov) if proj_right > 0 else float("inf")
dist_y = proj_up / (2.0 * pct * tan_half_fov) if proj_up > 0 else float("inf")
dist = min(dist_x, dist_y)
# Minimum distance to avoid camera inside the object.
if dist < diag * 0.1:
dist = diag * 0.1
return np.array(target, dtype=float) + eye_dir * dist
def _schedule_auto_preview(self):
if not self._auto_preview_cb.isChecked():
return
@@ -421,6 +421,15 @@ class TechnicalDrawingWidget(QWidget):
self._active_source_id = component.id
self._on_generate()
def set_active_assembly(self, assembly) -> None:
"""Use the given assembly as the drawing source and regenerate.
The assembly is treated as a single fused part (all bodies merged).
"""
self._active_source_kind = "assembly"
self._active_source_id = assembly.id
self._on_generate()
def generate(self) -> None:
"""Public entry point: generate for the current source."""
self._on_generate()