- tech drawing and render improv
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}",)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user