Improved render previews
This commit is contained in:
Generated
+55
-45
@@ -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 - 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[{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"Python.2dtest.executor": "Run",
|
"Python.2dtest.executor": "Run",
|
||||||
"Python.3d_windows.executor": "Run",
|
"Python.3d_windows.executor": "Run",
|
||||||
"Python.Unnamed.executor": "Run",
|
"Python.Unnamed.executor": "Run",
|
||||||
"Python.base.executor": "Run",
|
"Python.base.executor": "Run",
|
||||||
"Python.data_model.executor": "Run",
|
"Python.data_model.executor": "Run",
|
||||||
"Python.draw_widget2d.executor": "Run",
|
"Python.draw_widget2d.executor": "Run",
|
||||||
"Python.draw_widget_solve.executor": "Run",
|
"Python.draw_widget_solve.executor": "Run",
|
||||||
"Python.fluency.executor": "Run",
|
"Python.fluency.executor": "Run",
|
||||||
"Python.fluencyb.executor": "Run",
|
"Python.fluencyb.executor": "Run",
|
||||||
"Python.gl_widget.executor": "Run",
|
"Python.gl_widget.executor": "Run",
|
||||||
"Python.gui_ui.executor": "Run",
|
"Python.gui_ui.executor": "Run",
|
||||||
"Python.kernel.executor": "Run",
|
"Python.kernel.executor": "Run",
|
||||||
"Python.main.executor": "Run",
|
"Python.main.executor": "Run",
|
||||||
"Python.meshtest.executor": "Run",
|
"Python.meshtest.executor": "Run",
|
||||||
"Python.occ_renderer.executor": "Run",
|
"Python.occ_renderer.executor": "Run",
|
||||||
"Python.occ_to_mesh.executor": "Run",
|
"Python.occ_to_mesh.executor": "Run",
|
||||||
"Python.render_backend.executor": "Run",
|
"Python.render_backend.executor": "Run",
|
||||||
"Python.side_fluency.executor": "Run",
|
"Python.side_fluency.executor": "Run",
|
||||||
"Python.simple_mesh.executor": "Run",
|
"Python.simple_mesh.executor": "Run",
|
||||||
"Python.sketch.executor": "Run",
|
"Python.sketch.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.vtk_widget.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.vulkan.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"git-widget-placeholder": "feature/occ-migration",
|
"git-widget-placeholder": "feature/occ-migration",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/rendering",
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "npm",
|
"nodejs_package_manager_path": "npm",
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
|
"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 - Added undo" />
|
<MESSAGE value="- added renderer - Added undo" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="- added renderer - Added undo" />
|
<MESSAGE value="- Render improvements, camera plane, update" />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="- Render improvements, camera plane, update" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
scene["key_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"to_world": mi.ScalarTransform4f.look_at(
|
||||||
origin=[1.0, -0.8, 1.2],
|
origin=[1.0, -0.8, 1.2],
|
||||||
target=[0.0, 0.0, 0.0],
|
target=[0.0, 0.0, 0.0],
|
||||||
up=[0.0, 0.0, 1.0],
|
up=[0.0, 0.0, 1.0],
|
||||||
)
|
),
|
||||||
scene["key_light"] = {
|
|
||||||
"type": "directional",
|
|
||||||
"to_world": key_to_world,
|
|
||||||
"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(
|
scene["fill_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"to_world": mi.ScalarTransform4f.look_at(
|
||||||
origin=[-1.0, 0.6, 0.8],
|
origin=[-1.0, 0.6, 0.8],
|
||||||
target=[0.0, 0.0, 0.0],
|
target=[0.0, 0.0, 0.0],
|
||||||
up=[0.0, 0.0, 1.0],
|
up=[0.0, 0.0, 1.0],
|
||||||
)
|
),
|
||||||
scene["fill_light"] = {
|
|
||||||
"type": "directional",
|
|
||||||
"to_world": fill_to_world,
|
|
||||||
"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(
|
scene["rim_light"] = {
|
||||||
|
"type": "directional",
|
||||||
|
"to_world": mi.ScalarTransform4f.look_at(
|
||||||
origin=[-0.3, 1.2, -0.8],
|
origin=[-0.3, 1.2, -0.8],
|
||||||
target=[0.0, 0.0, 0.0],
|
target=[0.0, 0.0, 0.0],
|
||||||
up=[0.0, 0.0, 1.0],
|
up=[0.0, 0.0, 1.0],
|
||||||
)
|
),
|
||||||
scene["rim_light"] = {
|
|
||||||
"type": "directional",
|
|
||||||
"to_world": rim_to_world,
|
|
||||||
"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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+400
-73
@@ -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
|
||||||
|
(1∶1 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):
|
||||||
|
first_body = next(iter(comp.bodies.values()), None)
|
||||||
|
if first_body and first_body.geometry:
|
||||||
|
btn = _create_component_button(
|
||||||
|
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 = QPushButton(str(idx))
|
||||||
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)
|
||||||
self._component_buttons.append(btn)
|
_set_button_style(btn)
|
||||||
self._component_group.addButton(btn)
|
self._component_group.addButton(btn)
|
||||||
self._component_box_layout.addWidget(btn)
|
self._component_box_layout.addWidget(btn)
|
||||||
|
self._component_buttons.append(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,29 +4484,56 @@ 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:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if filepath.lower().endswith((".step", ".stp")):
|
if filepath.lower().endswith((".step", ".stp")):
|
||||||
geometry = self._kernel.import_step(filepath)
|
parts = self._kernel.import_step_components(filepath)
|
||||||
else:
|
else:
|
||||||
geometry = self._kernel.import_iges(filepath)
|
geometry = self._kernel.import_iges(filepath)
|
||||||
|
parts = [("Imported", geometry)]
|
||||||
|
|
||||||
if not self._current_component:
|
num_before = len(self._project.components)
|
||||||
self._current_component = self._project.add_component()
|
|
||||||
|
|
||||||
body = self._current_component.add_body(
|
for name, geometry in parts:
|
||||||
Body(name="Imported", geometry=geometry, source_operation="import")
|
comp = self._project.add_component(Component(name=name))
|
||||||
)
|
self._current_component = comp
|
||||||
self._mark_dirty()
|
|
||||||
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add a numbered button to the component bar
|
||||||
|
btn_num = len(self._project.components)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Select the first newly imported component
|
||||||
|
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._refresh_lists()
|
||||||
self._viewer_3d.fit_camera()
|
self._viewer_3d.fit_camera()
|
||||||
logger.info(f"Imported: {filepath}")
|
self._mark_dirty()
|
||||||
|
logger.info(f"Imported {len(parts)} part(s) from {filepath}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "Error", f"Failed to import: {e}")
|
QMessageBox.critical(self, "Error", f"Failed to import: {e}")
|
||||||
@@ -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
|
||||||
|
|
||||||
|
for comp in self._project.components.values():
|
||||||
|
for body in comp.bodies.values():
|
||||||
|
if not body.visible or not body.geometry:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
shape = self._kernel._get_shape(self._selected_body.geometry)
|
occ_shape = self._kernel._get_shape(body.geometry)
|
||||||
|
assembly_parts.append((occ_shape, body.render_material))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to get shape for render: {e}")
|
logger.warning(f"Failed to get shape for render: {e}")
|
||||||
|
|
||||||
# Try selected assembly component
|
if not assembly_parts:
|
||||||
if shape is None and self._selected_assembly_component_id:
|
|
||||||
ac_id = self._selected_assembly_component_id
|
|
||||||
for assembly in self._project.assemblies.values():
|
|
||||||
ac = assembly.components.get(ac_id)
|
|
||||||
if ac and ac.component_id:
|
|
||||||
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:
|
|
||||||
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():
|
||||||
|
for body in comp.bodies.values():
|
||||||
|
if not body.visible or not body.geometry:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
shape = self._kernel._get_shape(self._selected_body.geometry)
|
occ_shape = self._kernel._get_shape(body.geometry)
|
||||||
|
assembly_parts.append((occ_shape, body.render_material))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to get shape for render: {e}")
|
logger.warning(f"Failed to get shape for render: {e}")
|
||||||
# Try selected assembly component.
|
|
||||||
if shape is None and self._selected_assembly_component_id:
|
if not assembly_parts:
|
||||||
ac_id = self._selected_assembly_component_id
|
|
||||||
for assembly in self._project.assemblies.values():
|
|
||||||
ac = assembly.components.get(ac_id)
|
|
||||||
if ac and ac.component_id:
|
|
||||||
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."""
|
||||||
|
|||||||
@@ -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,6 +1608,19 @@ 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")
|
||||||
|
|
||||||
|
if has_assembly:
|
||||||
|
# Assembly: render all parts with their assigned materials
|
||||||
|
self._preview_thread = _AssemblyRenderThread(
|
||||||
|
self._backend,
|
||||||
|
self._assembly_parts,
|
||||||
|
camera,
|
||||||
|
settings,
|
||||||
|
parent=self,
|
||||||
|
is_preview=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
material = get_preset(self._material_combo.currentText())
|
||||||
self._preview_thread = _RenderThread(
|
self._preview_thread = _RenderThread(
|
||||||
self._backend,
|
self._backend,
|
||||||
self._mesh_path,
|
self._mesh_path,
|
||||||
@@ -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,6 +1661,18 @@ 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")
|
||||||
|
|
||||||
|
if has_assembly:
|
||||||
|
self._render_thread = _AssemblyRenderThread(
|
||||||
|
self._backend,
|
||||||
|
self._assembly_parts,
|
||||||
|
camera,
|
||||||
|
settings,
|
||||||
|
parent=self,
|
||||||
|
is_preview=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
material = get_preset(self._material_combo.currentText())
|
||||||
self._render_thread = _RenderThread(
|
self._render_thread = _RenderThread(
|
||||||
self._backend,
|
self._backend,
|
||||||
self._mesh_path,
|
self._mesh_path,
|
||||||
|
|||||||
Reference in New Issue
Block a user