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" />
</component>
<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$/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/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/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>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -48,45 +50,45 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
&quot;Python.base.executor&quot;: &quot;Run&quot;,
&quot;Python.data_model.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
&quot;Python.fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
&quot;Python.kernel.executor&quot;: &quot;Run&quot;,
&quot;Python.main.executor&quot;: &quot;Run&quot;,
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.sketch.executor&quot;: &quot;Run&quot;,
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency/rendering&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"Python.2dtest.executor": "Run",
"Python.3d_windows.executor": "Run",
"Python.Unnamed.executor": "Run",
"Python.base.executor": "Run",
"Python.data_model.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run",
"Python.fluencyb.executor": "Run",
"Python.gl_widget.executor": "Run",
"Python.gui_ui.executor": "Run",
"Python.kernel.executor": "Run",
"Python.main.executor": "Run",
"Python.meshtest.executor": "Run",
"Python.occ_renderer.executor": "Run",
"Python.occ_to_mesh.executor": "Run",
"Python.render_backend.executor": "Run",
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
"Python.sketch.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "feature/occ-migration",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
"node.js.detected.package.eslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
}
}</component>
}]]></component>
<component name="QodanaReportsService">
<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" />
@@ -94,9 +96,9 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" />
</key>
@@ -432,7 +434,15 @@
<option name="project" value="LOCAL" />
<updated>1783918463034</updated>
</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 />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -453,7 +463,6 @@
<ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots>
<MESSAGE value="- Renabled extrusion with new object system" />
<MESSAGE value="- Sketch projection partly works again :)" />
<MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" />
@@ -478,6 +487,7 @@
<MESSAGE value="- Working assembly multi :)" />
<MESSAGE value="- added renderer" />
<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>
</project>
+814 -777
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:
"""Create a 2D point."""
from OCP.gp import gp_Pnt
return OCCGeometryObject(gp_Pnt(x, y, 0))
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
@@ -61,9 +62,7 @@ class OCGeometryKernel(GeometryKernel):
from OCP.gp import gp_Pnt
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
edge = BRepBuilderAPI_MakeEdge(
gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)
).Edge()
edge = BRepBuilderAPI_MakeEdge(gp_Pnt(start.x, start.y, 0), gp_Pnt(end.x, end.y, 0)).Edge()
return OCCGeometryObject(edge, {"type": "line"})
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.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
circ = gp_Circ(
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
)
circ = gp_Circ(gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius)
edge = BRepBuilderAPI_MakeEdge(circ).Edge()
return OCCGeometryObject(edge, {"type": "circle"})
@@ -88,9 +85,7 @@ class OCGeometryKernel(GeometryKernel):
start_rad = math.radians(start_angle)
end_rad = math.radians(end_angle)
circ = gp_Circ(
gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius
)
circ = gp_Circ(gp_Ax2(gp_Pnt(center.x, center.y, 0), gp_Dir(0, 0, 1)), radius)
edge = BRepBuilderAPI_MakeEdge(circ, start_rad, end_rad).Edge()
return OCCGeometryObject(edge, {"type": "arc"})
@@ -159,8 +154,7 @@ class OCGeometryKernel(GeometryKernel):
face = self._get_shape(sketch)
if face is None:
raise ValueError(
"Cannot extrude: sketch has no geometry. "
"Draw a closed profile before extruding."
"Cannot extrude: sketch has no geometry. Draw a closed profile before extruding."
)
# If the wrapper class itself leaked through somehow, surface a
# 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]:
"""Return the normal stored on a sketch-derived geometry object, else +Z."""
import numpy as np
meta = getattr(obj, "metadata", None) or {}
n = meta.get("normal")
if n is None:
@@ -453,7 +448,6 @@ class OCGeometryKernel(GeometryKernel):
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Rotate a body around an axis."""
import math
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir
@@ -562,6 +556,52 @@ class OCGeometryKernel(GeometryKernel):
shape = reader.OneShape()
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:
"""Import from IGES format."""
from OCP.IGESControl import IGESControl_Reader
@@ -643,10 +683,8 @@ class OCGeometryKernel(GeometryKernel):
from OCP.TopExp import TopExp_Explorer
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.GeomAbs import GeomAbs_Line, GeomAbs_Circle, GeomAbs_Ellipse, GeomAbs_BSplineCurve
from OCP.GeomAbs import GeomAbs_Line
vertices_list: List[List[float]] = []
edges_list: List[List[int]] = []
@@ -678,6 +716,7 @@ class OCGeometryKernel(GeometryKernel):
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
from OCP.TopoDS import TopoDS
edge = TopoDS.Edge_s(explorer.Current())
edge_points = discretize_edge(edge)
+9 -13
View File
@@ -13,9 +13,7 @@ import numpy as np
from fluency.geometry.base import (
Point2D,
Point3D,
GeometryObject,
SketchInterface,
)
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
@@ -48,6 +46,7 @@ class Workplane:
def __post_init__(self):
# Normalise normal and x_dir on construction.
import numpy as np
n = np.asarray(self.normal, dtype=float)
n = n / np.linalg.norm(n)
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]:
"""Map a 3D world point to UV coordinates on this plane."""
import numpy as np
ox, oy, oz = self.origin
v = np.array([p[0] - ox, p[1] - oy, p[2] - oz])
xd = np.array(self.x_dir, dtype=float)
@@ -244,6 +244,7 @@ class Body:
visible: bool = True
render_object: Any = None
render_material: Optional[str] = None # material preset name for render backend
created_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
for c in self.connections:
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):
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
):
return c
conn = AssemblyConnection(
first_ac_id=first_ac_id,
@@ -526,8 +528,7 @@ class Assembly:
def remove_connections_for(self, ac_id: str) -> None:
"""Drop every connection that involves *ac_id* (e.g. on removal)."""
self.connections = [
c for c in self.connections
if c.first_ac_id != ac_id and c.second_ac_id != ac_id
c for c in self.connections if c.first_ac_id != ac_id and c.second_ac_id != ac_id
]
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.
self.remove_connections_for(assembly_component_id)
if self.active_assembly_component == assembly_component_id:
self.active_assembly_component = next(
iter(self.components.keys()), None
)
self.active_assembly_component = next(iter(self.components.keys()), None)
self.modified_at = datetime.now()
return True
return False
def get_active_instance(self) -> Optional[AssemblyComponent]:
"""Get the currently active assembly component instance."""
if (
self.active_assembly_component
and self.active_assembly_component in self.components
):
if self.active_assembly_component and self.active_assembly_component in self.components:
return self.components[self.active_assembly_component]
return None
+108 -65
View File
@@ -39,47 +39,31 @@ class MitsubaBackend(RenderBackend):
# ── Scene construction ──────────────────────────────────────────
def _build_scene_dict(
def _base_scene_dict(
self,
mesh_path: str,
material: RenderMaterial,
camera: RenderCamera,
settings: RenderSettings,
first_mesh_path: Optional[str] = None,
) -> 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
environment emitter for soft fill, giving well-balanced shading on
all faces of the model. Lighting intensities and colors come from
``settings.lighting``; ground plane comes from ``settings.ground_plane``.
When *first_mesh_path* is given the ground plane / backdrop is sized
from its bounding box; otherwise a large default is used.
"""
import mitsuba as mi
lighting = settings.lighting
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(
origin=list(camera.origin),
target=list(camera.target),
up=list(camera.up),
)
scene = {
scene: dict = {
"type": "scene",
# Integrator
"integrator": {
"type": "path",
"max_depth": settings.max_depth,
},
# Camera
"integrator": {"type": "path", "max_depth": settings.max_depth},
"sensor": {
"type": "perspective",
"fov": camera.fov,
@@ -95,7 +79,6 @@ class MitsubaBackend(RenderBackend):
"sample_count": settings.spp,
},
},
# Ambient environment fill
"emitter": {
"type": "constant",
"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_to_world = mi.ScalarTransform4f.look_at(
scene["key_light"] = {
"type": "directional",
"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"] = {
"type": "directional",
"to_world": key_to_world,
),
"irradiance": {"type": "rgb", "value": key_rgb},
}
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],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["fill_light"] = {
"type": "directional",
"to_world": fill_to_world,
),
"irradiance": {"type": "rgb", "value": fill_rgb},
}
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],
target=[0.0, 0.0, 0.0],
up=[0.0, 0.0, 1.0],
)
scene["rim_light"] = {
"type": "directional",
"to_world": rim_to_world,
),
"irradiance": {"type": "rgb", "value": rim_rgb},
}
# ── Ground plane / backdrop (optional) ─────────────────────
# ── Ground plane / backdrop ───────────────────────────────────
if ground.enabled:
try:
# Load mesh to compute bounds for ground/backdrop placement
mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path})
ext = os.path.splitext(first_mesh_path)[1].lower() if first_mesh_path else ""
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_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]
ground_z = bbox_min[2] - 0.001 * model_height
dx = bbox_max[0] - bbox_min[0]
@@ -167,7 +141,6 @@ class MitsubaBackend(RenderBackend):
dz = bbox_max[2] - bbox_min[2]
diag = float((dx * dx + dy * dy + dz * dz) ** 0.5)
except Exception:
# Fallback: place at origin with large default size
ground_z = -ground.distance_below
diag = 1000.0
@@ -177,19 +150,15 @@ class MitsubaBackend(RenderBackend):
}
if ground.curved_backdrop:
# Photo booth style curved leinwand:
# - Flat floor section in front of the model
# - Curved cylinder behind that sweeps up and over
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)
half_size = diag * 50.0
radius = diag * 3.0
cyl_height = diag * 20.0
scene["ground_floor"] = {
"type": "rectangle",
"to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z])
@ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]),
"bsdf": bsdf_ground,
}
# Cylinder: axis along Y, positioned behind model, radius sweeps up
scene["ground_backdrop"] = {
"type": "cylinder",
"radius": radius,
@@ -200,7 +169,6 @@ class MitsubaBackend(RenderBackend):
"bsdf": bsdf_ground,
}
else:
# Simple flat ground plane — very large so edges aren't visible
half_size = diag * 50.0
scene["ground_plane"] = {
"type": "rectangle",
@@ -211,6 +179,48 @@ class MitsubaBackend(RenderBackend):
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:
"""Convert a RenderMaterial to a Mitsuba BSDF dict."""
mt = material.bsdf_type
@@ -282,9 +292,8 @@ class MitsubaBackend(RenderBackend):
logger.info(f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp")
# Render
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:
logger.error(f"Mitsuba render failed: {e}")
raise
@@ -292,14 +301,48 @@ class MitsubaBackend(RenderBackend):
if progress_callback:
progress_callback(1.0)
# Convert to numpy (H, W, 3)
arr = np.array(image, dtype=np.float32)
# Apply approximate sRGB tonemapping
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)
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
def render_preview(
+88 -41
View File
@@ -17,6 +17,86 @@ from .base import Renderer, RenderObject, RenderColor
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
class OCCRenderObject(RenderObject):
"""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
origin = (cx - d * nx, cy - d * ny, cz - d * nz)
# x_dir: direction of the face's first edge (stable, in-plane).
x_dir = None
try:
from OCP.TopExp import TopExp
from OCP.BRep import BRep_Tool
expl = TopExp_Explorer(face, TopAbs_EDGE)
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())
# x_dir: project a "screen-horizontal" direction onto the face plane
# so that the sketch's U-axis appears horizontal in the current 3D
# viewport. This eliminates the 90° rotation that occurred when we
# used the first edge / plane X axis (which follow OCC internal order,
# not the viewer perspective).
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
# 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
@@ -1258,25 +1322,8 @@ class OCCRenderer(Renderer):
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)
# x_dir from first edge.
x_dir = None
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())
# x_dir: viewport-aligned so connector gizmo matches screen.
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
return [{
"type": "planar_face",
+400 -73
View File
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
QMenu,
QMessageBox,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
@@ -56,6 +57,221 @@ from gui_ui import Ui_fluencyCAD # auto-generated Qt form (project root on sys.
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(
body_shape: Any,
workplane: Tuple[
@@ -388,32 +604,51 @@ class MainWindow(QMainWindow):
self._ui.InputTab.addTab(self._render_tab, "Render")
# 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_layout = QHBoxLayout(self._component_box)
self._component_box_layout.setAlignment(Qt.AlignLeft)
self._component_box_layout.setContentsMargins(2, 2, 2, 2)
self._component_group = QButtonGroup(self)
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.
compo_layout = self._ui.compo_box.layout()
if compo_layout is None:
compo_layout = QHBoxLayout(self._ui.compo_box)
compo_layout.setContentsMargins(0, 0, 0, 0)
compo_layout.addWidget(self._component_box)
compo_layout.addStretch()
compo_layout.addWidget(self._component_scroll)
# ── Assembly box (dynamic buttons like component box) ──
self._assembly_box = QWidget()
self._assembly_box_layout = QHBoxLayout(self._assembly_box)
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.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.
asm_layout = self._ui.assembly_box.layout()
if asm_layout is None:
asm_layout = QHBoxLayout(self._ui.assembly_box)
asm_layout.setContentsMargins(0, 0, 0, 0)
asm_layout.addWidget(self._assembly_box)
asm_layout.addStretch()
asm_layout.addWidget(self._assembly_scroll)
# ── Assembly Move button (programmatic, in assembly_tools) ──
self._btn_asm_move = QPushButton("Pos")
@@ -425,7 +660,7 @@ class MainWindow(QMainWindow):
)
asm_tools_layout = self._ui.assembly_tools.layout()
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).
self._panel_focus: str = "equal"
@@ -755,6 +990,24 @@ class MainWindow(QMainWindow):
return i
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):
logger.info("=== NEW COMPONENT ===")
comp = self._project.add_component()
@@ -762,11 +1015,14 @@ class MainWindow(QMainWindow):
self._mark_dirty()
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.setFixedSize(QSize(40, 40))
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(comp.name)
btn.clicked.connect(self._on_component_button_clicked)
btn.setChecked(True)
_set_button_style(btn)
for b in self._component_buttons:
b.setChecked(False)
@@ -774,6 +1030,8 @@ class MainWindow(QMainWindow):
self._component_buttons.append(btn)
self._component_group.addButton(btn)
self._component_box_layout.addWidget(btn)
# Scroll to the new button.
_scroll_to_button(btn, self._component_scroll)
self._refresh_lists()
logger.info(f"Created component: {comp.name}")
@@ -804,6 +1062,9 @@ class MainWindow(QMainWindow):
self._assembly_view_active = False
self._refresh_lists()
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):
self._sketch_list.clear()
@@ -846,6 +1107,7 @@ class MainWindow(QMainWindow):
self._propagate_to_assembly()
self._recalculate_connectors()
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
def _update_bodies_from_sketch(self):
"""Re-extrude bodies whose source sketch has been edited.
@@ -1121,11 +1383,22 @@ class MainWindow(QMainWindow):
label = f"{instance_num}"
btn = QPushButton(label)
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})")
# Store the assembly component id in the button.
btn._assembly_component_id = ac.id
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.
for b in self._assembly_component_buttons:
@@ -1213,6 +1486,12 @@ class MainWindow(QMainWindow):
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:
"""Apply a position translation and rotation matrix to a shape.
@@ -3419,6 +3698,10 @@ class MainWindow(QMainWindow):
logger.warning("No sketch entity found")
QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
return
if sketch is None:
sketch = Sketch()
self._current_sketch = sketch
self._current_component.add_sketch(sketch)
sketch.occ_sketch = sketch_entity
# Resolve the profile geometry *before* opening the dialog so the
@@ -3526,6 +3809,7 @@ class MainWindow(QMainWindow):
body_name = body.name
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera()
logger.info(f"Extruded: {body_name}")
logger.info("=== EXTRUDE COMPLETE ===")
@@ -3546,6 +3830,10 @@ class MainWindow(QMainWindow):
if not sketch_entity:
QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
return
if sketch is None:
sketch = Sketch()
self._current_sketch = sketch
self._current_component.add_sketch(sketch)
sketch.occ_sketch = sketch_entity
dialog = RevolveDialog(self)
@@ -3582,6 +3870,7 @@ class MainWindow(QMainWindow):
logger.info(f"Render object: {body.render_object}")
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera()
logger.info(f"Revolved: {body.name}")
@@ -3656,6 +3945,7 @@ class MainWindow(QMainWindow):
logger.info(f"Render object: {new_body.render_object}")
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera()
logger.info(f"Cut complete: {new_body.name}")
@@ -3696,6 +3986,7 @@ class MainWindow(QMainWindow):
logger.info(f"Render object: {new_body.render_object}")
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera()
logger.info(f"Union complete: {new_body.name}")
@@ -4063,15 +4354,30 @@ class MainWindow(QMainWindow):
self._viewer_3d.clear_scene()
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):
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.setCheckable(True)
btn.setFixedSize(QSize(40, 40))
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(comp.name)
btn.clicked.connect(self._on_component_button_clicked)
self._component_buttons.append(btn)
_set_button_style(btn)
self._component_group.addButton(btn)
self._component_box_layout.addWidget(btn)
self._component_buttons.append(btn)
# Pick which component to activate: explicit saved selection,
# 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
btn = QPushButton(str(instance_num))
btn.setCheckable(True)
btn.setFixedSize(QSize(40, 40))
btn.setFixedSize(QSize(100, 100))
btn.setToolTip(f"{ac.name} (instance {instance_num})")
btn._assembly_component_id = ac.id
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_group.addButton(btn)
self._assembly_box_layout.addWidget(btn)
@@ -4166,29 +4484,56 @@ class MainWindow(QMainWindow):
filepath, _ = QFileDialog.getOpenFileName(
self, "Import File", "", "STEP Files (*.step *.stp);;IGES Files (*.iges *.igs)"
)
if filepath:
if not filepath:
return
try:
if filepath.lower().endswith((".step", ".stp")):
geometry = self._kernel.import_step(filepath)
parts = self._kernel.import_step_components(filepath)
else:
geometry = self._kernel.import_iges(filepath)
parts = [("Imported", geometry)]
if not self._current_component:
self._current_component = self._project.add_component()
num_before = len(self._project.components)
body = self._current_component.add_body(
Body(name="Imported", geometry=geometry, source_operation="import")
)
self._mark_dirty()
for name, geometry in parts:
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)
body.render_object = self._viewer_3d.add_mesh(
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._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:
QMessageBox.critical(self, "Error", f"Failed to import: {e}")
@@ -4241,32 +4586,21 @@ class MainWindow(QMainWindow):
def _open_render_window(self):
"""Populate the render tab with the selected body or assembly and switch to it."""
shape = None
# Try selected body first
if self._selected_body and self._selected_body.geometry:
# Collect all visible bodies across all components
assembly_parts = [] # list of (TopoDS_Shape, Optional[str])
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:
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:
logger.warning(f"Failed to get shape for render: {e}")
# Try selected assembly component
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:
if not assembly_parts:
QMessageBox.information(
self,
"Render",
@@ -4274,16 +4608,12 @@ class MainWindow(QMainWindow):
)
return
# Capture the current 3D viewport camera so the render matches what the user sees.
# 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.
# Capture the current 3D viewport camera
try:
renderer = self._viewer_3d.get_renderer()
if hasattr(renderer, "get_render_camera"):
viewport_camera = renderer.get_render_camera()
else:
# Fallback for pygfx or other renderers without the method.
from fluency.rendering.render_backend import RenderCamera
eye, at_, up = self._viewer_3d.get_camera_position()
@@ -4297,38 +4627,31 @@ class MainWindow(QMainWindow):
except Exception:
viewport_camera = None
# Populate the render tab and switch to it.
self._render_tab.set_shape(shape, camera=viewport_camera)
# Single body → use set_shape; multiple → use set_assembly
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)
def _load_render_tab_shape(self) -> None:
"""Auto-load the selected body or assembly component into the render tab."""
shape = None
# Try selected body first.
if self._selected_body and self._selected_body.geometry:
# Collect all visible bodies across all components
assembly_parts = []
for comp in self._project.components.values():
for body in comp.bodies.values():
if not body.visible or not body.geometry:
continue
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:
logger.warning(f"Failed to get shape for render: {e}")
# Try selected assembly component.
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:
if not assembly_parts:
return
# Capture the viewport camera so the render matches what the user sees.
# Capture the viewport camera
try:
renderer = self._viewer_3d.get_renderer()
if hasattr(renderer, "get_render_camera"):
@@ -4346,7 +4669,11 @@ class MainWindow(QMainWindow):
)
except Exception:
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:
"""When the user switches to the Render tab, auto-load the selected body."""
+134 -4
View File
@@ -104,6 +104,55 @@ class _RenderThread(QThread):
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 ───────────────────────────────────────────────────
@@ -960,6 +1009,9 @@ class RenderTabContent(QWidget):
self._backend = None
self._mesh_path: Optional[str] = None
# Assembly support: list of (mesh_path, RenderMaterial)
self._assembly_parts: list = []
# Rendering threads & images
self._render_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 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:
"""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}")
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):
self._auto_preview_timer = QTimer(self)
self._auto_preview_timer.setSingleShot(True)
@@ -1489,10 +1590,13 @@ class RenderTabContent(QWidget):
@Slot()
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
self._cancel_active_thread()
material = get_preset(self._material_combo.currentText())
res_text = self._res_combo.currentText()
w, h = [int(x) for x in res_text.split("×")]
settings = RenderSettings(
@@ -1504,6 +1608,19 @@ class RenderTabContent(QWidget):
)
camera = self._build_camera_from_ui()
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._backend,
self._mesh_path,
@@ -1526,11 +1643,12 @@ class RenderTabContent(QWidget):
"No render backend available.\nInstall with: pip install mitsuba",
)
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.")
return
self._cancel_active_thread()
material = get_preset(self._material_combo.currentText())
spp = self._spp_spin.value()
res_text = self._res_combo.currentText()
w, h = [int(x) for x in res_text.split("×")]
@@ -1543,6 +1661,18 @@ class RenderTabContent(QWidget):
)
camera = self._build_camera_from_ui()
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._backend,
self._mesh_path,