diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index f1cabc3..523523d 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -43,45 +43,45 @@
- {
+ "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/src/fluency/rendering",
+ "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"
}
-}]]>
+}
1783887704766
-
+
+
+ 1783891561116
+
+
+
+ 1783891561116
+
+
diff --git a/src/fluency/rendering/occ_to_mesh.py b/src/fluency/rendering/occ_to_mesh.py
index 7dcbe14..f52c278 100644
--- a/src/fluency/rendering/occ_to_mesh.py
+++ b/src/fluency/rendering/occ_to_mesh.py
@@ -43,6 +43,8 @@ def occ_shape_to_ply(
all_faces: List[List[int]] = []
vertex_offset = 0
+ from OCP.TopAbs import TopAbs_FORWARD
+
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = TopoDS.Face_s(explorer.Current())
@@ -56,6 +58,10 @@ def occ_shape_to_ply(
# Transform
trsf = location.Transformation()
+ # Check face orientation: FORWARD means the surface normal points
+ # outward from the solid; REVERSED means it points inward.
+ is_forward = (face.Orientation() == TopAbs_FORWARD)
+
# Extract vertices (apply location transform to positions)
nb_nodes = triangulation.NbNodes()
for i in range(1, nb_nodes + 1):
@@ -64,15 +70,25 @@ def occ_shape_to_ply(
all_vertices.append([pnt.X(), pnt.Y(), pnt.Z()])
# Extract triangles
+ # For REVERSED faces, swap winding order (n1, n3, n2) so that
+ # the computed normal points outward consistently.
nb_triangles = triangulation.NbTriangles()
for i in range(1, nb_triangles + 1):
tri = triangulation.Triangle(i)
n1, n2, n3 = tri.Get()
- all_faces.append([
- n1 - 1 + vertex_offset,
- n2 - 1 + vertex_offset,
- n3 - 1 + vertex_offset,
- ])
+ if is_forward:
+ all_faces.append([
+ n1 - 1 + vertex_offset,
+ n2 - 1 + vertex_offset,
+ n3 - 1 + vertex_offset,
+ ])
+ else:
+ # Swap winding for REVERSED faces
+ all_faces.append([
+ n1 - 1 + vertex_offset,
+ n3 - 1 + vertex_offset,
+ n2 - 1 + vertex_offset,
+ ])
vertex_offset += nb_nodes
explorer.Next()
@@ -87,10 +103,8 @@ def occ_shape_to_ply(
f"Tessellation: {len(vertices)} vertices, {len(faces)} triangles"
)
- # Compute outward-facing vertex normals AND correct face winding.
- # OCC triangulation produces inconsistent winding across faces (especially
- # after location transforms). Mitsuba uses face winding for front/back
- # determination, so both normals AND winding must be consistent.
+ # Compute smooth vertex normals from face normals.
+ # Winding is already corrected during tessellation using OCC face orientation.
normals, corrected_faces = _compute_outward_normals(vertices, faces, shape)
# Write PLY with corrected faces and normals
@@ -110,72 +124,28 @@ def _compute_outward_normals(
) -> Tuple[np.ndarray, np.ndarray]:
"""Compute outward-facing vertex normals and correct face winding.
- 1. Compute per-face normals from cross product of triangle edges.
- 2. Determine correct orientation by checking face normals against the
- shape centroid (outward = away from center).
- 3. Flip triangles with inward normals before accumulating to vertices.
- 4. Average and normalize per-vertex normals.
+ The winding is already corrected during tessellation using OCC's face
+ orientation (TopAbs_FORWARD/REVERSED). This function computes smooth
+ vertex normals by averaging face normals at shared vertices.
- Returns (normals, corrected_faces) so the PLY writer can use consistent
- winding order — critical for Mitsuba which uses winding for front/back
- face determination.
+ Returns (normals, corrected_faces) for PLY export.
"""
n_verts = len(vertices)
v_normals = np.zeros((n_verts, 3), dtype=np.float64)
- # Compute shape centroid for outward direction reference.
- # Use OCC bounding box if available, otherwise fall back to vertex bounds.
- if shape is not None:
- from OCP.Bnd import Bnd_Box
- from OCP.BRepBndLib import BRepBndLib
-
- bbox = Bnd_Box()
- BRepBndLib.Add_s(shape, bbox)
- xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
- else:
- vmin = vertices.min(axis=0).astype(np.float64)
- vmax = vertices.max(axis=0).astype(np.float64)
- xmin, ymin, zmin = vmin
- xmax, ymax, zmax = vmax
-
- centroid = np.array(
- [(xmin + xmax) / 2, (ymin + ymax) / 2, (zmin + zmax) / 2],
- dtype=np.float64,
- )
-
# Ensure faces is 2D (numpy creates (3,) for single-face meshes)
if faces.ndim == 1:
faces = faces.reshape(1, -1)
- # Compute face normals from triangle geometry
+ # Winding is already correct from tessellation (face orientation check).
+ # Just compute face normals and accumulate to vertices.
v0 = vertices[faces[:, 0]]
v1 = vertices[faces[:, 1]]
v2 = vertices[faces[:, 2]]
edge1 = v1 - v0
edge2 = v2 - v0
- face_normals = np.cross(edge1, edge2)
-
- # Triangle centroids to test direction from shape center
- tri_centers = (v0 + v1 + v2) / 3.0
- to_tri = tri_centers - centroid
-
- # Dot product: positive means normal points away from centroid (outward)
- dots = np.sum(face_normals * to_tri, axis=1)
-
- # Faces with negative dot have inward normals — swap columns 1 and 2
- flip_mask = dots < 0
- corrected_faces = faces.copy()
- col1 = corrected_faces[:, 1]
- col2 = corrected_faces[:, 2]
- corrected_faces[flip_mask, 1] = col2[flip_mask]
- corrected_faces[flip_mask, 2] = col1[flip_mask]
-
- # Recompute face normals after correction
- v0c = vertices[corrected_faces[:, 0]]
- v1c = vertices[corrected_faces[:, 1]]
- v2c = vertices[corrected_faces[:, 2]]
- fn = np.cross(v1c - v0c, v2c - v0c)
+ fn = np.cross(edge1, edge2)
# Normalize face normals
lengths = np.linalg.norm(fn, axis=1, keepdims=True)
@@ -183,8 +153,8 @@ def _compute_outward_normals(
fn /= lengths
# Accumulate to vertices
- for i in range(len(corrected_faces)):
- idx = corrected_faces[i]
+ for i in range(len(faces)):
+ idx = faces[i]
v_normals[idx[0]] += fn[i]
v_normals[idx[1]] += fn[i]
v_normals[idx[2]] += fn[i]
@@ -194,7 +164,7 @@ def _compute_outward_normals(
v_lengths[v_lengths < 1e-10] = 1.0
v_normals /= v_lengths
- return v_normals.astype(np.float32), corrected_faces.astype(np.uint32)
+ return v_normals.astype(np.float32), faces.astype(np.uint32)
def occ_shape_to_stl(