Improved render previews

This commit is contained in:
bklronin
2026-07-18 23:06:42 +02:00
parent 742d06d242
commit d6e829c23d
9 changed files with 2195 additions and 1553 deletions
+55 -45
View File
@@ -4,14 +4,16 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added renderer&#10;- Added undo"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Render improvements, camera plane, update">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.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/mitsuba_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/render_backend.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -48,45 +50,45 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent">{ <component name="PropertiesComponent"><![CDATA[{
&quot;keyToString&quot;: { "keyToString": {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;, "Python.2dtest.executor": "Run",
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;, "Python.3d_windows.executor": "Run",
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;, "Python.Unnamed.executor": "Run",
&quot;Python.base.executor&quot;: &quot;Run&quot;, "Python.base.executor": "Run",
&quot;Python.data_model.executor&quot;: &quot;Run&quot;, "Python.data_model.executor": "Run",
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;, "Python.draw_widget2d.executor": "Run",
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;, "Python.draw_widget_solve.executor": "Run",
&quot;Python.fluency.executor&quot;: &quot;Run&quot;, "Python.fluency.executor": "Run",
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;, "Python.fluencyb.executor": "Run",
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;, "Python.gl_widget.executor": "Run",
&quot;Python.gui_ui.executor&quot;: &quot;Run&quot;, "Python.gui_ui.executor": "Run",
&quot;Python.kernel.executor&quot;: &quot;Run&quot;, "Python.kernel.executor": "Run",
&quot;Python.main.executor&quot;: &quot;Run&quot;, "Python.main.executor": "Run",
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;, "Python.meshtest.executor": "Run",
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;, "Python.occ_renderer.executor": "Run",
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;, "Python.occ_to_mesh.executor": "Run",
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;, "Python.render_backend.executor": "Run",
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;, "Python.side_fluency.executor": "Run",
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;, "Python.simple_mesh.executor": "Run",
&quot;Python.sketch.executor&quot;: &quot;Run&quot;, "Python.sketch.executor": "Run",
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;, "Python.vtk_widget.executor": "Run",
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;, "Python.vulkan.executor": "Run",
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;, "RunOnceActivity.OpenProjectViewOnStart": "true",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;, "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, "RunOnceActivity.git.unshallow": "true",
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;, "RunOnceActivity.typescript.service.memoryLimit.init": "true",
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;, "codeWithMe.voiceChat.enabledByDefault": "false",
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;, "git-widget-placeholder": "feature/occ-migration",
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency/rendering&quot;, "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, "node.js.detected.package.eslint": "true",
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.eslint": "(autodetect)",
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.tslint": "(autodetect)",
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, "nodejs_package_manager_path": "npm",
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot; "settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
} }
}</component> }]]></component>
<component name="QodanaReportsService"> <component name="QodanaReportsService">
<option name="descriptions"> <option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" /> <ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -94,9 +96,9 @@
</component> </component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" /> <recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/src/fluency" /> <recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/drawing_modules" /> <recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" /> <recent name="$PROJECT_DIR$/modules" />
</key> </key>
@@ -432,7 +434,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1783918463034</updated> <updated>1783918463034</updated>
</task> </task>
<option name="localTasksCounter" value="40" /> <task id="LOCAL-00040" summary="- Render improvements, camera plane, update">
<option name="closed" value="true" />
<created>1783976498520</created>
<option name="number" value="00040" />
<option name="presentableId" value="LOCAL-00040" />
<option name="project" value="LOCAL" />
<updated>1783976498520</updated>
</task>
<option name="localTasksCounter" value="41" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -453,7 +463,6 @@
<ignored-roots> <ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" /> <path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots> </ignored-roots>
<MESSAGE value="- Renabled extrusion with new object system" />
<MESSAGE value="- Sketch projection partly works again :)" /> <MESSAGE value="- Sketch projection partly works again :)" />
<MESSAGE value="- Added new componnt controls" /> <MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" /> <MESSAGE value="- changing compos for sketches works" />
@@ -478,6 +487,7 @@
<MESSAGE value="- Working assembly multi :)" /> <MESSAGE value="- Working assembly multi :)" />
<MESSAGE value="- added renderer" /> <MESSAGE value="- added renderer" />
<MESSAGE value="- added renderer&#10;- Added undo" /> <MESSAGE value="- added renderer&#10;- Added undo" />
<option name="LAST_COMMIT_MESSAGE" value="- added renderer&#10;- Added undo" /> <MESSAGE value="- Render improvements, camera plane, update" />
<option name="LAST_COMMIT_MESSAGE" value="- Render improvements, camera plane, update" />
</component> </component>
</project> </project>
+825 -788
View File
File diff suppressed because it is too large Load Diff
+482 -469
View File
File diff suppressed because it is too large Load Diff
+54 -15
View File
@@ -54,6 +54,7 @@ class OCGeometryKernel(GeometryKernel):
def create_point(self, x: float, y: float) -> GeometryObject: def create_point(self, x: float, y: float) -> GeometryObject:
"""Create a 2D point.""" """Create a 2D point."""
from OCP.gp import gp_Pnt from OCP.gp import gp_Pnt
return OCCGeometryObject(gp_Pnt(x, y, 0)) return OCCGeometryObject(gp_Pnt(x, y, 0))
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject: def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
@@ -61,9 +62,7 @@ class OCGeometryKernel(GeometryKernel):
from OCP.gp import gp_Pnt from OCP.gp import gp_Pnt
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
edge = BRepBuilderAPI_MakeEdge( edge = BRepBuilderAPI_MakeEdge(gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)).Edge()
gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)
).Edge()
return OCCGeometryObject(edge, {"type": "line"}) return OCCGeometryObject(edge, {"type": "line"})
def create_circle(self, center: Point2D, radius: float) -> GeometryObject: def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
@@ -71,9 +70,7 @@ class OCGeometryKernel(GeometryKernel):
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
circ = gp_Circ( circ = gp_Circ(gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius)
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
)
edge = BRepBuilderAPI_MakeEdge(circ).Edge() edge = BRepBuilderAPI_MakeEdge(circ).Edge()
return OCCGeometryObject(edge, {"type": "circle"}) return OCCGeometryObject(edge, {"type": "circle"})
@@ -88,9 +85,7 @@ class OCGeometryKernel(GeometryKernel):
start_rad = math.radians(start_angle) start_rad = math.radians(start_angle)
end_rad = math.radians(end_angle) end_rad = math.radians(end_angle)
circ = gp_Circ( circ = gp_Circ(gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius)
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
)
edge = BRepBuilderAPI_MakeEdge(circ, start_rad, end_rad).Edge() edge = BRepBuilderAPI_MakeEdge(circ, start_rad, end_rad).Edge()
return OCCGeometryObject(edge, {"type": "arc"}) return OCCGeometryObject(edge, {"type": "arc"})
@@ -159,8 +154,7 @@ class OCGeometryKernel(GeometryKernel):
face = self._get_shape(sketch) face = self._get_shape(sketch)
if face is None: if face is None:
raise ValueError( raise ValueError(
"Cannot extrude: sketch has no geometry. " "Cannot extrude: sketch has no geometry. Draw a closed profile before extruding."
"Draw a closed profile before extruding."
) )
# If the wrapper class itself leaked through somehow, surface a # If the wrapper class itself leaked through somehow, surface a
# clear error instead of letting BRepPrimAPI_MakePrism raise an # clear error instead of letting BRepPrimAPI_MakePrism raise an
@@ -204,6 +198,7 @@ class OCGeometryKernel(GeometryKernel):
def _sketch_normal(obj: GeometryObject) -> Tuple[float, float, float]: def _sketch_normal(obj: GeometryObject) -> Tuple[float, float, float]:
"""Return the normal stored on a sketch-derived geometry object, else +Z.""" """Return the normal stored on a sketch-derived geometry object, else +Z."""
import numpy as np import numpy as np
meta = getattr(obj, "metadata", None) or {} meta = getattr(obj, "metadata", None) or {}
n = meta.get("normal") n = meta.get("normal")
if n is None: if n is None:
@@ -453,7 +448,6 @@ class OCGeometryKernel(GeometryKernel):
origin: Tuple[float, float, float] = (0, 0, 0), origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject: ) -> GeometryObject:
"""Rotate a body around an axis.""" """Rotate a body around an axis."""
import math
shape = self._get_shape(body) shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir
@@ -562,6 +556,52 @@ class OCGeometryKernel(GeometryKernel):
shape = reader.OneShape() shape = reader.OneShape()
return OCCGeometryObject(shape, {"type": "imported_step"}) return OCCGeometryObject(shape, {"type": "imported_step"})
def import_step_components(self, filepath: str) -> list:
"""Import a STEP file and return each solid as a separate ``(name, shape)`` pair.
The STEP reader transfers the entire root shape, then we iterate
over individual ``TopAbs_SOLID`` entities so that each solid gets
its own ``OCCGeometryObject``. If the file contains only a single
solid the list will have one entry.
Returns a list of ``(name, OCCGeometryObject)`` tuples.
"""
from OCP.STEPControl import STEPControl_Reader
from OCP.IFSelect import IFSelect_RetDone
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_SOLID
from OCP.TopoDS import TopoDS
reader = STEPControl_Reader()
status = reader.ReadFile(filepath)
if status != IFSelect_RetDone:
raise ValueError(f"Failed to read STEP file: {filepath}")
reader.TransferRoots()
shape = reader.OneShape()
# Extract individual solids
solids: list = []
explorer = TopExp_Explorer(shape, TopAbs_SOLID)
idx = 0
while explorer.More():
solid = TopoDS.Solid_s(explorer.Current())
idx += 1
solids.append(
(
f"Part {idx}",
OCCGeometryObject(solid, {"type": "imported_step"}),
)
)
explorer.Next()
# Fallback: no individual solids found — return the whole shape
if not solids:
solids = [("Imported", OCCGeometryObject(shape, {"type": "imported_step"}))]
return solids
def import_iges(self, filepath: str) -> GeometryObject: def import_iges(self, filepath: str) -> GeometryObject:
"""Import from IGES format.""" """Import from IGES format."""
from OCP.IGESControl import IGESControl_Reader from OCP.IGESControl import IGESControl_Reader
@@ -643,10 +683,8 @@ class OCGeometryKernel(GeometryKernel):
from OCP.TopExp import TopExp_Explorer from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE from OCP.TopAbs import TopAbs_EDGE
from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location
from OCP.BRepAdaptor import BRepAdaptor_Curve from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle, GeomAbs_Ellipse, GeomAbs_BSplineCurve from OCP.GeomAbs import GeomAbs_Line
vertices_list: List[List[float]] = [] vertices_list: List[List[float]] = []
edges_list: List[List[int]] = [] edges_list: List[List[int]] = []
@@ -678,6 +716,7 @@ class OCGeometryKernel(GeometryKernel):
explorer = TopExp_Explorer(shape, TopAbs_EDGE) explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More(): while explorer.More():
from OCP.TopoDS import TopoDS from OCP.TopoDS import TopoDS
edge = TopoDS.Edge_s(explorer.Current()) edge = TopoDS.Edge_s(explorer.Current())
edge_points = discretize_edge(edge) edge_points = discretize_edge(edge)
+11 -15
View File
@@ -13,9 +13,7 @@ import numpy as np
from fluency.geometry.base import ( from fluency.geometry.base import (
Point2D, Point2D,
Point3D,
GeometryObject, GeometryObject,
SketchInterface,
) )
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry_occ.sketch import OCCSketch
@@ -48,6 +46,7 @@ class Workplane:
def __post_init__(self): def __post_init__(self):
# Normalise normal and x_dir on construction. # Normalise normal and x_dir on construction.
import numpy as np import numpy as np
n = np.asarray(self.normal, dtype=float) n = np.asarray(self.normal, dtype=float)
n = n / np.linalg.norm(n) n = n / np.linalg.norm(n)
x = np.asarray(self.x_dir, dtype=float) x = np.asarray(self.x_dir, dtype=float)
@@ -84,6 +83,7 @@ class Workplane:
def world_to_uv(self, p: Tuple[float, float, float]) -> Tuple[float, float]: def world_to_uv(self, p: Tuple[float, float, float]) -> Tuple[float, float]:
"""Map a 3D world point to UV coordinates on this plane.""" """Map a 3D world point to UV coordinates on this plane."""
import numpy as np import numpy as np
ox, oy, oz = self.origin ox, oy, oz = self.origin
v = np.array([p[0] - ox, p[1] - oy, p[2] - oz]) v = np.array([p[0] - ox, p[1] - oy, p[2] - oz])
xd = np.array(self.x_dir, dtype=float) xd = np.array(self.x_dir, dtype=float)
@@ -244,6 +244,7 @@ class Body:
visible: bool = True visible: bool = True
render_object: Any = None render_object: Any = None
render_material: Optional[str] = None # material preset name for render backend
created_at: datetime = field(default_factory=datetime.now) created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now) modified_at: datetime = field(default_factory=datetime.now)
@@ -471,8 +472,8 @@ class AssemblyConnection:
""" """
id: str = field(default_factory=lambda: str(uuid.uuid4())) id: str = field(default_factory=lambda: str(uuid.uuid4()))
first_ac_id: str = "" # grounded reference side first_ac_id: str = "" # grounded reference side
second_ac_id: str = "" # solved side second_ac_id: str = "" # solved side
first_connector_id: Optional[str] = None first_connector_id: Optional[str] = None
second_connector_id: Optional[str] = None second_connector_id: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now) created_at: datetime = field(default_factory=datetime.now)
@@ -512,8 +513,9 @@ class Assembly:
""" """
# Guard: deduplicate — same pair in either order # Guard: deduplicate — same pair in either order
for c in self.connections: for c in self.connections:
if (c.first_ac_id == first_ac_id and c.second_ac_id == second_ac_id) or \ if (c.first_ac_id == first_ac_id and c.second_ac_id == second_ac_id) or (
(c.first_ac_id == second_ac_id and c.second_ac_id == first_ac_id): c.first_ac_id == second_ac_id and c.second_ac_id == first_ac_id
):
return c return c
conn = AssemblyConnection( conn = AssemblyConnection(
first_ac_id=first_ac_id, first_ac_id=first_ac_id,
@@ -526,8 +528,7 @@ class Assembly:
def remove_connections_for(self, ac_id: str) -> None: def remove_connections_for(self, ac_id: str) -> None:
"""Drop every connection that involves *ac_id* (e.g. on removal).""" """Drop every connection that involves *ac_id* (e.g. on removal)."""
self.connections = [ self.connections = [
c for c in self.connections c for c in self.connections if c.first_ac_id != ac_id and c.second_ac_id != ac_id
if c.first_ac_id != ac_id and c.second_ac_id != ac_id
] ]
def get_rigid_group(self, ac_id: str) -> List[str]: def get_rigid_group(self, ac_id: str) -> List[str]:
@@ -591,19 +592,14 @@ class Assembly:
# the rigid-group graph and point at a missing component. # the rigid-group graph and point at a missing component.
self.remove_connections_for(assembly_component_id) self.remove_connections_for(assembly_component_id)
if self.active_assembly_component == assembly_component_id: if self.active_assembly_component == assembly_component_id:
self.active_assembly_component = next( self.active_assembly_component = next(iter(self.components.keys()), None)
iter(self.components.keys()), None
)
self.modified_at = datetime.now() self.modified_at = datetime.now()
return True return True
return False return False
def get_active_instance(self) -> Optional[AssemblyComponent]: def get_active_instance(self) -> Optional[AssemblyComponent]:
"""Get the currently active assembly component instance.""" """Get the currently active assembly component instance."""
if ( if self.active_assembly_component and self.active_assembly_component in self.components:
self.active_assembly_component
and self.active_assembly_component in self.components
):
return self.components[self.active_assembly_component] return self.components[self.active_assembly_component]
return None return None
+111 -68
View File
@@ -39,47 +39,31 @@ class MitsubaBackend(RenderBackend):
# ── Scene construction ────────────────────────────────────────── # ── Scene construction ──────────────────────────────────────────
def _build_scene_dict( def _base_scene_dict(
self, self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera, camera: RenderCamera,
settings: RenderSettings, settings: RenderSettings,
first_mesh_path: Optional[str] = None,
) -> dict: ) -> dict:
"""Build a Mitsuba scene dictionary from our data classes. """Return a scene dict with everything *except* the shape entries.
Uses a 3-point lighting setup (key + fill + rim) plus an ambient When *first_mesh_path* is given the ground plane / backdrop is sized
environment emitter for soft fill, giving well-balanced shading on from its bounding box; otherwise a large default is used.
all faces of the model. Lighting intensities and colors come from
``settings.lighting``; ground plane comes from ``settings.ground_plane``.
""" """
import mitsuba as mi import mitsuba as mi
lighting = settings.lighting lighting = settings.lighting
ground = settings.ground_plane ground = settings.ground_plane
# Map our BSDF types to Mitsuba BSDF dicts
bsdf = self._make_bsdf(material)
# Determine mesh file type from extension
ext = os.path.splitext(mesh_path)[1].lower()
shape_type = "ply" if ext == ".ply" else "obj"
# Build camera-to-world transform using the Python API
cam_to_world = mi.ScalarTransform4f.look_at( cam_to_world = mi.ScalarTransform4f.look_at(
origin=list(camera.origin), origin=list(camera.origin),
target=list(camera.target), target=list(camera.target),
up=list(camera.up), up=list(camera.up),
) )
scene = { scene: dict = {
"type": "scene", "type": "scene",
# Integrator "integrator": {"type": "path", "max_depth": settings.max_depth},
"integrator": {
"type": "path",
"max_depth": settings.max_depth,
},
# Camera
"sensor": { "sensor": {
"type": "perspective", "type": "perspective",
"fov": camera.fov, "fov": camera.fov,
@@ -95,7 +79,6 @@ class MitsubaBackend(RenderBackend):
"sample_count": settings.spp, "sample_count": settings.spp,
}, },
}, },
# Ambient environment fill
"emitter": { "emitter": {
"type": "constant", "type": "constant",
"radiance": { "radiance": {
@@ -107,59 +90,50 @@ class MitsubaBackend(RenderBackend):
], ],
}, },
}, },
# Shape
"shape": {
"type": shape_type,
"filename": mesh_path,
"bsdf": bsdf,
},
} }
# ── 3-point lighting (colors and intensities from config) ─── # ── 3-point lighting ──────────────────────────────────────────
key_rgb = [c * lighting.key_intensity for c in lighting.key_color] key_rgb = [c * lighting.key_intensity for c in lighting.key_color]
key_to_world = mi.ScalarTransform4f.look_at(
origin=[1.0, -0.8, 1.2],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["key_light"] = { scene["key_light"] = {
"type": "directional", "type": "directional",
"to_world": key_to_world, "to_world": mi.ScalarTransform4f.look_at(
origin=[1.0, -0.8, 1.2],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
),
"irradiance": {"type": "rgb", "value": key_rgb}, "irradiance": {"type": "rgb", "value": key_rgb},
} }
fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color] fill_rgb = [c * lighting.fill_intensity for c in lighting.fill_color]
fill_to_world = mi.ScalarTransform4f.look_at(
origin=[-1.0, 0.6, 0.8],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["fill_light"] = { scene["fill_light"] = {
"type": "directional", "type": "directional",
"to_world": fill_to_world, "to_world": mi.ScalarTransform4f.look_at(
origin=[-1.0, 0.6, 0.8],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
),
"irradiance": {"type": "rgb", "value": fill_rgb}, "irradiance": {"type": "rgb", "value": fill_rgb},
} }
rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color] rim_rgb = [c * lighting.rim_intensity for c in lighting.rim_color]
rim_to_world = mi.ScalarTransform4f.look_at(
origin=[-0.3, 1.2, -0.8],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["rim_light"] = { scene["rim_light"] = {
"type": "directional", "type": "directional",
"to_world": rim_to_world, "to_world": mi.ScalarTransform4f.look_at(
origin=[-0.3, 1.2, -0.8],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
),
"irradiance": {"type": "rgb", "value": rim_rgb}, "irradiance": {"type": "rgb", "value": rim_rgb},
} }
# ── Ground plane / backdrop (optional) ───────────────────── # ── Ground plane / backdrop ───────────────────────────────────
if ground.enabled: if ground.enabled:
try: try:
# Load mesh to compute bounds for ground/backdrop placement ext = os.path.splitext(first_mesh_path)[1].lower() if first_mesh_path else ""
mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path}) shape_type = "ply" if ext == ".ply" else "obj"
mesh_shape = mi.load_dict({"type": shape_type, "filename": first_mesh_path})
bbox = mesh_shape.bbox() bbox = mesh_shape.bbox()
bbox_min, bbox_max = bbox[0], bbox[1] bbox_min, bbox_max = bbox[0], bbox[1]
# Ground at model's lowest Z with 0.1% offset
model_height = bbox_max[2] - bbox_min[2] model_height = bbox_max[2] - bbox_min[2]
ground_z = bbox_min[2] - 0.001 * model_height ground_z = bbox_min[2] - 0.001 * model_height
dx = bbox_max[0] - bbox_min[0] dx = bbox_max[0] - bbox_min[0]
@@ -167,7 +141,6 @@ class MitsubaBackend(RenderBackend):
dz = bbox_max[2] - bbox_min[2] dz = bbox_max[2] - bbox_min[2]
diag = float((dx * dx + dy * dy + dz * dz) ** 0.5) diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
except Exception: except Exception:
# Fallback: place at origin with large default size
ground_z = -ground.distance_below ground_z = -ground.distance_below
diag = 1000.0 diag = 1000.0
@@ -177,19 +150,15 @@ class MitsubaBackend(RenderBackend):
} }
if ground.curved_backdrop: if ground.curved_backdrop:
# Photo booth style curved leinwand: half_size = diag * 50.0
# - Flat floor section in front of the model radius = diag * 3.0
# - Curved cylinder behind that sweeps up and over cyl_height = diag * 20.0
half_size = diag * 50.0 # huge floor
radius = diag * 3.0 # curvature radius
cyl_height = diag * 20.0 # width of the cylinder (along its axis)
scene["ground_floor"] = { scene["ground_floor"] = {
"type": "rectangle", "type": "rectangle",
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z]) "to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]), @ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
"bsdf": bsdf_ground, "bsdf": bsdf_ground,
} }
# Cylinder: axis along Y, positioned behind model, radius sweeps up
scene["ground_backdrop"] = { scene["ground_backdrop"] = {
"type": "cylinder", "type": "cylinder",
"radius": radius, "radius": radius,
@@ -200,7 +169,6 @@ class MitsubaBackend(RenderBackend):
"bsdf": bsdf_ground, "bsdf": bsdf_ground,
} }
else: else:
# Simple flat ground plane — very large so edges aren't visible
half_size = diag * 50.0 half_size = diag * 50.0
scene["ground_plane"] = { scene["ground_plane"] = {
"type": "rectangle", "type": "rectangle",
@@ -211,6 +179,48 @@ class MitsubaBackend(RenderBackend):
return scene return scene
def _build_scene_dict(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
) -> dict:
"""Build a single-shape Mitsuba scene dictionary."""
ext = os.path.splitext(mesh_path)[1].lower()
shape_type = "ply" if ext == ".ply" else "obj"
scene = self._base_scene_dict(camera, settings, first_mesh_path=mesh_path)
scene["shape"] = {
"type": shape_type,
"filename": mesh_path,
"bsdf": self._make_bsdf(material),
}
return scene
def _build_assembly_scene_dict(
self,
parts: list,
camera: RenderCamera,
settings: RenderSettings,
) -> dict:
"""Build a multi-shape Mitsuba scene dictionary.
*parts* is a list of ``(mesh_path, RenderMaterial)`` tuples.
"""
first_path = parts[0][0] if parts else None
scene = self._base_scene_dict(camera, settings, first_mesh_path=first_path)
for i, (mesh_path, material) in enumerate(parts):
ext = os.path.splitext(mesh_path)[1].lower()
shape_type = "ply" if ext == ".ply" else "obj"
scene[f"shape_{i}"] = {
"type": shape_type,
"filename": mesh_path,
"bsdf": self._make_bsdf(material),
}
return scene
def _make_bsdf(self, material: RenderMaterial) -> dict: def _make_bsdf(self, material: RenderMaterial) -> dict:
"""Convert a RenderMaterial to a Mitsuba BSDF dict.""" """Convert a RenderMaterial to a Mitsuba BSDF dict."""
mt = material.bsdf_type mt = material.bsdf_type
@@ -282,9 +292,8 @@ class MitsubaBackend(RenderBackend):
logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp") logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
# Render
try: try:
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0)) # type: ignore[arg-type] # Mitsuba accepts int at runtime image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0))
except Exception as e: except Exception as e:
logger.error(f"Mitsuba render failed: {e}") logger.error(f"Mitsuba render failed: {e}")
raise raise
@@ -292,14 +301,48 @@ class MitsubaBackend(RenderBackend):
if progress_callback: if progress_callback:
progress_callback(1.0) progress_callback(1.0)
# Convert to numpy (H, W, 3)
arr = np.array(image, dtype=np.float32) arr = np.array(image, dtype=np.float32)
# Apply approximate sRGB tonemapping
arr = np.clip(arr, 0.0, None) arr = np.clip(arr, 0.0, None)
arr = np.power(arr, 1.0 / 2.2) # gamma arr = np.power(arr, 1.0 / 2.2)
arr = np.clip(arr, 0.0, 1.0) arr = np.clip(arr, 0.0, 1.0)
return arr
def render_assembly(
self,
parts: list,
camera: RenderCamera,
settings: RenderSettings,
progress_callback: Optional[Callable[[float], None]] = None,
) -> np.ndarray:
"""Render multiple meshes with individual materials.
*parts* is a list of ``(mesh_path, RenderMaterial)`` tuples.
Returns (H, W, 3) float32 RGB array.
"""
self._set_variant()
import mitsuba as mi
scene_dict = self._build_assembly_scene_dict(parts, camera, settings)
scene = mi.load_dict(scene_dict)
logger.info(
f"Rendering assembly ({len(parts)} parts) "
f"{settings.width}x{settings.height} @ {settings.spp} spp"
)
try:
image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0))
except Exception as e:
logger.error(f"Mitsuba assembly render failed: {e}")
raise
if progress_callback:
progress_callback(1.0)
arr = np.array(image, dtype=np.float32)
arr = np.clip(arr, 0.0, None)
arr = np.power(arr, 1.0 / 2.2)
arr = np.clip(arr, 0.0, 1.0)
return arr return arr
def render_preview( def render_preview(
+88 -41
View File
@@ -17,6 +17,86 @@ from .base import Renderer, RenderObject, RenderColor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _compute_viewport_aligned_xdir(
normal: Tuple[float, float, float],
view: Any = None,
) -> Tuple[float, float, float]:
"""Compute an in-plane x_dir that appears screen-horizontal.
When *view* (a V3d_View) is provided, derives a "screen-right" direction
from the camera's Eye/At/Up vectors and projects it onto the face plane.
This makes the sketch U-axis align with what the user sees as horizontal
on screen, eliminating the 90° rotation caused by OCC internal edge order.
Falls back to projecting world +X onto the plane when view is None
(headless tests or unavailable camera state).
"""
import numpy as np
n = np.asarray(normal, dtype=float)
n /= np.linalg.norm(n)
# Derive screen-right from camera: cross(view_up, -view_dir) gives the
# direction that appears horizontal on screen.
if view is not None:
try:
eye_obj = view.Eye()
at_obj = view.At()
up_obj = view.Up()
eye = np.array([eye_obj.X(), eye_obj.Y(), eye_obj.Z()], dtype=float)
at = np.array([at_obj.X(), at_obj.Y(), at_obj.Z()], dtype=float)
up = np.array([up_obj.X(), up_obj.Y(), up_obj.Z()], dtype=float)
view_dir = at - eye
vd_norm = np.linalg.norm(view_dir)
if vd_norm > 1e-9:
view_dir /= vd_norm
else:
view_dir = np.array([0.0, 0.0, 1.0])
up_norm = np.linalg.norm(up)
if up_norm < 1e-9:
up = np.array([0.0, 1.0, 0.0])
else:
up /= up_norm
# Screen-right in world coords: cross(view_up, view_dir) gives
# the direction that appears horizontal on screen.
screen_right = np.cross(up, view_dir)
sr_norm = np.linalg.norm(screen_right)
if sr_norm > 1e-9:
screen_right /= sr_norm
else:
screen_right = np.array([1.0, 0.0, 0.0])
except Exception:
screen_right = np.array([1.0, 0.0, 0.0])
else:
# No camera available — use world +X as best guess.
screen_right = np.array([1.0, 0.0, 0.0])
# Project screen-right onto the face plane (remove normal component).
x_dir = screen_right - np.dot(screen_right, n) * n
xn = np.linalg.norm(x_dir)
if xn < 1e-9:
# screen_right is parallel to normal — pick any orthogonal direction.
fallback = np.array([0.0, 1.0, 0.0])
x_dir = fallback - np.dot(fallback, n) * n
xn = np.linalg.norm(x_dir)
if xn > 1e-9:
unit = x_dir / xn
return (float(unit[0]), float(unit[1]), float(unit[2]))
# Last resort: cross with any non-parallel axis.
for ax in (np.array([1, 0, 0]), np.array([0, 1, 0]), np.array([0, 0, 1])):
x_dir = ax - np.dot(ax, n) * n
xn = np.linalg.norm(x_dir)
if xn > 1e-9:
unit = x_dir / xn
return (float(unit[0]), float(unit[1]), float(unit[2]))
# Should never reach here for a valid normal.
return (1.0, 0.0, 0.0)
@dataclass @dataclass
class OCCRenderObject(RenderObject): class OCCRenderObject(RenderObject):
"""Internal object state for the OCC renderer.""" """Internal object state for the OCC renderer."""
@@ -1046,28 +1126,12 @@ class OCCRenderer(Renderer):
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
origin = (cx - d * nx, cy - d * ny, cz - d * nz) origin = (cx - d * nx, cy - d * ny, cz - d * nz)
# x_dir: direction of the face's first edge (stable, in-plane). # x_dir: project a "screen-horizontal" direction onto the face plane
x_dir = None # so that the sketch's U-axis appears horizontal in the current 3D
try: # viewport. This eliminates the 90° rotation that occurred when we
from OCP.TopExp import TopExp # used the first edge / plane X axis (which follow OCC internal order,
from OCP.BRep import BRep_Tool # not the viewer perspective).
expl = TopExp_Explorer(face, TopAbs_EDGE) x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
if expl.More():
edge = TopoDS.Edge_s(expl.Current())
v1 = TopExp.FirstVertex_s(edge, True)
v2 = TopExp.LastVertex_s(edge, True)
p1 = BRep_Tool.Pnt_s(v1)
p2 = BRep_Tool.Pnt_s(v2)
ex, ey, ez = p2.X() - p1.X(), p2.Y() - p1.Y(), p2.Z() - p1.Z()
elen = (ex * ex + ey * ey + ez * ez) ** 0.5
if elen > 1e-9:
x_dir = (ex / elen, ey / elen, ez / elen)
except Exception:
pass
if x_dir is None:
# Fall back to the plane's own X axis.
px = pln.XAxis().Direction()
x_dir = (px.X(), px.Y(), px.Z())
# Identify the displayed body that owns this face, so the host can # Identify the displayed body that owns this face, so the host can
# auto-target it as the cut/union body when the user extrudes the # auto-target it as the cut/union body when the user extrudes the
@@ -1258,25 +1322,8 @@ class OCCRenderer(Renderer):
d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
origin = (cx - d * nx, cy - d * ny, cz - d * nz) origin = (cx - d * nx, cy - d * ny, cz - d * nz)
# x_dir from first edge. # x_dir: viewport-aligned so connector gizmo matches screen.
x_dir = None x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
try:
expl = TopExp_Explorer(face, TopAbs_EDGE_TYPE)
if expl.More():
edge = TopoDS.Edge_s(expl.Current())
v1 = TopExp.FirstVertex_s(edge, True)
v2 = TopExp.LastVertex_s(edge, True)
p1 = BRep_Tool.Pnt_s(v1)
p2 = BRep_Tool.Pnt_s(v2)
ex, ey, ez = p2.X() - p1.X(), p2.Y() - p1.Y(), p2.Z() - p1.Z()
elen = (ex * ex + ey * ey + ez * ez) ** 0.5
if elen > 1e-9:
x_dir = (ex / elen, ey / elen, ez / elen)
except Exception:
pass
if x_dir is None:
px = pln.XAxis().Direction()
x_dir = (px.X(), px.Y(), px.Z())
return [{ return [{
"type": "planar_face", "type": "planar_face",
+417 -90
View File
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
QMenu, QMenu,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QScrollArea,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@@ -56,6 +57,221 @@ from gui_ui import Ui_fluencyCAD # auto-generated Qt form (project root on sys.
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _make_body_thumbnail(
body,
kernel,
size: QSize = QSize(64, 48),
):
"""Render a small isometric thumbnail of a body using Pillow.
Returns a QPixmap or None on failure.
"""
try:
import numpy as np
from PIL import Image, ImageDraw
from PySide6.QtGui import QImage, QPixmap
verts, faces = body.get_mesh(kernel)
if verts is None or len(verts) == 0:
return None
verts = np.asarray(verts, dtype=np.float64)
faces = np.asarray(faces, dtype=np.int32)
mins = verts.min(axis=0)
maxs = verts.max(axis=0)
center = (mins + maxs) / 2
extent = maxs - mins
max_dim = max(extent)
if max_dim < 1e-10:
return None
# Normalize vertices to [-1, 1] range centered at origin
v = (verts - center) / (max_dim * 0.7)
# Simple isometric projection (rotation + orthographic)
angle_y = np.radians(-45)
angle_x = np.radians(25)
cos_y, sin_y = np.cos(angle_y), np.sin(angle_y)
cos_x, sin_x = np.cos(angle_x), np.sin(angle_x)
# Rotate Y
x1 = v[:, 0] * cos_y - v[:, 2] * sin_y
z1 = v[:, 0] * sin_y + v[:, 2] * cos_y
y1 = v[:, 1]
# Rotate X
y2 = y1 * cos_x - z1 * sin_x
z2 = y1 * sin_x + z1 * cos_x
x2 = x1
# Project to 2D (orthographic)
w_px, h_px = size.width(), size.height()
# Compute 2D bounding box of projected vertices for tight framing
all_px = x2
all_py = -y2
px_min, px_max = all_px.min(), all_px.max()
py_min, py_max = all_py.min(), all_py.max()
span_x = px_max - px_min
span_y = py_max - py_min
if span_x < 1e-10 or span_y < 1e-10:
return None
# Scale to fill ~90% of the image
margin = 0.10
avail_w = w_px * (1.0 - margin)
avail_h = h_px * (1.0 - margin)
scale = min(avail_w / span_x, avail_h / span_y)
center_x = (px_min + px_max) / 2
center_y = (py_min + py_max) / 2
px = (all_px * scale + w_px / 2 - center_x * scale).astype(np.float64)
py = (all_py * scale + h_px / 2 - center_y * scale).astype(np.float64)
depth = z2 # for painter's algorithm
# Dark grey background
img = Image.new("RGBA", (w_px, h_px), (55, 55, 60, 255))
draw = ImageDraw.Draw(img)
# Compute face normals for backface culling & shading
v0 = np.stack([px[faces[:, 0]], py[faces[:, 0]], depth[faces[:, 0]]], axis=1)
v1 = np.stack([px[faces[:, 1]], py[faces[:, 1]], depth[faces[:, 1]]], axis=1)
v2 = np.stack([px[faces[:, 2]], py[faces[:, 2]], depth[faces[:, 2]]], axis=1)
# 2D cross product for winding
cross = (v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) - (v1[:, 1] - v0[:, 1]) * (
v2[:, 0] - v0[:, 0]
)
# Average depth per face
avg_depth = (v0[:, 2] + v1[:, 2] + v2[:, 2]) / 3.0
# Sort faces by depth (painter's algorithm: draw far faces first)
order = np.argsort(-avg_depth)
# Ceramic white body with shading
base_r, base_g, base_b = 220, 218, 215
for i in order:
# Backface culling
if cross[i] <= 0:
continue
pts = [
(float(px[faces[i, 0]]), float(py[faces[i, 0]])),
(float(px[faces[i, 1]]), float(py[faces[i, 1]])),
(float(px[faces[i, 2]]), float(py[faces[i, 2]])),
]
# Shading: stronger contrast for depth perception
brightness = 0.5 + 0.5 * max(0.0, min(1.0, (avg_depth[i] + 1) / 2))
r = int(base_r * brightness)
g = int(base_g * brightness)
b = int(base_b * brightness)
draw.polygon(pts, fill=(r, g, b, 255))
# Convert PIL image to QPixmap
data = img.tobytes("raw", "RGBA")
qimg = QImage(data, w_px, h_px, w_px * 4, QImage.Format_RGBA8888)
pixmap = QPixmap.fromImage(qimg.copy())
return pixmap
except Exception as e:
logger.debug(f"Thumbnail generation failed: {e}")
return None
# ── Button sizing & styling constants ────────────────────────────────
_BTN_MIN = 40 # minimum button dimension (px)
_BTN_MAX = 160 # maximum button dimension (px)
_BTN_PAD = 4 # padding between icon and button edge
_BTN_UNSELECTED_STYLE = (
"QPushButton {"
" background-color: #3a3a3e;"
" border: 2px solid #555560;"
" border-radius: 6px;"
"}"
"QPushButton:hover {"
" background-color: #4a4a50;"
"}"
"QPushButton:checked {"
" background-color: #2d5f8a;"
" border: 2px solid #4da6ff;"
"}"
)
def _set_button_style(btn: QPushButton) -> None:
"""Apply the standard button style sheet."""
btn.setStyleSheet(_BTN_UNSELECTED_STYLE)
def _resize_row_buttons(scroll_area: QScrollArea, buttons: list[QPushButton]) -> None:
"""Resize every button in *buttons* to fill the scroll area height.
Computes a square size from the parent viewport height so buttons
always use all available vertical space. Width follows height
(11 aspect) clamped to [_BTN_MIN, _BTN_MAX].
"""
vh = scroll_area.viewport().height()
if vh < 20 or not buttons:
return
size = max(_BTN_MIN, min(_BTN_MAX, vh - 8)) # 8 px margin
for btn in buttons:
btn.setFixedSize(QSize(size, size))
icon_size = QSize(size - _BTN_PAD, size - _BTN_PAD)
if btn.icon().isNull():
continue
btn.setIconSize(icon_size)
def _scroll_to_button(btn: QPushButton, scroll_area: QScrollArea) -> None:
"""Ensure *btn* is visible inside a horizontal scroll area."""
ha = scroll_area.horizontalScrollBar()
viewport_w = scroll_area.viewport().width()
parent = btn.parent()
if parent:
x = btn.pos().x()
right = x + btn.width()
scroll_left = ha.value()
scroll_right = scroll_left + viewport_w
if x < scroll_left:
ha.setValue(x)
elif right > scroll_right:
ha.setValue(right - viewport_w)
def _create_component_button(
num: int,
name: str,
body,
kernel,
group: QButtonGroup,
layout: QHBoxLayout,
click_handler,
scroll_area: QScrollArea,
) -> QPushButton:
"""Create a component button with thumbnail."""
btn = QPushButton(str(num))
btn.setCheckable(True)
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(name)
btn.clicked.connect(click_handler)
_set_button_style(btn)
pixmap = _make_body_thumbnail(body, kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
btn.setText("")
group.addButton(btn)
layout.addWidget(btn)
return btn
def _project_body_to_workplane( def _project_body_to_workplane(
body_shape: Any, body_shape: Any,
workplane: Tuple[ workplane: Tuple[
@@ -388,32 +604,51 @@ class MainWindow(QMainWindow):
self._ui.InputTab.addTab(self._render_tab, "Render") self._ui.InputTab.addTab(self._render_tab, "Render")
# Component buttons (dynamically generated per component, not in UI). # Component buttons (dynamically generated per component, not in UI).
# Wrapped in a QScrollArea so many components can scroll horizontally.
self._component_box = QWidget() self._component_box = QWidget()
self._component_box_layout = QHBoxLayout(self._component_box) self._component_box_layout = QHBoxLayout(self._component_box)
self._component_box_layout.setAlignment(Qt.AlignLeft) self._component_box_layout.setAlignment(Qt.AlignLeft)
self._component_box_layout.setContentsMargins(2, 2, 2, 2)
self._component_group = QButtonGroup(self) self._component_group = QButtonGroup(self)
self._component_group.setExclusive(True) self._component_group.setExclusive(True)
self._component_scroll = QScrollArea()
self._component_scroll.setWidget(self._component_box)
self._component_scroll.setWidgetResizable(True)
self._component_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self._component_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._component_scroll.setFrameShape(QFrame.NoFrame)
self._component_scroll.setMaximumHeight(95)
# Add to the Components group box from the UI. # Add to the Components group box from the UI.
compo_layout = self._ui.compo_box.layout() compo_layout = self._ui.compo_box.layout()
if compo_layout is None: if compo_layout is None:
compo_layout = QHBoxLayout(self._ui.compo_box) compo_layout = QHBoxLayout(self._ui.compo_box)
compo_layout.setContentsMargins(0, 0, 0, 0) compo_layout.setContentsMargins(0, 0, 0, 0)
compo_layout.addWidget(self._component_box) compo_layout.addWidget(self._component_scroll)
compo_layout.addStretch()
# ── Assembly box (dynamic buttons like component box) ── # ── Assembly box (dynamic buttons like component box) ──
self._assembly_box = QWidget() self._assembly_box = QWidget()
self._assembly_box_layout = QHBoxLayout(self._assembly_box) self._assembly_box_layout = QHBoxLayout(self._assembly_box)
self._assembly_box_layout.setAlignment(Qt.AlignLeft) self._assembly_box_layout.setAlignment(Qt.AlignLeft)
self._assembly_box_layout.setContentsMargins(2, 2, 2, 2)
self._assembly_component_group = QButtonGroup(self) self._assembly_component_group = QButtonGroup(self)
self._assembly_component_group.setExclusive(True) self._assembly_component_group.setExclusive(True)
self._assembly_scroll = QScrollArea()
self._assembly_scroll.setWidget(self._assembly_box)
self._assembly_scroll.setWidgetResizable(True)
self._assembly_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self._assembly_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._assembly_scroll.setFrameShape(QFrame.NoFrame)
self._assembly_scroll.setMaximumHeight(95)
# Add to the Assembly group box from the UI. # Add to the Assembly group box from the UI.
asm_layout = self._ui.assembly_box.layout() asm_layout = self._ui.assembly_box.layout()
if asm_layout is None: if asm_layout is None:
asm_layout = QHBoxLayout(self._ui.assembly_box) asm_layout = QHBoxLayout(self._ui.assembly_box)
asm_layout.setContentsMargins(0, 0, 0, 0) asm_layout.setContentsMargins(0, 0, 0, 0)
asm_layout.addWidget(self._assembly_box) asm_layout.addWidget(self._assembly_scroll)
asm_layout.addStretch()
# ── Assembly Move button (programmatic, in assembly_tools) ── # ── Assembly Move button (programmatic, in assembly_tools) ──
self._btn_asm_move = QPushButton("Pos") self._btn_asm_move = QPushButton("Pos")
@@ -425,7 +660,7 @@ class MainWindow(QMainWindow):
) )
asm_tools_layout = self._ui.assembly_tools.layout() asm_tools_layout = self._ui.assembly_tools.layout()
if asm_tools_layout is not None: if asm_tools_layout is not None:
asm_tools_layout.addWidget(self._btn_asm_move, 0, 2, 1, 1) asm_tools_layout.addWidget(self._btn_asm_move)
# Panel-focus mode (equal | sketch | viewer). # Panel-focus mode (equal | sketch | viewer).
self._panel_focus: str = "equal" self._panel_focus: str = "equal"
@@ -755,6 +990,24 @@ class MainWindow(QMainWindow):
return i return i
return 0 return 0
def _update_component_thumbnail(self, component_index: int) -> None:
"""Update the thumbnail icon on a component button."""
if not (0 <= component_index < len(self._component_buttons)):
return
comp_ids = list(self._project.components.keys())
if component_index >= len(comp_ids):
return
comp = self._project.components[comp_ids[component_index]]
first_body = next(iter(comp.bodies.values()), None)
if not first_body or not first_body.geometry:
return
btn = self._component_buttons[component_index]
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
btn.setText("")
def _new_component(self): def _new_component(self):
logger.info("=== NEW COMPONENT ===") logger.info("=== NEW COMPONENT ===")
comp = self._project.add_component() comp = self._project.add_component()
@@ -762,11 +1015,14 @@ class MainWindow(QMainWindow):
self._mark_dirty() self._mark_dirty()
logger.info(f"Created component: {comp.name}") logger.info(f"Created component: {comp.name}")
btn = QPushButton(str(len(self._project.components))) btn_num = len(self._project.components)
btn = QPushButton(str(btn_num))
btn.setCheckable(True) btn.setCheckable(True)
btn.setFixedSize(QSize(40, 40)) btn.setFixedSize(QSize(100, 100))
btn.setToolTip(comp.name)
btn.clicked.connect(self._on_component_button_clicked) btn.clicked.connect(self._on_component_button_clicked)
btn.setChecked(True) btn.setChecked(True)
_set_button_style(btn)
for b in self._component_buttons: for b in self._component_buttons:
b.setChecked(False) b.setChecked(False)
@@ -774,6 +1030,8 @@ class MainWindow(QMainWindow):
self._component_buttons.append(btn) self._component_buttons.append(btn)
self._component_group.addButton(btn) self._component_group.addButton(btn)
self._component_box_layout.addWidget(btn) self._component_box_layout.addWidget(btn)
# Scroll to the new button.
_scroll_to_button(btn, self._component_scroll)
self._refresh_lists() self._refresh_lists()
logger.info(f"Created component: {comp.name}") logger.info(f"Created component: {comp.name}")
@@ -804,6 +1062,9 @@ class MainWindow(QMainWindow):
self._assembly_view_active = False self._assembly_view_active = False
self._refresh_lists() self._refresh_lists()
self._redraw_bodies() self._redraw_bodies()
# Scroll to the selected button.
if 0 <= idx < len(self._component_buttons):
_scroll_to_button(self._component_buttons[idx], self._component_scroll)
def _refresh_lists(self): def _refresh_lists(self):
self._sketch_list.clear() self._sketch_list.clear()
@@ -846,6 +1107,7 @@ class MainWindow(QMainWindow):
self._propagate_to_assembly() self._propagate_to_assembly()
self._recalculate_connectors() self._recalculate_connectors()
self._refresh_lists() self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
def _update_bodies_from_sketch(self): def _update_bodies_from_sketch(self):
"""Re-extrude bodies whose source sketch has been edited. """Re-extrude bodies whose source sketch has been edited.
@@ -1121,11 +1383,22 @@ class MainWindow(QMainWindow):
label = f"{instance_num}" label = f"{instance_num}"
btn = QPushButton(label) btn = QPushButton(label)
btn.setCheckable(True) btn.setCheckable(True)
btn.setFixedSize(QSize(40, 40)) btn.setFixedSize(QSize(100, 100))
btn.setToolTip(f"{ac.name} (instance {list(assembly.components.keys()).index(ac.id) + 1})") btn.setToolTip(f"{ac.name} (instance {list(assembly.components.keys()).index(ac.id) + 1})")
# Store the assembly component id in the button. # Store the assembly component id in the button.
btn._assembly_component_id = ac.id btn._assembly_component_id = ac.id
btn.clicked.connect(self._on_assembly_component_clicked) btn.clicked.connect(self._on_assembly_component_clicked)
_set_button_style(btn)
# Thumbnail from the component's first body.
src_comp = self._current_component
if src_comp:
first_body = next(iter(src_comp.bodies.values()), None)
if first_body and first_body.geometry:
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
# Uncheck all other assembly buttons, check this one. # Uncheck all other assembly buttons, check this one.
for b in self._assembly_component_buttons: for b in self._assembly_component_buttons:
@@ -1213,6 +1486,12 @@ class MainWindow(QMainWindow):
self._show_assembly_in_viewer(fit=True) self._show_assembly_in_viewer(fit=True)
# Scroll to the selected button.
for btn in self._assembly_component_buttons:
if getattr(btn, "_assembly_component_id", None) == active_id:
_scroll_to_button(btn, self._assembly_scroll)
break
def _apply_transform(self, shape: Any, position, rotation) -> Any: def _apply_transform(self, shape: Any, position, rotation) -> Any:
"""Apply a position translation and rotation matrix to a shape. """Apply a position translation and rotation matrix to a shape.
@@ -3419,6 +3698,10 @@ class MainWindow(QMainWindow):
logger.warning("No sketch entity found") logger.warning("No sketch entity found")
QMessageBox.warning(self, "No Sketch", "Please create a sketch first") QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
return return
if sketch is None:
sketch = Sketch()
self._current_sketch = sketch
self._current_component.add_sketch(sketch)
sketch.occ_sketch = sketch_entity sketch.occ_sketch = sketch_entity
# Resolve the profile geometry *before* opening the dialog so the # Resolve the profile geometry *before* opening the dialog so the
@@ -3526,6 +3809,7 @@ class MainWindow(QMainWindow):
body_name = body.name body_name = body.name
self._refresh_lists() self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera() self._viewer_3d.fit_camera()
logger.info(f"Extruded: {body_name}") logger.info(f"Extruded: {body_name}")
logger.info("=== EXTRUDE COMPLETE ===") logger.info("=== EXTRUDE COMPLETE ===")
@@ -3546,6 +3830,10 @@ class MainWindow(QMainWindow):
if not sketch_entity: if not sketch_entity:
QMessageBox.warning(self, "No Sketch", "Please create a sketch first") QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
return return
if sketch is None:
sketch = Sketch()
self._current_sketch = sketch
self._current_component.add_sketch(sketch)
sketch.occ_sketch = sketch_entity sketch.occ_sketch = sketch_entity
dialog = RevolveDialog(self) dialog = RevolveDialog(self)
@@ -3582,6 +3870,7 @@ class MainWindow(QMainWindow):
logger.info(f"Render object: {body.render_object}") logger.info(f"Render object: {body.render_object}")
self._refresh_lists() self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera() self._viewer_3d.fit_camera()
logger.info(f"Revolved: {body.name}") logger.info(f"Revolved: {body.name}")
@@ -3656,6 +3945,7 @@ class MainWindow(QMainWindow):
logger.info(f"Render object: {new_body.render_object}") logger.info(f"Render object: {new_body.render_object}")
self._refresh_lists() self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera() self._viewer_3d.fit_camera()
logger.info(f"Cut complete: {new_body.name}") logger.info(f"Cut complete: {new_body.name}")
@@ -3696,6 +3986,7 @@ class MainWindow(QMainWindow):
logger.info(f"Render object: {new_body.render_object}") logger.info(f"Render object: {new_body.render_object}")
self._refresh_lists() self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera() self._viewer_3d.fit_camera()
logger.info(f"Union complete: {new_body.name}") logger.info(f"Union complete: {new_body.name}")
@@ -4063,15 +4354,30 @@ class MainWindow(QMainWindow):
self._viewer_3d.clear_scene() self._viewer_3d.clear_scene()
self._refresh_lists() self._refresh_lists()
# Rebuild component buttons (one per component, numbered). # Rebuild component buttons (one per component, with thumbnails).
for idx, comp in enumerate(self._project.components.values(), start=1): for idx, comp in enumerate(self._project.components.values(), start=1):
btn = QPushButton(str(idx)) first_body = next(iter(comp.bodies.values()), None)
btn.setCheckable(True) if first_body and first_body.geometry:
btn.setFixedSize(QSize(40, 40)) btn = _create_component_button(
btn.clicked.connect(self._on_component_button_clicked) idx,
comp.name,
first_body,
self._kernel,
self._component_group,
self._component_box_layout,
self._on_component_button_clicked,
self._component_scroll,
)
else:
btn = QPushButton(str(idx))
btn.setCheckable(True)
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(comp.name)
btn.clicked.connect(self._on_component_button_clicked)
_set_button_style(btn)
self._component_group.addButton(btn)
self._component_box_layout.addWidget(btn)
self._component_buttons.append(btn) self._component_buttons.append(btn)
self._component_group.addButton(btn)
self._component_box_layout.addWidget(btn)
# Pick which component to activate: explicit saved selection, # Pick which component to activate: explicit saved selection,
# falling back to the project's active_component, then the first. # falling back to the project's active_component, then the first.
@@ -4097,10 +4403,22 @@ class MainWindow(QMainWindow):
instance_num = len(self._assembly_component_buttons) + 1 instance_num = len(self._assembly_component_buttons) + 1
btn = QPushButton(str(instance_num)) btn = QPushButton(str(instance_num))
btn.setCheckable(True) btn.setCheckable(True)
btn.setFixedSize(QSize(40, 40)) btn.setFixedSize(QSize(100, 100))
btn.setToolTip(f"{ac.name} (instance {instance_num})") btn.setToolTip(f"{ac.name} (instance {instance_num})")
btn._assembly_component_id = ac.id btn._assembly_component_id = ac.id
btn.clicked.connect(self._on_assembly_component_clicked) btn.clicked.connect(self._on_assembly_component_clicked)
_set_button_style(btn)
# Thumbnail from the source component's first body.
src_comp = self._project.components.get(ac.component_id)
if src_comp:
first_body = next(iter(src_comp.bodies.values()), None)
if first_body and first_body.geometry:
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
self._assembly_component_buttons.append(btn) self._assembly_component_buttons.append(btn)
self._assembly_component_group.addButton(btn) self._assembly_component_group.addButton(btn)
self._assembly_box_layout.addWidget(btn) self._assembly_box_layout.addWidget(btn)
@@ -4166,32 +4484,59 @@ class MainWindow(QMainWindow):
filepath, _ = QFileDialog.getOpenFileName( filepath, _ = QFileDialog.getOpenFileName(
self, "Import File", "", "STEP Files (*.step *.stp);;IGES Files (*.iges *.igs)" self, "Import File", "", "STEP Files (*.step *.stp);;IGES Files (*.iges *.igs)"
) )
if filepath: if not filepath:
try: return
if filepath.lower().endswith((".step", ".stp")):
geometry = self._kernel.import_step(filepath)
else:
geometry = self._kernel.import_iges(filepath)
if not self._current_component: try:
self._current_component = self._project.add_component() if filepath.lower().endswith((".step", ".stp")):
parts = self._kernel.import_step_components(filepath)
else:
geometry = self._kernel.import_iges(filepath)
parts = [("Imported", geometry)]
body = self._current_component.add_body( num_before = len(self._project.components)
Body(name="Imported", geometry=geometry, source_operation="import")
) for name, geometry in parts:
self._mark_dirty() comp = self._project.add_component(Component(name=name))
self._current_component = comp
body = comp.add_body(Body(name=name, geometry=geometry, source_operation="import"))
vertices, faces = body.get_mesh(self._kernel) vertices, faces = body.get_mesh(self._kernel)
body.render_object = self._viewer_3d.add_mesh( body.render_object = self._viewer_3d.add_mesh(
vertices, faces, body.color, body.name vertices, faces, body.color, body.name
) )
self._refresh_lists() # Add a numbered button to the component bar
self._viewer_3d.fit_camera() btn_num = len(self._project.components)
logger.info(f"Imported: {filepath}") btn = _create_component_button(
btn_num,
name,
body,
self._kernel,
self._component_group,
self._component_box_layout,
self._on_component_button_clicked,
self._component_scroll,
)
self._component_buttons.append(btn)
except Exception as e: # Select the first newly imported component
QMessageBox.critical(self, "Error", f"Failed to import: {e}") if self._component_buttons and num_before < len(self._component_buttons):
for b in self._component_buttons:
b.setChecked(False)
self._component_buttons[num_before].setChecked(True)
self._current_component = self._project.components[
list(self._project.components.keys())[num_before]
]
self._refresh_lists()
self._viewer_3d.fit_camera()
self._mark_dirty()
logger.info(f"Imported {len(parts)} part(s) from {filepath}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to import: {e}")
def _export_step(self): def _export_step(self):
if not self._selected_body: if not self._selected_body:
@@ -4241,32 +4586,21 @@ class MainWindow(QMainWindow):
def _open_render_window(self): def _open_render_window(self):
"""Populate the render tab with the selected body or assembly and switch to it.""" """Populate the render tab with the selected body or assembly and switch to it."""
shape = None # Collect all visible bodies across all components
# Try selected body first assembly_parts = [] # list of (TopoDS_Shape, Optional[str])
if self._selected_body and self._selected_body.geometry: single_shape = None
try:
shape = self._kernel._get_shape(self._selected_body.geometry)
except Exception as e:
logger.warning(f"Failed to get shape for render: {e}")
# Try selected assembly component for comp in self._project.components.values():
if shape is None and self._selected_assembly_component_id: for body in comp.bodies.values():
ac_id = self._selected_assembly_component_id if not body.visible or not body.geometry:
for assembly in self._project.assemblies.values(): continue
ac = assembly.components.get(ac_id) try:
if ac and ac.component_id: occ_shape = self._kernel._get_shape(body.geometry)
src_comp = self._project.components.get(ac.component_id) assembly_parts.append((occ_shape, body.render_material))
if src_comp: except Exception as e:
for body in src_comp.bodies.values(): logger.warning(f"Failed to get shape for render: {e}")
if body.geometry:
try:
shape = self._kernel._get_shape(body.geometry)
break
except Exception:
pass
break
if shape is None: if not assembly_parts:
QMessageBox.information( QMessageBox.information(
self, self,
"Render", "Render",
@@ -4274,16 +4608,12 @@ class MainWindow(QMainWindow):
) )
return return
# Capture the current 3D viewport camera so the render matches what the user sees. # Capture the current 3D viewport camera
# get_render_camera() handles both perspective and orthographic modes — in
# orthographic mode it translates view.Scale() (mouse-wheel zoom) into a
# proper perspective camera distance, so the render framing matches the viewport.
try: try:
renderer = self._viewer_3d.get_renderer() renderer = self._viewer_3d.get_renderer()
if hasattr(renderer, "get_render_camera"): if hasattr(renderer, "get_render_camera"):
viewport_camera = renderer.get_render_camera() viewport_camera = renderer.get_render_camera()
else: else:
# Fallback for pygfx or other renderers without the method.
from fluency.rendering.render_backend import RenderCamera from fluency.rendering.render_backend import RenderCamera
eye, at_, up = self._viewer_3d.get_camera_position() eye, at_, up = self._viewer_3d.get_camera_position()
@@ -4297,38 +4627,31 @@ class MainWindow(QMainWindow):
except Exception: except Exception:
viewport_camera = None viewport_camera = None
# Populate the render tab and switch to it. # Single body → use set_shape; multiple → use set_assembly
self._render_tab.set_shape(shape, camera=viewport_camera) if len(assembly_parts) == 1:
self._render_tab.set_shape(assembly_parts[0][0], camera=viewport_camera)
else:
self._render_tab.set_assembly(assembly_parts, camera=viewport_camera)
self._ui.InputTab.setCurrentWidget(self._render_tab) self._ui.InputTab.setCurrentWidget(self._render_tab)
def _load_render_tab_shape(self) -> None: def _load_render_tab_shape(self) -> None:
"""Auto-load the selected body or assembly component into the render tab.""" """Auto-load the selected body or assembly component into the render tab."""
shape = None # Collect all visible bodies across all components
# Try selected body first. assembly_parts = []
if self._selected_body and self._selected_body.geometry: for comp in self._project.components.values():
try: for body in comp.bodies.values():
shape = self._kernel._get_shape(self._selected_body.geometry) if not body.visible or not body.geometry:
except Exception as e: continue
logger.warning(f"Failed to get shape for render: {e}") try:
# Try selected assembly component. occ_shape = self._kernel._get_shape(body.geometry)
if shape is None and self._selected_assembly_component_id: assembly_parts.append((occ_shape, body.render_material))
ac_id = self._selected_assembly_component_id except Exception as e:
for assembly in self._project.assemblies.values(): logger.warning(f"Failed to get shape for render: {e}")
ac = assembly.components.get(ac_id)
if ac and ac.component_id: if not assembly_parts:
src_comp = self._project.components.get(ac.component_id)
if src_comp:
for body in src_comp.bodies.values():
if body.geometry:
try:
shape = self._kernel._get_shape(body.geometry)
break
except Exception:
pass
break
if shape is None:
return return
# Capture the viewport camera so the render matches what the user sees.
# Capture the viewport camera
try: try:
renderer = self._viewer_3d.get_renderer() renderer = self._viewer_3d.get_renderer()
if hasattr(renderer, "get_render_camera"): if hasattr(renderer, "get_render_camera"):
@@ -4346,7 +4669,11 @@ class MainWindow(QMainWindow):
) )
except Exception: except Exception:
viewport_camera = None viewport_camera = None
self._render_tab.set_shape(shape, camera=viewport_camera)
if len(assembly_parts) == 1:
self._render_tab.set_shape(assembly_parts[0][0], camera=viewport_camera)
else:
self._render_tab.set_assembly(assembly_parts, camera=viewport_camera)
def _on_tab_changed(self, index: int) -> None: def _on_tab_changed(self, index: int) -> None:
"""When the user switches to the Render tab, auto-load the selected body.""" """When the user switches to the Render tab, auto-load the selected body."""
+152 -22
View File
@@ -104,6 +104,55 @@ class _RenderThread(QThread):
self.error.emit(str(e)) self.error.emit(str(e))
class _AssemblyRenderThread(QThread):
"""Background thread for assembly rendering (multiple meshes)."""
finished = Signal(np.ndarray)
error = Signal(str)
progress = Signal(float)
def __init__(
self,
backend: RenderBackend,
parts: list,
camera: RenderCamera,
settings: RenderSettings,
parent=None,
is_preview: bool = False,
):
super().__init__(parent)
self._backend = backend
self._parts = parts # list of (mesh_path, RenderMaterial)
self._camera = camera
self._settings = settings
self._is_preview = is_preview
self._cancelled = False
def cancel(self):
self._cancelled = True
def run(self):
try:
if self._is_preview:
image = self._backend.render_assembly(
self._parts,
self._camera,
self._settings,
)
else:
image = self._backend.render_assembly(
self._parts,
self._camera,
self._settings,
progress_callback=lambda p: self.progress.emit(p),
)
if not self._cancelled:
self.finished.emit(image)
except Exception as e:
if not self._cancelled:
self.error.emit(str(e))
# ── Render window ─────────────────────────────────────────────────── # ── Render window ───────────────────────────────────────────────────
@@ -960,6 +1009,9 @@ class RenderTabContent(QWidget):
self._backend = None self._backend = None
self._mesh_path: Optional[str] = None self._mesh_path: Optional[str] = None
# Assembly support: list of (mesh_path, RenderMaterial)
self._assembly_parts: list = []
# Rendering threads & images # Rendering threads & images
self._render_thread: Optional[_RenderThread] = None self._render_thread: Optional[_RenderThread] = None
self._preview_thread: Optional[_RenderThread] = None self._preview_thread: Optional[_RenderThread] = None
@@ -1000,6 +1052,31 @@ class RenderTabContent(QWidget):
"""Return the current camera (from UI controls or initial).""" """Return the current camera (from UI controls or initial)."""
return self._build_camera_from_ui() return self._build_camera_from_ui()
def set_assembly(
self,
parts: list,
camera: Optional[RenderCamera] = None,
) -> None:
"""Load multiple shapes for assembly rendering.
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples
where the second element is an optional material preset name.
"""
self._shape = None
self._mesh_path = None
self._assembly_parts = []
if camera is not None:
self._camera = 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()
def set_camera(self, camera: RenderCamera) -> None: def set_camera(self, camera: RenderCamera) -> None:
"""Update the render camera from an external source (e.g. 3D viewport). """Update the render camera from an external source (e.g. 3D viewport).
@@ -1390,6 +1467,30 @@ class RenderTabContent(QWidget):
logger.error(f"Failed to prepare mesh: {e}") logger.error(f"Failed to prepare mesh: {e}")
QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{e}") QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{e}")
def _prepare_assembly_mesh(self, parts: list):
"""Tessellate multiple shapes to separate PLY files.
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples.
Each material preset name is resolved via ``get_preset``.
"""
from fluency.rendering.material_presets import get_preset
self._assembly_parts = []
first_bounds = None
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))
if first_bounds is None:
first_bounds = occ_shape_bounds(shape)
except Exception as e:
logger.warning(f"Failed to tessellate assembly part: {e}")
if first_bounds and self._camera is None:
mn, mx = first_bounds
self._camera = self._backend.default_camera_from_bounds(mn, mx)
logger.info(f"Prepared assembly: {len(self._assembly_parts)} parts")
def _setup_auto_preview(self): def _setup_auto_preview(self):
self._auto_preview_timer = QTimer(self) self._auto_preview_timer = QTimer(self)
self._auto_preview_timer.setSingleShot(True) self._auto_preview_timer.setSingleShot(True)
@@ -1489,10 +1590,13 @@ class RenderTabContent(QWidget):
@Slot() @Slot()
def _on_preview(self): def _on_preview(self):
if self._backend is None or self._mesh_path is None: if self._backend is None:
return
has_single = self._mesh_path is not None
has_assembly = len(self._assembly_parts) > 0
if not has_single and not has_assembly:
return return
self._cancel_active_thread() self._cancel_active_thread()
material = get_preset(self._material_combo.currentText())
res_text = self._res_combo.currentText() res_text = self._res_combo.currentText()
w, h = [int(x) for x in res_text.split("×")] w, h = [int(x) for x in res_text.split("×")]
settings = RenderSettings( settings = RenderSettings(
@@ -1504,15 +1608,28 @@ class RenderTabContent(QWidget):
) )
camera = self._build_camera_from_ui() camera = self._build_camera_from_ui()
self._set_buttons_rendering("preview") self._set_buttons_rendering("preview")
self._preview_thread = _RenderThread(
self._backend, if has_assembly:
self._mesh_path, # Assembly: render all parts with their assigned materials
material, self._preview_thread = _AssemblyRenderThread(
camera, self._backend,
settings, self._assembly_parts,
parent=self, camera,
is_preview=True, settings,
) parent=self,
is_preview=True,
)
else:
material = get_preset(self._material_combo.currentText())
self._preview_thread = _RenderThread(
self._backend,
self._mesh_path,
material,
camera,
settings,
parent=self,
is_preview=True,
)
self._preview_thread.finished.connect(self._on_preview_done) self._preview_thread.finished.connect(self._on_preview_done)
self._preview_thread.error.connect(self._on_render_error) self._preview_thread.error.connect(self._on_render_error)
self._preview_thread.start() self._preview_thread.start()
@@ -1526,11 +1643,12 @@ class RenderTabContent(QWidget):
"No render backend available.\nInstall with: pip install mitsuba", "No render backend available.\nInstall with: pip install mitsuba",
) )
return return
if self._mesh_path is None: has_single = self._mesh_path is not None
has_assembly = len(self._assembly_parts) > 0
if not has_single and not has_assembly:
QMessageBox.warning(self, "No Shape", "No shape to render.") QMessageBox.warning(self, "No Shape", "No shape to render.")
return return
self._cancel_active_thread() self._cancel_active_thread()
material = get_preset(self._material_combo.currentText())
spp = self._spp_spin.value() spp = self._spp_spin.value()
res_text = self._res_combo.currentText() res_text = self._res_combo.currentText()
w, h = [int(x) for x in res_text.split("×")] w, h = [int(x) for x in res_text.split("×")]
@@ -1543,15 +1661,27 @@ class RenderTabContent(QWidget):
) )
camera = self._build_camera_from_ui() camera = self._build_camera_from_ui()
self._set_buttons_rendering("render") self._set_buttons_rendering("render")
self._render_thread = _RenderThread(
self._backend, if has_assembly:
self._mesh_path, self._render_thread = _AssemblyRenderThread(
material, self._backend,
camera, self._assembly_parts,
settings, camera,
parent=self, settings,
is_preview=False, parent=self,
) is_preview=False,
)
else:
material = get_preset(self._material_combo.currentText())
self._render_thread = _RenderThread(
self._backend,
self._mesh_path,
material,
camera,
settings,
parent=self,
is_preview=False,
)
self._render_thread.finished.connect(self._on_render_done) self._render_thread.finished.connect(self._on_render_done)
self._render_thread.error.connect(self._on_render_error) self._render_thread.error.connect(self._on_render_error)
self._render_thread.progress.connect(self._on_render_progress) self._render_thread.progress.connect(self._on_render_progress)