3 Commits

Author SHA1 Message Date
bklronin 48042659fc - arc improvements, fillets, operations, bodys 2026-08-02 22:04:57 +02:00
bklronin baa2fd5d47 - added "measurement lines" 2026-08-02 20:58:43 +02:00
bklronin 0daa6152ee - added "measurement lines" 2026-07-26 21:39:47 +02:00
14 changed files with 6257 additions and 2351 deletions
+65 -48
View File
@@ -4,16 +4,15 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Render improvements, camera plane, update">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added &quot;measurement lines&quot;">
<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/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.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/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.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" />
@@ -30,7 +29,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="single_window" />
<entry key="$PROJECT_DIR$" value="feature/surface-modifier-pyramid-patterns" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -50,45 +49,47 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<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 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.debug_dragging.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.main_window.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&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>
}</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" />
@@ -96,9 +97,9 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" />
</key>
@@ -442,7 +443,23 @@
<option name="project" value="LOCAL" />
<updated>1783976498520</updated>
</task>
<option name="localTasksCounter" value="41" />
<task id="LOCAL-00041" summary="- added &quot;measurement lines&quot;">
<option name="closed" value="true" />
<created>1785094789247</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" />
<updated>1785094789248</updated>
</task>
<task id="LOCAL-00042" summary="- added &quot;measurement lines&quot;">
<option name="closed" value="true" />
<created>1785697123545</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" />
<updated>1785697123545</updated>
</task>
<option name="localTasksCounter" value="43" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -463,7 +480,6 @@
<ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots>
<MESSAGE value="- Sketch projection partly works again :)" />
<MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" />
@@ -488,6 +504,7 @@
<MESSAGE value="- added renderer" />
<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" />
<MESSAGE value="- added &quot;measurement lines&quot;" />
<option name="LAST_COMMIT_MESSAGE" value="- added &quot;measurement lines&quot;" />
</component>
</project>
+856 -831
View File
File diff suppressed because it is too large Load Diff
+546 -523
View File
File diff suppressed because it is too large Load Diff
+89 -1
View File
@@ -235,6 +235,93 @@ class OCGeometryKernel(GeometryKernel):
pass
return None
@staticmethod
def find_coplanar_face(
shape: Any,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
ref_center: Optional[Tuple[float, float, float]] = None,
angle_tol_deg: float = 5.0,
dist_tol: float = 1e-3,
) -> Optional[Tuple[Any, Tuple[float, float, float]]]:
"""Find a planar face on *shape* coplanar with the given plane.
Iterates the faces of *shape* and returns the first planar face whose
plane normal is parallel to *normal* (within *angle_tol_deg* degrees)
and whose plane passes through *origin* (within *dist_tol* distance).
When several faces match, the one whose surface centre is closest to
*ref_center* (if provided) is preferred.
Returns ``(face, center)`` where *center* is the surface centroid as a
3-tuple, or *None* if no matching face is found.
"""
import math
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Plane
from OCP.BRepGProp import BRepGProp
from OCP.GProp import GProp_GProps
import numpy as np
if shape is None:
return None
n = np.asarray(normal, dtype=float)
n = n / (np.linalg.norm(n) + 1e-30)
ox, oy, oz = origin
cos_tol = math.cos(math.radians(angle_tol_deg))
candidates: list = []
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = TopoDS.Face_s(explorer.Current())
try:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
explorer.Next()
continue
plane = surf.Plane()
pn = np.array(
[
plane.Axis().Direction().X(),
plane.Axis().Direction().Y(),
plane.Axis().Direction().Z(),
],
dtype=float,
)
# Check normals parallel (same or opposite direction)
cos_angle = abs(float(np.dot(n, pn)))
if cos_angle < cos_tol:
explorer.Next()
continue
# Check distance from plane to origin
pp = plane.Location()
d = abs(float(np.dot(n, np.array([pp.X() - ox, pp.Y() - oy, pp.Z() - oz]))))
if d > dist_tol:
explorer.Next()
continue
# Surface centroid via GProp (SurfaceProperties for faces)
props = GProp_GProps()
BRepGProp.SurfaceProperties_s(face, props)
c = props.CentreOfMass()
center = (float(c.X()), float(c.Y()), float(c.Z()))
candidates.append((face, center))
except Exception:
pass
explorer.Next()
if not candidates:
return None
if ref_center is not None and len(candidates) > 1:
rc = np.asarray(ref_center, dtype=float)
best = min(candidates, key=lambda fc: float(np.linalg.norm(np.asarray(fc[1]) - rc)))
return best
return candidates[0]
def revolve(
self,
sketch: GeometryObject,
@@ -368,10 +455,11 @@ class OCGeometryKernel(GeometryKernel):
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
fillet.Add(radius, explorer.Current())
fillet.Add(radius, TopoDS.Edge_s(explorer.Current()))
explorer.Next()
fillet.Build()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
"""Surface modifier for OpenCASCADE geometry.
Applies geometric patterns (pyramids, bumps, grooves) to 3D surfaces using boolean operations.
This enables grip-enhancing textures and visual surface modifications on CAD models.
"""
from __future__ import annotations
import logging
import math
from typing import Any, Optional, Tuple
# OCC imports at module level for common types
logger = logging.getLogger(__name__)
class SurfaceModifier:
"""Applies geometric patterns to 3D surfaces using OCC boolean operations."""
def __init__(self):
self._patterns_applied = []
def apply_pyramid_pattern(
self,
face_shape,
pyramid_height: float = 1.0,
base_radius: float = 2.0,
spacing: float = 5.0,
num_rings: Optional[int] = None,
direction: Tuple[float, float, float] = (0, 0, 1),
) -> Optional[Any]:
"""Apply a pyramid pattern to a face surface.
Args:
face_shape: OCC TopoDS_Shape representing the face or solid
pyramid_height: Height of each pyramid
base_radius: Radius of pyramid base
spacing: Distance between pyramids
num_rings: Number of concentric rings (auto-calculated if None)
direction: Normal direction for pyramids
Returns:
Modified shape on success, None on failure
"""
try:
from OCP.TopAbs import TopAbs_FACE
from OCP.TopoDS import TopoDS_Face, TopoDS_Shape
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.TopExp import TopExp_Explorer
from OCP.BRepAdaptor import BRepAdaptor_Surface
# Validate face shape
if not isinstance(face_shape, (TopoDS_Shape, TopoDS_Face)):
logger.error("Invalid face shape type")
return None
# Extract the first face for surface parameterization
if isinstance(face_shape, TopoDS_Shape):
explorer = TopExp_Explorer(face_shape, TopAbs_FACE)
if not explorer.More():
logger.error("No faces found in shape")
return None
from OCP import TopoDS
face = TopoDS.TopoDS.Face_s(explorer.Current())
else:
face = face_shape
# Get face surface for UV parameterization
surf = BRepAdaptor_Surface(face)
u_min, u_max = surf.FirstUParameter(), surf.LastUParameter()
v_min, v_max = surf.FirstVParameter(), surf.LastVParameter()
# Calculate number of rings if not specified
if num_rings is None:
# Estimate based on face area and spacing
u_range = u_max - u_min
v_range = v_max - v_min
avg_dim = (u_range + v_range) / 2.0
num_rings = max(1, min(int(avg_dim / spacing), 5))
logger.info(
f"Applying pyramid pattern: {num_rings} rings, "
f"{base_radius:.2f} radius, {pyramid_height:.2f} height"
)
# Create pyramids distributed across the face UV space
result_shape = face_shape
pyramid_count = 0
for ring_idx in range(num_rings):
# Distribute rings evenly across UV parameter space
u_fraction = (ring_idx + 1) / (num_rings + 1)
v_fraction = 0.5 # Center vertically
# Map to actual UV coordinates on the face
u_pos = u_min + u_fraction * (u_max - u_min)
v_pos = v_min + v_fraction * (v_max - v_min)
# Get 3D position and tangent vectors at this UV point
from OCP.gp import gp_Pnt, gp_Vec
center_pt = gp_Pnt()
d1u = gp_Vec()
d1v = gp_Vec()
surf.D1(u_pos, v_pos, center_pt, d1u, d1v)
# Normal is cross product of tangent vectors
normal = d1u.Crossed(d1v)
normal.Normalize()
# Calculate number of pyramids in this ring based on spacing
if ring_idx == 0:
num_pyramids = 1 # Center pyramid
else:
circumference = 2.0 * math.pi * (ring_idx * spacing)
num_pyramids = max(3, int(circumference / spacing))
for i in range(num_pyramids):
if ring_idx == 0:
# Center pyramid - place at face center
place_u = u_pos
place_v = v_pos
else:
angle = (2.0 * math.pi * i) / num_pyramids
# Offset in UV space based on ring radius
offset_u = (ring_idx * spacing / (u_max - u_min)) * math.cos(angle)
offset_v = (ring_idx * spacing / (v_max - v_min)) * math.sin(angle)
place_u = max(u_min, min(u_max, u_pos + offset_u))
place_v = max(v_min, min(v_max, v_pos + offset_v))
try:
# Get 3D position and normal for this pyramid
pyramid_pt = gp_Pnt()
pd1u = gp_Vec()
pd1v = gp_Vec()
surf.D1(place_u, place_v, pyramid_pt, pd1u, pd1v)
pyramid_normal = pd1u.Crossed(pd1v)
pyramid_normal.Normalize()
# Create solid pyramid at this position
pyramid_shape = self._create_solid_pyramid(
pyramid_pt,
pyramid_normal,
pyramid_height,
base_radius,
)
if pyramid_shape is not None:
# Fuse with existing geometry
fuse = BRepAlgoAPI_Fuse(result_shape, pyramid_shape)
fuse.Build()
if fuse.IsDone():
result_shape = fuse.Shape()
pyramid_count += 1
else:
logger.warning(
f"Failed to fuse pyramid at ({place_u:.2f}, {place_v:.2f})"
)
except Exception as e:
logger.debug(
f"Error creating pyramid at ring {ring_idx}, pyramid {i}: {e}"
)
self._patterns_applied.append(
{
"type": "pyramid",
"parameters": {
"height": pyramid_height,
"base_radius": base_radius,
"spacing": spacing,
"num_rings": num_rings,
"direction": direction,
},
}
)
logger.info(f"Successfully applied {pyramid_count} pyramids")
return result_shape
except Exception as e:
logger.error(f"Error applying pyramid pattern: {e}", exc_info=True)
return None
def _create_solid_pyramid(
self,
base_point, # gp_Pnt - position on the face
normal_vec, # gp_Dir or gp_Vec - surface normal direction
height: float,
base_radius: float,
) -> Optional[Any]:
"""Create a solid pyramid at the specified position and orientation.
Uses BRepPrimAPI_MakePrism to extrude a square base into a solid pyramid.
Args:
base_point: 3D point where pyramid base is centered
normal_vec: Direction vector for pyramid growth (surface normal)
height: Height of the pyramid from base to apex
base_radius: Half-width of the square base
Returns:
OCC solid shape for the pyramid, or None on failure
"""
try:
from OCP.gp import gp_Dir, gp_Ax2, gp_Vec
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakeWire,
)
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
half = base_radius / 2.0
# Build orthonormal basis from normal vector
if isinstance(normal_vec, gp_Vec):
n_dir = gp_Dir(normal_vec.XYZ())
else:
n_dir = normal_vec
# Create a local coordinate system at the base point
local_ax2 = gp_Ax2(base_point, n_dir)
# Get X and Y axes from the local coordinate system
x_dir = local_ax2.XDirection()
y_dir = local_ax2.YDirection()
# Create 4 corners of the square base in the local plane
corner_points = [
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(half),
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(half),
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(-half),
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(-half),
]
# Create edges connecting the corners
wire_maker = BRepBuilderAPI_MakeWire()
for idx in range(4):
next_idx = (idx + 1) % 4
edge = BRepBuilderAPI_MakeEdge(
corner_points[idx], corner_points[next_idx]
).Edge()
wire_maker.Add(edge)
if not wire_maker.IsDone():
logger.warning("Failed to create pyramid base wire")
return None
# Extrude the base wire in the normal direction by height to form a prism
extrusion_vec = gp_Vec(n_dir).Multiplied(height)
prism_maker = BRepPrimAPI_MakePrism(
wire_maker.Wire(), extrusion_vec, False # no check intersection
)
prism_maker.Build()
if not prism_maker.IsDone():
logger.warning("Failed to create pyramid prism")
return None
return prism_maker.Shape()
except Exception as e:
logger.debug(f"Error creating solid pyramid: {e}")
return None
def apply_bump_pattern(
self,
face_shape,
bump_height: float = 1.0,
bump_radius: float = 2.0,
spacing: float = 5.0,
num_rings: Optional[int] = None,
) -> Optional[Any]:
"""Apply a simple bump pattern to a face surface.
Args:
face_shape: OCC TopoDS_Shape representing the face
bump_height: Height of each bump
bump_radius: Radius of each bump base
spacing: Distance between bumps
num_rings: Number of concentric rings
Returns:
Modified shape on success, None on failure
"""
return self.apply_pyramid_pattern(
face_shape,
pyramid_height=bump_height,
base_radius=bump_radius,
spacing=spacing,
num_rings=num_rings,
)
def apply_surface_modifier_to_body(
body_geometry, modifier_type: str = "pyramid", **parameters
) -> Optional[Any]:
"""Apply a surface modifier to a body geometry.
Args:
body_geometry: OCCGeometryObject or similar geometry object
modifier_type: Type of modifier ('pyramid', 'bump')
**parameters: Modifier-specific parameters
Returns:
Modified shape, or None on failure
"""
from fluency.geometry_occ.kernel import OCGeometryKernel
kernel = OCGeometryKernel()
shape = kernel._get_shape(body_geometry)
if shape is None:
logger.error("No geometry found in body")
return None
modifier = SurfaceModifier()
try:
if modifier_type == "pyramid":
success = modifier.apply_pyramid_pattern(shape, **parameters)
elif modifier_type == "bump":
success = modifier.apply_bump_pattern(shape, **parameters)
else:
logger.error(f"Unknown modifier type: {modifier_type}")
return None
if not success:
logger.error("Surface modifier application failed")
return None
# Return the modified shape wrapped in OCCGeometryObject
from fluency.geometry_occ.kernel import OCCGeometryObject
return OCCGeometryObject(shape)
except Exception as e:
logger.error(f"Error applying surface modifier: {e}", exc_info=True)
return None
# Example usage and testing
if __name__ == "__main__":
# Create a simple test case
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
# Create a box to modify
box_maker = BRepPrimAPI_MakeBox(50, 50, 10)
box_maker.Build()
if box_maker.IsDone():
print("Created test box")
# Apply pyramid pattern to top face (Z direction)
modifier = SurfaceModifier()
success = modifier.apply_pyramid_pattern(
box_maker.Shape(),
pyramid_height=2.0,
base_radius=3.0,
spacing=8.0,
num_rings=2,
direction=(0, 0, 1),
)
if success:
print("Successfully applied pyramid pattern")
# Export modified shape
from OCP.StlAPI import StlAPI_Writer
from OCP.BRepMesh import BRepMesh_IncrementalMesh
tess = BRepMesh_IncrementalMesh(box_maker.Shape(), 0.1)
tess.Perform()
writer = StlAPI_Writer()
writer.SetASCIIMode(False)
writer.Write(box_maker.Shape(), "/tmp/test_pyramid_pattern.stl")
print("Exported modified shape to STL")
else:
print("Failed to apply pyramid pattern")
else:
print("Failed to create test box")
+161 -26
View File
@@ -28,6 +28,7 @@ import logging
import os
import shutil
import tempfile
import uuid
import zipfile
from dataclasses import asdict, is_dataclass
from datetime import datetime
@@ -42,6 +43,7 @@ from fluency.models.data_model import (
Body,
Component,
Connector,
Feature,
Project,
Sketch,
Workplane,
@@ -65,7 +67,7 @@ def _json_default(obj: Any) -> Any:
return sorted(obj)
if isinstance(obj, tuple):
return list(obj)
if is_dataclass(obj):
if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj)
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
@@ -85,10 +87,37 @@ def _coerce_listlike(value: Any) -> List[Any]:
return list(value)
def _to_float(value: Any, default: float = 0.0) -> float:
"""Safely coerce a saved value to float, falling back to *default*.
Corrupt archives may store a string or None where a number is expected;
the loaders must not crash on them.
"""
try:
return float(value)
except (TypeError, ValueError):
return default
def _saved_id(data: Dict[str, Any]) -> str:
"""Return a saved entity id, or a fresh UUID for corrupt/legacy data.
Old files always wrote an ``id``; a missing/empty value means the
archive is damaged, and the model's uuid factory only kicks in when the
constructor argument is omitted so we generate here to keep ids valid
non-empty strings.
"""
v = data.get("id")
if isinstance(v, str) and v:
return v
return str(uuid.uuid4())
def _to_3tuple(value: Any) -> Tuple[float, float, float]:
"""Coerce a saved 3-vector to a tuple of floats (for OCC)."""
if value is None:
return (0.0, 0.0, 0.0)
try:
if isinstance(value, np.ndarray):
seq = value.tolist()
else:
@@ -96,10 +125,13 @@ def _to_3tuple(value: Any) -> Tuple[float, float, float]:
if len(seq) < 3:
seq = list(seq) + [0.0] * (3 - len(seq))
return (float(seq[0]), float(seq[1]), float(seq[2]))
except (TypeError, ValueError, IndexError):
return (0.0, 0.0, 0.0)
def _to_3vec(value: Any) -> np.ndarray:
"""Coerce a saved 3-vector to a 3-element numpy array."""
try:
if isinstance(value, np.ndarray):
return value.astype(float).reshape(3)
if value is None:
@@ -108,10 +140,13 @@ def _to_3vec(value: Any) -> np.ndarray:
if len(seq) < 3:
seq = list(seq) + [0.0] * (3 - len(seq))
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
except (TypeError, ValueError, IndexError):
return np.zeros(3, dtype=float)
def _to_mat3(value: Any) -> np.ndarray:
"""Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray."""
try:
if isinstance(value, np.ndarray):
arr = value.astype(float)
return arr.reshape(3, 3)
@@ -121,6 +156,8 @@ def _to_mat3(value: Any) -> np.ndarray:
if len(flat) < 9:
flat = flat + [0.0] * (9 - len(flat))
return np.array(flat[:9], dtype=float).reshape(3, 3)
except (TypeError, ValueError, IndexError):
return np.eye(3, dtype=float)
def _parse_iso(value: Optional[str]) -> datetime:
@@ -151,11 +188,11 @@ def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]:
def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
wp = Workplane(
id=data.get("id") or None, # Workplane generates uuid if None
id=_saved_id(data),
name=data.get("name", "Untitled Workplane"),
origin=tuple(data.get("origin", (0.0, 0.0, 0.0))),
normal=tuple(data.get("normal", (0.0, 0.0, 1.0))),
x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
origin=_to_3tuple(data.get("origin", (0.0, 0.0, 0.0))),
normal=_to_3tuple(data.get("normal", (0.0, 0.0, 1.0))),
x_dir=_to_3tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
visible=bool(data.get("visible", True)),
)
wp.created_at = _parse_iso(data.get("created_at"))
@@ -163,6 +200,56 @@ def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
return wp
def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
"""Serialize one parametric feature (sketch id + params).
"base" snapshot features are NOT serialized here their frozen
geometry is written as a separate STEP member (``base_geometry_ref``)
and the ``features_base_snapshot`` flag on the body marks that the
list starts with one.
"""
return {
"id": feat.id,
"operation": feat.operation,
"sketch_id": feat.sketch.id if feat.sketch is not None else None,
"length": feat.length,
"symmetric": bool(feat.symmetric),
"invert": bool(feat.invert),
"through_all": bool(feat.through_all),
"cut_all_bodies": bool(feat.cut_all_bodies),
"face_index": feat.face_index,
"angle": _to_float(feat.angle, 360.0),
"radius": feat.radius,
"tangent_propagation": bool(feat.tangent_propagation),
"scope": feat.scope,
"edge_refs": list(feat.edge_refs),
}
def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Feature:
"""Deserialize a feature, resolving its sketch reference against the
component's already-loaded sketches."""
feat = Feature(
id=_saved_id(data),
operation=data.get("operation", "extrude"),
length=data.get("length"),
symmetric=bool(data.get("symmetric", False)),
invert=bool(data.get("invert", False)),
through_all=bool(data.get("through_all", False)),
cut_all_bodies=bool(data.get("cut_all_bodies", False)),
face_index=data.get("face_index"),
angle=_to_float(data.get("angle"), 360.0),
radius=data.get("radius"),
tangent_propagation=bool(data.get("tangent_propagation", False)),
scope=data.get("scope", "selected"),
edge_refs=list(data.get("edge_refs") or []),
)
sid = data.get("sketch_id")
if sid and sid in sketches:
feat.sketch = sketches[sid]
return feat
def _body_to_dict(body: Body) -> Dict[str, Any]:
"""Body serialization. ``geometry_ref`` is set later by the ZIP writer
once the STEP file is written."""
@@ -177,12 +264,16 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
"extrude_cut": body.extrude_cut,
"extrude_union": body.extrude_union,
"extrude_through_all": body.extrude_through_all,
"extrude_cut_all_bodies": body.extrude_cut_all_bodies,
"extrude_face_index": body.extrude_face_index,
"extrude_target_body_id": body.extrude_target_body_id,
"features": [_feature_to_dict(f) for f in body.features if f.operation != "base"],
"features_base_snapshot": bool(body.features and body.features[0].operation == "base"),
"base_geometry_ref": None, # filled in by save_project
"position": _coerce_listlike(body.position),
"rotation": _coerce_listlike(body.rotation),
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
"opacity": float(body.opacity),
"opacity": _to_float(body.opacity, 1.0),
"visible": bool(body.visible),
"has_geometry": body.geometry is not None,
"geometry_ref": None, # filled in by save_project
@@ -201,7 +292,7 @@ def _body_from_dict(
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
body = Body(
id=data.get("id") or None,
id=_saved_id(data),
name=data.get("name", "Untitled Body"),
geometry=geometry,
source_sketch=source_sketch,
@@ -212,12 +303,13 @@ def _body_from_dict(
extrude_cut=bool(data.get("extrude_cut", False)),
extrude_union=bool(data.get("extrude_union", False)),
extrude_through_all=bool(data.get("extrude_through_all", False)),
extrude_cut_all_bodies=bool(data.get("extrude_cut_all_bodies", False)),
extrude_face_index=data.get("extrude_face_index"),
extrude_target_body_id=data.get("extrude_target_body_id"),
position=_to_3vec(data.get("position")),
rotation=_to_mat3(data.get("rotation")),
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
opacity=float(data.get("opacity", 1.0)),
opacity=_to_float(data.get("opacity"), 1.0),
visible=bool(data.get("visible", True)),
)
body.created_at = _parse_iso(data.get("created_at"))
@@ -267,9 +359,9 @@ def _sketch_from_dict(
# Re-apply the workplane (from_dict already does this internally, but be
# defensive in case the saved dict didn't carry the workplane fields).
occ_sketch.set_workplane(
tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
_to_3tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
_to_3tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
_to_3tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
)
geometry: Optional[OCCGeometryObject] = None
@@ -277,7 +369,7 @@ def _sketch_from_dict(
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
sk = Sketch(
id=data.get("id") or None,
id=_saved_id(data),
name=data.get("name", "Untitled Sketch"),
occ_sketch=occ_sketch,
geometry=geometry,
@@ -314,7 +406,7 @@ def _component_from_dict(
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> Component:
comp = Component(
id=data.get("id") or None,
id=_saved_id(data),
name=data.get("name", "Untitled Component"),
description=data.get("description", ""),
active_sketch=data.get("active_sketch"),
@@ -335,7 +427,22 @@ def _component_from_dict(
src_id = body_data.get("source_sketch_id")
if src_id and src_id in comp.sketches:
src_sketch = comp.sketches[src_id]
comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch)
body = _body_from_dict(body_data, body_geometry_loader, src_sketch)
# Parametric feature history (new files). Old files carry no
# "features" key — the body keeps an empty list and is migrated
# lazily at update time (see ``_ensure_feature_history``).
for f_data in body_data.get("features") or []:
body.features.append(_feature_from_dict(f_data, comp.sketches))
if body_data.get("features_base_snapshot") and body.features:
# The list was saved WITHOUT its leading "base" snapshot;
# restore it from the dedicated STEP member.
base_geom: Optional[OCCGeometryObject] = None
base_ref = body_data.get("base_geometry_ref")
if base_ref and body_geometry_loader is not None:
base_geom = body_geometry_loader(base_ref)
if base_geom is not None:
body.features.insert(0, Feature(operation="base", geometry=base_geom))
comp.bodies[bid] = body
return comp
@@ -347,8 +454,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
"position": list(conn.position),
"normal": list(conn.normal),
"x_dir": list(conn.x_dir),
"axis_rotation": float(conn.axis_rotation),
"offset": float(conn.offset),
"axis_rotation": _to_float(conn.axis_rotation, 0.0),
"offset": _to_float(conn.offset, 0.0),
"assembly_component_id": conn.assembly_component_id,
"source_obj_id": conn.source_obj_id,
"partner_ac_id": conn.partner_ac_id,
@@ -362,13 +469,13 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
def _connector_from_dict(data: Dict[str, Any]) -> Connector:
conn = Connector(
id=data.get("id") or None,
id=_saved_id(data),
name=data.get("name", "Untitled Connector"),
position=_to_3tuple(data.get("position")),
normal=_to_3tuple(data.get("normal")),
x_dir=_to_3tuple(data.get("x_dir")),
axis_rotation=float(data.get("axis_rotation", 0.0)),
offset=float(data.get("offset", 0.0)),
axis_rotation=_to_float(data.get("axis_rotation"), 0.0),
offset=_to_float(data.get("offset"), 0.0),
assembly_component_id=data.get("assembly_component_id", ""),
source_obj_id=data.get("source_obj_id", ""),
)
@@ -396,7 +503,7 @@ def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
ac = AssemblyComponent(
id=data.get("id") or None,
id=_saved_id(data),
component_id=data.get("component_id", ""),
name=data.get("name", "Untitled Instance"),
position=_to_3vec(data.get("position")),
@@ -422,7 +529,7 @@ def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]:
def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
conn = AssemblyConnection(
id=data.get("id") or None,
id=_saved_id(data),
first_ac_id=data.get("first_ac_id", ""),
second_ac_id=data.get("second_ac_id", ""),
first_connector_id=data.get("first_connector_id"),
@@ -446,7 +553,7 @@ def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
asm = Assembly(
id=data.get("id") or None,
id=_saved_id(data),
name=data.get("name", "Untitled Assembly"),
active_assembly_component=data.get("active_assembly_component"),
)
@@ -454,7 +561,7 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
asm.modified_at = _parse_iso(data.get("modified_at"))
for cid, ac_data in (data.get("components") or {}).items():
asm.components[cid] = _assembly_component_from_dict(ac_data)
for c_data in (data.get("connections") or []):
for c_data in data.get("connections") or []:
asm.connections.append(_assembly_connection_from_dict(c_data))
return asm
@@ -517,7 +624,9 @@ def _read_step_bytes(
with open(tmp_path, "wb") as f:
f.write(data)
geom = kernel.import_step(tmp_path)
return geom
from typing import cast
return cast(OCCGeometryObject, geom)
except Exception as exc:
logger.warning("Failed to read STEP: %s", exc)
return None
@@ -580,6 +689,20 @@ def save_project(
arcname = f"bodies/{body_id}.step"
body_files.append((arcname, step_bytes))
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
# Base-snapshot STEP for migrated legacy bodies whose feature
# list starts with a frozen "base" geometry snapshot.
if (
body.features
and body.features[0].operation == "base"
and body.features[0].geometry is not None
):
base_bytes = _write_step_for_body(kernel, body.features[0].geometry)
if base_bytes is not None:
base_arcname = f"bodies/{body_id}_base.step"
body_files.append((base_arcname, base_bytes))
manifest["components"][comp_id]["bodies"][body_id]["base_geometry_ref"] = (
base_arcname
)
# Per-sketch STEP files (solved face geometry).
sketch_files: List[Tuple[str, bytes]] = []
@@ -672,7 +795,10 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
with zipfile.ZipFile(filepath, "r") as zipf:
manifest_raw = zipf.read("project.json")
try:
manifest = json.loads(manifest_raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise RuntimeError(f"Corrupt project file (bad JSON): {filepath}") from exc
view_state: Dict[str, Any] = manifest.get("view_state") or {}
# If a sketch's occ_sketch is referenced as a separate file, read
@@ -687,12 +813,21 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
except KeyError:
logger.warning("Sketch meta missing in archive: %s", ref)
continue
try:
meta = json.loads(meta_bytes.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
logger.warning("Sketch meta corrupt in archive: %s (%s)", ref, exc)
continue
sk_data["occ_sketch"] = meta.get("occ_sketch")
# Workplane fields on the sketch-level file override the
# embedded ones (source of truth lives in the sidecar).
for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
"is_solved", "is_fully_constrained"):
for k in (
"workplane_origin",
"workplane_normal",
"workplane_x_dir",
"is_solved",
"is_fully_constrained",
):
if k in meta:
sk_data[k] = meta[k]
+63 -3
View File
@@ -60,9 +60,9 @@ class Workplane:
x = x / x_norm
y = np.cross(n, x)
y = y / np.linalg.norm(y)
self.normal = tuple(float(v) for v in n)
self.x_dir = tuple(float(v) for v in x)
self._y_dir = tuple(float(v) for v in y)
self.normal = (float(n[0]), float(n[1]), float(n[2]))
self.x_dir = (float(x[0]), float(x[1]), float(x[2]))
self._y_dir = (float(y[0]), float(y[1]), float(y[2]))
@property
def y_dir(self) -> Tuple[float, float, float]:
@@ -206,6 +206,59 @@ class Sketch:
self.modified_at = datetime.now()
@dataclass
class Feature:
"""
One operation in a body's parametric feature history.
Bodies rebuild their geometry by replaying their ordered feature
list from scratch (see ``Body.features``). This is what makes
sketch edits propagate: a moved circle re-cuts at the new position
on a freshly rebuilt base instead of adding to the previous result.
``operation`` is one of:
- "extrude": base solid, ``kernel.extrude`` of the sketch profile
- "revolve": base solid, ``kernel.revolve`` of the sketch profile
- "cut": boolean difference of the running geometry with the
extruded sketch profile
- "union": boolean union of the running geometry with the
extruded sketch profile
- "fillet": round a set of edges of the running geometry
(``radius``, ``tangent_propagation``, ``scope``,
``edge_refs`` see below)
- "base": frozen geometry snapshot (``geometry`` field) used
to migrate legacy bodies whose original base feature
is unknown. Never the result of a user operation.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
operation: str = "extrude"
sketch: Optional[Sketch] = None # runtime ref; serialized as sketch_id
length: Optional[float] = None
symmetric: bool = False
invert: bool = False
through_all: bool = False
cut_all_bodies: bool = False
face_index: Optional[int] = None # which sketch face was selected
angle: float = 360.0 # revolve only (degrees)
# "base" features only: frozen pre-feature geometry snapshot.
geometry: Optional[OCCGeometryObject] = None
# "fillet" features only: radius (mm) of the round, whether the fillet
# should extend along edges tangent to the picked ones, the edge scope
# ("selected" = edges between the two picked faces, "all" = every edge
# of the body), and stable fingerprints of the selected edges so the
# replay can re-find them after the base geometry is rebuilt.
radius: Optional[float] = None
tangent_propagation: bool = False
scope: str = "selected"
edge_refs: List[str] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now)
@dataclass
class Body:
"""
@@ -222,6 +275,12 @@ class Body:
source_sketch: Optional[Sketch] = None
source_operation: str = "extrude"
# Parametric feature history. When non-empty, the body is rebuilt
# from scratch by replaying these features in order; the flat
# extrude_* / source_* fields below then only mirror the LAST
# feature for backward compatibility (old files, old code paths).
features: List[Feature] = field(default_factory=list)
# Re-extrusion parameters — stored so the body can be rebuilt from
# its source sketch when the sketch is edited. None means the body
# was not created by an extrude-type operation and cannot be auto-
@@ -232,6 +291,7 @@ class Body:
extrude_cut: bool = False
extrude_union: bool = False
extrude_through_all: bool = False
extrude_cut_all_bodies: bool = False # cut through all bodies in component
extrude_face_index: Optional[int] = None # which sketch face was selected
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
needs_update: bool = False # True when source sketch changed since last extrude
+241 -94
View File
@@ -126,6 +126,8 @@ class OCCRenderer(Renderer):
self._nav_mode: Optional[str] = None # "rotate" | "pan" | None
# Persistent light-blue transparent overlay marking the selected face.
self._highlight_ais: Any = None
# Overlays for the fillet tool's two picked faces (one AIS per face).
self._faces_highlight_ais: List[Any] = []
# Temporary transparent preview AIS for the live extrude/cut dialog.
self._preview_ais: Any = None
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
@@ -137,6 +139,7 @@ class OCCRenderer(Renderer):
self._parent_widget = parent_widget
import os as _os
if _os.environ.get("QT_QPA_PLATFORM") == "offscreen":
logger.warning("OCCRenderer skipped (QT_QPA_PLATFORM=offscreen)")
return False
@@ -159,16 +162,14 @@ class OCCRenderer(Renderer):
)
from OCP.AIS import AIS_InteractiveContext
from OCP.Graphic3d import (
Graphic3d_Camera,
Graphic3d_TypeOfShadingModel,
Graphic3d_MaterialAspect,
Graphic3d_NameOfMaterial,
)
from OCP.Quantity import (
Quantity_Color,
Quantity_TOC_RGB,
Quantity_NameOfColor,
)
logger.info("OCCRenderer imports complete")
hwnd = int(parent_widget.winId())
@@ -203,9 +204,7 @@ class OCCRenderer(Renderer):
Quantity_Color(0.5, 0.5, 0.55, Quantity_TOC_RGB),
True,
)
ambient = V3d_AmbientLight(
Quantity_Color(0.35, 0.35, 0.4, Quantity_TOC_RGB)
)
ambient = V3d_AmbientLight(Quantity_Color(0.35, 0.35, 0.4, Quantity_TOC_RGB))
for light in (key, fill, rim, ambient):
viewer.SetLightOn(light)
@@ -249,6 +248,7 @@ class OCCRenderer(Renderer):
# pick preview matches the persistent selection overlay below.
try:
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
# Modify the existing dynamic-highlight drawer in place (per
# OCC docs this is safer than building a fresh Prs3d_Drawer).
hd = context.HighlightStyle()
@@ -305,7 +305,6 @@ class OCCRenderer(Renderer):
"""
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
from OCP.Prs3d import Prs3d_Drawer
obj_id = name or f"shape_{uuid.uuid4().hex[:8]}"
@@ -345,6 +344,7 @@ class OCCRenderer(Renderer):
# explicit pick methods (pick_entity / pick_planar_face).
try:
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
mode = AIS_Shape.SelectionMode_s(topo)
self._context.Activate(ais, mode)
@@ -377,9 +377,7 @@ class OCCRenderer(Renderer):
"""
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
mat = Graphic3d_MaterialAspect(
Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC
)
mat = Graphic3d_MaterialAspect(Graphic3d_NameOfMaterial.Graphic3d_NOM_PLASTIC)
return mat
# ─── Legacy mesh / wireframe (kept for backward compat) ────────────
@@ -582,10 +580,12 @@ class OCCRenderer(Renderer):
self._context.RemoveAll(True)
except Exception:
from OCP.AIS import AIS_ListOfInteractive, AIS_KindOfInteractive
lst = AIS_ListOfInteractive()
self._context.DisplayedObjects(AIS_KindOfInteractive.AIS_KOI_None, -1, lst)
for ais in lst:
self._context.Remove(ais, True)
self._faces_highlight_ais = []
self._objects.clear()
def update_mesh(
@@ -702,10 +702,9 @@ class OCCRenderer(Renderer):
# Check projection type.
from OCP.Graphic3d import Graphic3d_Camera
proj_type = cam.ProjectionType()
is_orthographic = (
proj_type == Graphic3d_Camera.Projection_Orthographic
)
is_orthographic = proj_type == Graphic3d_Camera.Projection_Orthographic
if not is_orthographic:
# Perspective mode: use the actual eye position directly.
@@ -726,6 +725,7 @@ class OCCRenderer(Renderer):
# Compute scene bounding box diagonal from displayed objects.
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box()
try:
for robj in self._objects.values():
@@ -737,13 +737,7 @@ class OCCRenderer(Renderer):
pass
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
diag = float(
np.sqrt(
(xmax - xmin) ** 2
+ (ymax - ymin) ** 2
+ (zmax - zmin) ** 2
)
)
diag = float(np.sqrt((xmax - xmin) ** 2 + (ymax - ymin) ** 2 + (zmax - zmin) ** 2))
# Fallback: if bbox is empty (no objects or all shapes failed),
# use the eye-to-at distance as a reasonable estimate.
@@ -753,9 +747,8 @@ class OCCRenderer(Renderer):
# Base distance: how far the camera must be for the bbox diagonal
# to fill the frame at the given vertical FOV.
import math
base_distance = diag / (
2.0 * math.tan(math.radians(fov_y / 2.0))
)
base_distance = diag / (2.0 * math.tan(math.radians(fov_y / 2.0)))
# Scale factor maps inversely: larger scale (zoomed in) → closer camera.
# Dividing by view_scale ensures that when the user zooms in (scale increases)
@@ -886,9 +879,7 @@ class OCCRenderer(Renderer):
def on_pick(self, callback: Any) -> None:
pass
def project_to_screen(
self, point: Tuple[float, float, float]
) -> Tuple[float, float]:
def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[float, float]:
return (0.0, 0.0)
def save_screenshot(self, path: str, width: int = 1920, height: int = 1080) -> None:
@@ -898,6 +889,7 @@ class OCCRenderer(Renderer):
if self._view is None:
return
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
qcol = Quantity_Color(*color, Quantity_TOC_RGB)
self._view.SetBackgroundColor(qcol)
@@ -931,7 +923,6 @@ class OCCRenderer(Renderer):
from OCP.gp import gp_Pln
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial
obj_id = name or f"{self._WORKPLANE_BASE_ID}_{uuid.uuid4().hex[:8]}"
@@ -980,6 +971,7 @@ class OCCRenderer(Renderer):
)
# Use gp_Pnt for the corners to make a bounded face.
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
mp = BRepBuilderAPI_MakePolygon()
for c in corners_3d:
mp.Add(gp_Pnt(*c))
@@ -1039,9 +1031,7 @@ class OCCRenderer(Renderer):
def take_screenshot(self) -> bytes:
return b""
def unproject_from_screen(
self, x: float, y: float
) -> Tuple[float, float, float]:
def unproject_from_screen(self, x: float, y: float) -> Tuple[float, float, float]:
return (0.0, 0.0, 0.0)
# ─── Face picking (for sketch-on-surface) ────────────────────────────
@@ -1061,12 +1051,8 @@ class OCCRenderer(Renderer):
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Plane
from OCP.TopoDS import TopoDS_Face, TopoDS
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
from OCP.BRep import BRep_Tool
from OCP.gp import gp_Pln, gp_Dir, gp_Pnt
import numpy as np
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Pln
# Detect what's under the cursor.
self._context.MoveTo(x, y, self._view, True)
@@ -1107,6 +1093,7 @@ class OCCRenderer(Renderer):
# default (non-inverted) extrude would punch back into the body
# instead of building outward on top of it.
from OCP.TopAbs import TopAbs_REVERSED
n = pln.Axis().Direction()
if face.Orientation() == TopAbs_REVERSED:
n = n.Reversed()
@@ -1115,6 +1102,7 @@ class OCCRenderer(Renderer):
# plane, so the UV frame is centred on the face (nicer for sketching).
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box()
BRepBndLib.Add_s(face, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
@@ -1157,6 +1145,56 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
def pick_face(self, x: int, y: int) -> Optional[Dict[str, Any]]:
"""Pick ANY face under screen pixel (x, y) — planar or curved.
Returns ``{"face": TopoDS_Face, "owner_obj_id": str}`` or *None*.
Unlike :meth:`pick_planar_face` (which requires a planar face so it
can derive a UV frame for sketching) this accepts cylindrical /
spherical / spline faces too the fillet tool only needs the face
shape and its owning body.
"""
if self._view is None or self._context is None:
return None
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface
self._context.MoveTo(x, y, self._view, True)
if not self._context.HasDetected():
return None
shape = self._context.DetectedShape()
if shape is None:
return None
face = None
try:
candidate = TopoDS.Face_s(shape)
# Verify it really is a face by building an adaptor (throws for
# edges / vertices).
_ = BRepAdaptor_Surface(candidate)
face = candidate
except Exception:
face = None
if face is None:
return None
# Identify the displayed body that owns this face (same match used
# by pick_planar_face).
owner_obj_id: Optional[str] = None
try:
owner_ais = self._context.DetectedInteractive()
except Exception:
owner_ais = None
if owner_ais is not None:
for oid, robj in self._objects.items():
if robj.ais_shape is owner_ais:
owner_obj_id = oid
break
return {"face": face, "owner_obj_id": owner_obj_id}
def highlight_face(self, face: Any) -> None:
"""Overlay a persistent, mostly-transparent light-blue tint on *face*.
@@ -1205,6 +1243,58 @@ class OCCRenderer(Renderer):
logger.debug("clear_face_highlight remove failed", exc_info=True)
self._highlight_ais = None
# ─── Multi-face highlight (fillet face picking) ─────────────────────────
def highlight_faces(self, faces: List[Any]) -> None:
"""Tint every face in *faces* with the selection overlay.
Unlike :meth:`highlight_face` (single face, used by sketch-on-
surface) this keeps one overlay per face so the fillet tool can
show BOTH picked faces at once. Replaces any previous multi-face
overlay; independent of the single-face highlight.
"""
if self._context is None:
return
self.clear_faces_highlight()
if not faces:
return
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
for face in faces:
ais = AIS_Shape(face)
try:
ais.SetMaterial(self._default_material())
except Exception:
logger.debug("faces highlight material set failed", exc_info=True)
ais.SetColor(Quantity_Color(0.45, 0.75, 1.0, Quantity_TOC_RGB))
ais.SetDisplayMode(1) # shaded
try:
ais.SetTransparency(0.78)
except Exception:
logger.debug("faces highlight transparency set failed", exc_info=True)
try:
# Bias the overlay toward the camera so it draws on top of
# the coincident face surface without z-fighting.
ais.SetPolygonOffsets(3, 1.0, -0.5)
except Exception:
logger.debug("faces highlight polygon offset failed", exc_info=True)
self._context.Display(ais, True)
self._faces_highlight_ais.append(ais)
if self._view is not None:
self._view.Update()
def clear_faces_highlight(self) -> None:
"""Remove the multi-face fillet-pick overlays, if any."""
if self._context is None or not self._faces_highlight_ais:
return
for ais in self._faces_highlight_ais:
try:
self._context.Remove(ais, True)
except Exception:
logger.debug("clear_faces_highlight remove failed", exc_info=True)
self._faces_highlight_ais = []
# ─── General entity picking (for assembly connectors / snaps) ───────────
def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]:
@@ -1246,12 +1336,13 @@ class OCCRenderer(Renderer):
except Exception:
pass
if eye is not None:
results.sort(key=lambda c: float(np.linalg.norm(
np.array(c["position"]) - eye)))
results.sort(key=lambda c: float(np.linalg.norm(np.array(c["position"]) - eye)))
return results[0]
def _classify_detected_shape(
self, shape: Any, owner_obj_id: Optional[str] = None,
self,
shape: Any,
owner_obj_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Classify a detected OCC sub-shape into snap-candidate dicts.
@@ -1267,17 +1358,14 @@ class OCCRenderer(Renderer):
if shape is None:
return []
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Vertex, TopoDS
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Circle
from OCP.BRep import BRep_Tool
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE as TopAbs_EDGE_TYPE
from OCP.gp import gp_Pnt, gp_Dir
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
from OCP.TopExp import TopExp
import numpy as np
# Helper: find owner object id if not supplied.
@@ -1309,6 +1397,7 @@ class OCCRenderer(Renderer):
pln = adaptor.Plane()
n = pln.Axis().Direction()
from OCP.TopAbs import TopAbs_REVERSED
if face.Orientation() == TopAbs_REVERSED:
n = n.Reversed()
nx, ny, nz = n.X(), n.Y(), n.Z()
@@ -1319,20 +1408,26 @@ class OCCRenderer(Renderer):
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
pln_origin = pln.Location()
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)
# x_dir: viewport-aligned so connector gizmo matches screen.
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
return [{
return [
{
"type": "planar_face",
"position": origin,
"normal": (nx, ny, nz),
"x_dir": x_dir,
"face": face,
"owner_obj_id": owner_obj_id,
}]
}
]
elif stype == GeomAbs_Cylinder:
cyl = adaptor.Cylinder()
@@ -1356,9 +1451,11 @@ class OCCRenderer(Renderer):
if curve_adaptor.GetType() == GeomAbs_Circle:
circ = curve_adaptor.Circle()
center_pnt = circ.Location()
circle_centers.append(np.array([
center_pnt.X(), center_pnt.Y(), center_pnt.Z()
], dtype=float))
circle_centers.append(
np.array(
[center_pnt.X(), center_pnt.Y(), center_pnt.Z()], dtype=float
)
)
except Exception:
pass
edge_explorer.Next()
@@ -1368,9 +1465,7 @@ class OCCRenderer(Renderer):
if len(circle_centers) >= 2:
# Project each center onto the axis direction to get a
# scalar "height" value. Cluster into two groups.
ax_dir_np = np.array([
ax_dir.X(), ax_dir.Y(), ax_dir.Z()
], dtype=float)
ax_dir_np = np.array([ax_dir.X(), ax_dir.Y(), ax_dir.Z()], dtype=float)
heights = [np.dot(c, ax_dir_np) for c in circle_centers]
# Sort by height (scalar) and split roughly in half.
indexed = list(enumerate(heights))
@@ -1391,16 +1486,22 @@ class OCCRenderer(Renderer):
# No circular edges found — fall back to parameter-based.
vmin = adaptor.FirstVParameter()
vmax = adaptor.LastVParameter()
c0 = np.array([
c0 = np.array(
[
ax_pos.X() + ax_dir.X() * vmin,
ax_pos.Y() + ax_dir.Y() * vmin,
ax_pos.Z() + ax_dir.Z() * vmin,
], dtype=float)
c1 = np.array([
],
dtype=float,
)
c1 = np.array(
[
ax_pos.X() + ax_dir.X() * vmax,
ax_pos.Y() + ax_dir.Y() * vmax,
ax_pos.Z() + ax_dir.Z() * vmax,
], dtype=float)
],
dtype=float,
)
# Normal = the cylinder axis direction. This is the "bolt
# axis": the direction a bolt would travel INTO the hole.
@@ -1423,7 +1524,8 @@ class OCCRenderer(Renderer):
results: List[Dict[str, Any]] = []
for end_center in [c0, c1]:
origin = (float(end_center[0]), float(end_center[1]), float(end_center[2]))
results.append({
results.append(
{
"type": "cylindrical_face",
"position": origin,
"normal": normal,
@@ -1431,7 +1533,8 @@ class OCCRenderer(Renderer):
"face": face,
"owner_obj_id": owner_obj_id,
"radius": radius,
})
}
)
return results
# Try edge.
@@ -1475,14 +1578,16 @@ class OCCRenderer(Renderer):
x = x / xlen
x_dir = (float(x[0]), float(x[1]), float(x[2]))
return [{
return [
{
"type": "edge",
"position": position,
"normal": tangent,
"x_dir": x_dir,
"edge": edge,
"owner_obj_id": owner_obj_id,
}]
}
]
# Try vertex.
vertex = None
@@ -1490,21 +1595,26 @@ class OCCRenderer(Renderer):
vertex = TopoDS.Vertex_s(shape)
p = BRep_Tool.Pnt_s(vertex)
position = (p.X(), p.Y(), p.Z())
return [{
return [
{
"type": "vertex",
"position": position,
"normal": None,
"x_dir": None,
"vertex": vertex,
"owner_obj_id": owner_obj_id,
}]
}
]
except Exception:
pass
return []
def probe_snap_candidates(
self, x: int, y: int, radius: int = 30,
self,
x: int,
y: int,
radius: int = 30,
) -> List[Dict[str, Any]]:
"""Probe a pixel grid around (x, y) and return visible snap candidates.
@@ -1534,13 +1644,28 @@ class OCCRenderer(Renderer):
ring_offsets = [
(0, 0),
# Full radius ring (cardinal + diagonal)
(-radius, 0), (radius, 0), (0, -radius), (0, radius),
(-radius, -radius), (radius, radius), (-radius, radius), (radius, -radius),
(-radius, 0),
(radius, 0),
(0, -radius),
(0, radius),
(-radius, -radius),
(radius, radius),
(-radius, radius),
(radius, -radius),
# Half-radius ring
(-h, 0), (h, 0), (0, -h), (0, h),
(-h, -h), (h, h), (-h, h), (h, -h),
(-h, 0),
(h, 0),
(0, -h),
(0, h),
(-h, -h),
(h, h),
(-h, h),
(h, -h),
# Quarter-radius ring for small features
(-q, 0), (q, 0), (0, -q), (0, q),
(-q, 0),
(q, 0),
(0, -q),
(0, q),
]
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
@@ -1576,7 +1701,11 @@ class OCCRenderer(Renderer):
# Sort by screen-space distance to the cursor, nearest first.
results = list(candidates.values())
results.sort(key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2)
results.sort(
key=lambda c: (
(c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2
)
)
return results
def highlight_snap(self, position, color=None, size=6.0) -> Optional[str]:
@@ -1592,6 +1721,7 @@ class OCCRenderer(Renderer):
from OCP.gp import gp_Pnt
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
try:
scaled_size = size * self._get_gizmo_scale(position)
sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), scaled_size).Shape()
@@ -1742,7 +1872,9 @@ class OCCRenderer(Renderer):
continue
# Skip the primary itself — it gets its own bright marker.
if (round(cpos[0], 1), round(cpos[1], 1), round(cpos[2], 1)) == (
round(px, 1), round(py, 1), round(pz, 1)
round(px, 1),
round(py, 1),
round(pz, 1),
):
continue
cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7))
@@ -1772,9 +1904,7 @@ class OCCRenderer(Renderer):
ey = origin[1] + uy * length
ez = origin[2] + uz * length
edge = BRepBuilderAPI_MakeEdge(
gp_Pnt(*origin), gp_Pnt(ex, ey, ez)
).Edge()
edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*origin), gp_Pnt(ex, ey, ez)).Edge()
ais = AIS_Shape(edge)
ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB))
ais.SetDisplayMode(0) # wireframe
@@ -1800,12 +1930,17 @@ class OCCRenderer(Renderer):
# visual balance. This reads as 'bolt axis through hole'.
_make_axis_line(position, normal, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in")
_make_axis_line(
position, (-normal[0], -normal[1], -normal[2]),
axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub",
position,
(-normal[0], -normal[1], -normal[2]),
axis_length * 0.4,
(0.6, 0.6, 0.6),
"axis_stub",
)
# Radial reference (same colour as the marker).
if x_dir is not None:
_make_axis_line(position, x_dir, radius or (axis_length * 0.5), gizmo_color, "radial")
_make_axis_line(
position, x_dir, radius or (axis_length * 0.5), gizmo_color, "radial"
)
elif entity_type == "edge" and normal is not None:
# Tangent direction at midpoint.
@@ -1858,6 +1993,7 @@ class OCCRenderer(Renderer):
return
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.AIS import AIS_Shape
for robj in self._objects.values():
if robj.ais_shape is not None:
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
@@ -1878,6 +2014,7 @@ class OCCRenderer(Renderer):
return
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.AIS import AIS_Shape
for robj in self._objects.values():
if robj.ais_shape is not None:
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
@@ -1915,7 +2052,10 @@ class OCCRenderer(Renderer):
return None
def probe_snap_candidates_geometric(
self, x: int, y: int, radius: int = 30,
self,
x: int,
y: int,
radius: int = 30,
) -> List[Dict[str, Any]]:
"""Probe snap candidates by iterating geometry directly (no selection system).
@@ -1937,7 +2077,6 @@ class OCCRenderer(Renderer):
from OCP.TopoDS import TopoDS
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
import numpy as np
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
# Expand the search radius for the bbox pre-filter so features near
@@ -1965,10 +2104,14 @@ class OCCRenderer(Renderer):
bx0, by0, bz0, bx1, by1, bz1 = bbox.Get()
# Project the 8 AABB corners to screen.
corners = [
(bx0, by0, bz0), (bx1, by0, bz0),
(bx0, by1, bz0), (bx1, by1, bz0),
(bx0, by0, bz1), (bx1, by0, bz1),
(bx0, by1, bz1), (bx1, by1, bz1),
(bx0, by0, bz0),
(bx1, by0, bz0),
(bx0, by1, bz0),
(bx1, by1, bz0),
(bx0, by0, bz1),
(bx1, by0, bz1),
(bx0, by1, bz1),
(bx1, by1, bz1),
]
sx_min, sy_min = 99999, 99999
sx_max, sy_max = -99999, -99999
@@ -1984,8 +2127,12 @@ class OCCRenderer(Renderer):
if all_behind:
continue
# Check if cursor is within margin of the screen bbox.
if (x < sx_min - margin or x > sx_max + margin or
y < sy_min - margin or y > sy_max + margin):
if (
x < sx_min - margin
or x > sx_max + margin
or y < sy_min - margin
or y > sy_max + margin
):
continue
except Exception:
pass # If bbox fails, fall through and try features.
@@ -2060,8 +2207,9 @@ class OCCRenderer(Renderer):
# Sort by screen-space distance to cursor, nearest first.
results = list(candidates.values())
results.sort(
key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2
+ (c.get("screen", (x, y))[1] - y) ** 2
key=lambda c: (
(c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2
)
)
return results
@@ -2081,7 +2229,6 @@ class OCCRenderer(Renderer):
* ``suggestion`` human-readable snap suggestion
* ``feature_data`` dict with feature-specific info (radius, axis, etc.)
"""
import numpy as np
from collections import defaultdict
# Group candidates by owner_obj_id.
@@ -2126,10 +2273,9 @@ class OCCRenderer(Renderer):
if etype == "edge":
# Look for other edges nearby that might form a loop.
nearby_edges = [
n for n in candidates
if n.get("type") == "edge"
and n.get("owner_obj_id") == owner
and n is not c
n
for n in candidates
if n.get("type") == "edge" and n.get("owner_obj_id") == owner and n is not c
]
# For now, mark as edge — loop detection is complex.
ec["feature_type"] = "edge"
@@ -2145,9 +2291,9 @@ class OCCRenderer(Renderer):
if etype == "vertex":
# Look for edges that share this vertex (nearby edges).
nearby_edges = [
n for n in candidates
if n.get("type") == "edge"
and n.get("owner_obj_id") == owner
n
for n in candidates
if n.get("type") == "edge" and n.get("owner_obj_id") == owner
]
if len(nearby_edges) >= 2:
ec["feature_type"] = "meeting_edges"
@@ -2180,6 +2326,7 @@ class OCCRenderer(Renderer):
def _qt_buttons(self, event) -> Any:
"""Return the PySide6 Qt enum module lazily."""
from PySide6.QtCore import Qt
return Qt
def handle_mouse_press(self, event) -> None:
+265 -107
View File
@@ -4,18 +4,14 @@ from __future__ import annotations
import logging
import math
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Callable, Dict, Optional, Tuple
from PySide6.QtCore import Qt, QPoint, QPointF
from PySide6.QtGui import QColor, QFont, QKeySequence
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
@@ -29,6 +25,15 @@ from PySide6.QtWidgets import (
logger = logging.getLogger(__name__)
def _vec3(value: Any) -> Tuple[float, float, float]:
"""Coerce a 3-vector to a typed float triple (defensive fallback)."""
try:
return (float(value[0]), float(value[1]), float(value[2]))
except (TypeError, ValueError, IndexError):
return (0.0, 0.0, 0.0)
class ExtrudeDialog(QDialog):
"""Dialog for extrude options.
@@ -38,7 +43,7 @@ class ExtrudeDialog(QDialog):
*None*) to the callback tells the host to clear the preview.
"""
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle("Extrude Options")
self.setMinimumWidth(320)
@@ -76,12 +81,19 @@ class ExtrudeDialog(QDialog):
)
layout.addWidget(self.through_all_checkbox)
self.cut_all_bodies_checkbox = QCheckBox("Cut All Bodies")
self.cut_all_bodies_checkbox.setToolTip(
"Apply the boolean cut to every body in the current component, "
"not just the one the sketch was drawn on. Requires Perform Cut."
)
layout.addWidget(self.cut_all_bodies_checkbox)
self.rounded_checkbox = QCheckBox("Round Edges")
layout.addWidget(self.rounded_checkbox)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
@@ -93,9 +105,9 @@ class ExtrudeDialog(QDialog):
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# Live preview: recompute on every option change. Use a light-
# weight guard so we don't emit before the host has wired up the
# callback.
# Live preview: recompute on every option change. Wire each widget
# to its own signal by type — spinboxes emit ``valueChanged``,
# checkboxes emit ``stateChanged``.
for w in (
self.length_input,
self.symmetric_checkbox,
@@ -103,29 +115,21 @@ class ExtrudeDialog(QDialog):
self.cut_checkbox,
self.union_checkbox,
self.through_all_checkbox,
self.cut_all_bodies_checkbox,
self.rounded_checkbox,
):
# The spinbox has valueChanged; the checkboxes have stateChanged.
# Each must be wired in its own try/except so that a missing
# signal on one widget type doesn't skip the OTHER signal's
# connection (the prior single-try version accidentally
# left checkboxes un-connected when valueChanged raised first).
try:
if isinstance(w, QDoubleSpinBox):
w.valueChanged.connect(self._emit_preview)
except AttributeError:
pass
try:
else:
w.stateChanged.connect(self._emit_preview)
except AttributeError:
pass
def set_preview_callback(self, callback) -> None:
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
"""Install the live-preview callback (or *None* to disable)."""
self._preview_callback = callback
# Emit once so the initial state shows a preview right away.
self._emit_preview()
def _emit_preview(self, *args) -> None:
def _emit_preview(self, *args: Any) -> None:
if self._preview_callback is None:
return
try:
@@ -133,7 +137,7 @@ class ExtrudeDialog(QDialog):
except Exception as exc: # preview must never break the dialog
logger.debug("extrude preview callback raised: %s", exc)
def hideEvent(self, event):
def hideEvent(self, event: Any) -> None:
# Tell the host to clear the preview when the dialog goes away
# (accept, reject, or close). The host is responsible for the
# actual viewer cleanup.
@@ -144,7 +148,7 @@ class ExtrudeDialog(QDialog):
pass
super().hideEvent(event)
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool, bool]:
return (
self.length_input.value(),
self.symmetric_checkbox.isChecked(),
@@ -152,6 +156,7 @@ class ExtrudeDialog(QDialog):
self.cut_checkbox.isChecked(),
self.union_checkbox.isChecked(),
self.through_all_checkbox.isChecked(),
self.cut_all_bodies_checkbox.isChecked(),
self.rounded_checkbox.isChecked(),
)
@@ -159,7 +164,7 @@ class ExtrudeDialog(QDialog):
class RevolveDialog(QDialog):
"""Dialog for revolve options."""
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle("Revolve Options")
self.setMinimumWidth(300)
@@ -177,8 +182,8 @@ class RevolveDialog(QDialog):
layout.addLayout(angle_layout)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
@@ -199,7 +204,7 @@ class OffsetDialog(QDialog):
time. On accept the caller retrieves ``get_values()`` distance.
"""
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle("Offset Sketch")
self.setMinimumWidth(300)
@@ -223,8 +228,8 @@ class OffsetDialog(QDialog):
layout.addWidget(self.inward_checkbox)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
@@ -240,12 +245,12 @@ class OffsetDialog(QDialog):
self.distance_input.valueChanged.connect(self._emit_preview)
self.inward_checkbox.stateChanged.connect(self._emit_preview)
def set_preview_callback(self, callback) -> None:
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
"""Install the live-preview callback (or *None* to disable)."""
self._preview_callback = callback
self._emit_preview()
def _emit_preview(self, *args) -> None:
def _emit_preview(self, *args: Any) -> None:
if self._preview_callback is None:
return
try:
@@ -253,7 +258,7 @@ class OffsetDialog(QDialog):
except Exception as exc:
logger.debug("offset preview callback raised: %s", exc)
def hideEvent(self, event):
def hideEvent(self, event: Any) -> None:
if self._preview_callback is not None:
try:
self._preview_callback(None)
@@ -273,7 +278,7 @@ class WorkplaneOrientationDialog(QDialog):
returns (normal, x_dir) pair (both as 3-tuples).
"""
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle("New Workplane Orientation")
self.setMinimumWidth(320)
@@ -293,6 +298,12 @@ class WorkplaneOrientationDialog(QDialog):
layout.addWidget(lbl)
self._preset_group = QButtonGroup(self)
# normal/x_dir per preset, keyed by the QButtonGroup id — QRadioButton
# has no data slot of its own, so stash the vectors here instead of
# duck-typing extra attributes onto the widget.
self._preset_vectors: Dict[
int, Tuple[Tuple[float, float, float], Tuple[float, float, float]]
] = {}
preset_layout = QGridLayout()
presets = [
("XY (Top)", (0, 0, 1), (1, 0, 0)),
@@ -306,15 +317,14 @@ class WorkplaneOrientationDialog(QDialog):
btn = QRadioButton(label)
btn.setChecked(idx == 0)
self._preset_group.addButton(btn, idx)
btn.normal = normal
btn.x_dir = x_dir
self._preset_vectors[idx] = (_vec3(normal), _vec3(x_dir))
preset_layout.addWidget(btn, idx // 2, idx % 2)
layout.addLayout(preset_layout)
# ── Custom angle (offset from XY) ──
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
self._custom_radio = QRadioButton("Custom (angle from XY):")
@@ -347,8 +357,8 @@ class WorkplaneOrientationDialog(QDialog):
# ── Buttons ──
line2 = QFrame()
line2.setFrameShape(QFrame.HLine)
line2.setFrameShadow(QFrame.Sunken)
line2.setFrameShape(QFrame.Shape.HLine)
line2.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line2)
button_layout = QHBoxLayout()
@@ -367,7 +377,7 @@ class WorkplaneOrientationDialog(QDialog):
self._angle_x.valueChanged.connect(self._emit_preview)
self._angle_y.valueChanged.connect(self._emit_preview)
def set_preview_callback(self, callback) -> None:
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
"""Install a callback for live 3D preview of the workplane orientation.
*callback* is called with ``(normal, x_dir)`` whenever the user
@@ -377,7 +387,7 @@ class WorkplaneOrientationDialog(QDialog):
# Emit once so the initial state shows a preview right away.
self._emit_preview()
def _emit_preview(self, *args) -> None:
def _emit_preview(self, *args: Any) -> None:
"""Call the preview callback with the current orientation, if installed."""
if self._preview_callback is None:
return
@@ -387,7 +397,7 @@ class WorkplaneOrientationDialog(QDialog):
except Exception as exc:
logger.debug("workplane preview callback raised: %s", exc)
def hideEvent(self, event):
def hideEvent(self, event: Any) -> None:
"""Clear the live preview when the dialog closes."""
if self._preview_callback is not None:
try:
@@ -396,85 +406,52 @@ class WorkplaneOrientationDialog(QDialog):
pass
super().hideEvent(event)
def _on_preset_changed(self, btn):
def _on_preset_changed(self, btn: Any) -> None:
"""When a preset is selected, deselect the custom radio and emit preview."""
self._custom_radio.setChecked(False)
self._emit_preview()
def _on_ok(self):
"""Compute the final orientation and accept."""
import numpy as np
import math
def _compute_custom_orientation(
self,
) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]:
"""Compute ``(normal, x_dir)`` from the custom angle spinboxes.
if self._custom_radio.isChecked():
# Custom: start from XY normal and rotate by the two angles.
Starts from the +Z normal and rotates by the two angle values.
Returns *None* if the math fails (defensive the dialog then falls
back to the default orientation instead of crashing).
"""
import numpy as np
try:
ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value())
# Start from +Z normal, rotate around X then Y
n = np.array([0.0, 0.0, 1.0])
# Rotate around X
rx = np.array([
rx = np.array(
[
[1, 0, 0],
[0, math.cos(ax), -math.sin(ax)],
[0, math.sin(ax), math.cos(ax)],
])
]
)
n = rx @ n
# Rotate around Y
ry = np.array([
ry = np.array(
[
[math.cos(ay), 0, math.sin(ay)],
[0, 1, 0],
[-math.sin(ay), 0, math.cos(ay)],
])
]
)
n = ry @ n
n = n / np.linalg.norm(n)
n_norm = np.linalg.norm(n)
if n_norm < 1e-12:
return None
n = n / n_norm
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
world_y = np.array([0.0, 1.0, 0.0])
if abs(np.dot(n, world_y)) > 0.99:
world_y = np.array([0.0, 0.0, 1.0])
x = np.cross(world_y, n)
x_norm = np.linalg.norm(x)
if x_norm > 1e-9:
x = x / x_norm
else:
x = np.array([1.0, 0.0, 0.0])
self._normal = tuple(float(v) for v in n)
self._x_dir = tuple(float(v) for v in x)
else:
btn = self._preset_group.checkedButton()
if btn is not None:
self._normal = btn.normal
self._x_dir = btn.x_dir
self.accept()
def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
"""Return (normal, x_dir, name) for the chosen workplane.
Computes the current selection from the UI state so it works
whether called before or after ``_on_ok``.
"""
import numpy as np
import math
if self._custom_radio.isChecked():
ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value())
n = np.array([0.0, 0.0, 1.0])
rx = np.array([
[1, 0, 0],
[0, math.cos(ax), -math.sin(ax)],
[0, math.sin(ax), math.cos(ax)],
])
n = rx @ n
ry = np.array([
[math.cos(ay), 0, math.sin(ay)],
[0, 1, 0],
[-math.sin(ay), 0, math.cos(ay)],
])
n = ry @ n
n = n / np.linalg.norm(n)
world_y = np.array([0.0, 1.0, 0.0])
if abs(np.dot(n, world_y)) > 0.99:
if abs(float(np.dot(n, world_y))) > 0.99:
world_y = np.array([0.0, 0.0, 1.0])
x = np.cross(world_y, n)
x_norm = np.linalg.norm(x)
@@ -483,15 +460,196 @@ class WorkplaneOrientationDialog(QDialog):
else:
x = np.array([1.0, 0.0, 0.0])
return (
tuple(float(v) for v in n),
tuple(float(v) for v in x),
(float(n[0]), float(n[1]), float(n[2])),
(float(x[0]), float(x[1]), float(x[2])),
)
except Exception as exc:
logger.debug("custom workplane orientation math failed: %s", exc)
return None
def _on_ok(self) -> None:
"""Compute the final orientation and accept."""
if self._custom_radio.isChecked():
orientation = self._compute_custom_orientation()
if orientation is not None:
self._normal, self._x_dir = orientation
else:
btn = self._preset_group.checkedButton()
if btn is not None:
self._normal, self._x_dir = self._preset_vectors[self._preset_group.id(btn)]
self.accept()
def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
"""Return (normal, x_dir, name) for the chosen workplane.
Computes the current selection from the UI state so it works
whether called before or after ``_on_ok``.
"""
if self._custom_radio.isChecked():
orientation = self._compute_custom_orientation()
if orientation is None:
orientation = ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0))
normal, x_dir = orientation
return (
normal,
x_dir,
self._name_input.text().strip() or "Workplane",
)
else:
btn = self._preset_group.checkedButton()
if btn is not None:
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
normal, x_dir = self._preset_vectors[self._preset_group.id(btn)]
return (normal, x_dir, self._name_input.text().strip() or "Workplane")
# Fallback: XY default.
return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
return (
(0.0, 0.0, 1.0),
(1.0, 0.0, 0.0),
self._name_input.text().strip() or "Workplane",
)
class FilletDialog(QDialog):
"""Dialog for fillet options — the common settings from CAD fillet tools.
Shown AFTER the user has picked the two faces whose shared edges will
be rounded. Offers:
- size, entered as **diameter** or **radius** (the user asked for a
diameter box; the unit toggle covers the radius crowd),
- **tangent propagation** (extend the round along tangent-connected
edges, like FreeCAD/SolidWorks "tangent chain"),
- edge **scope** (only the edges between the two picked faces vs
every edge of the body),
- a live 3D preview (``set_preview_callback``), so dragging the size
spinner shows the fillet in real time before committing.
``get_values()`` returns ``(size, size_is_diameter, tangent_propagation,
scope)`` where *scope* is ``"selected"`` or ``"all"``. The host
converts *size* to a radius (``size / 2`` for diameter).
"""
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle("Fillet Options")
self.setMinimumWidth(360)
self._preview_callback: Optional[Callable[[Any], None]] = None
layout = QVBoxLayout(self)
# ── Size: value + Diameter/Radius unit ──
size_row = QHBoxLayout()
self.size_unit_combo = QComboBox()
self.size_unit_combo.addItems(["Diameter", "Radius"])
self.size_unit_combo.setToolTip(
"Enter the fillet size as a diameter or a radius (radius = diameter / 2)."
)
size_row.addWidget(self.size_unit_combo)
self.size_value_label = QLabel("Diameter (mm):")
size_row.addWidget(self.size_value_label)
self.size_input = QDoubleSpinBox()
self.size_input.setDecimals(2)
self.size_input.setRange(0.01, 100000.0)
self.size_input.setValue(2.0)
self.size_input.setSingleStep(0.5)
self.size_input.setSuffix(" mm")
self.size_input.setToolTip("Fillet size along the rounded edge.")
size_row.addWidget(self.size_input)
layout.addLayout(size_row)
# ── Edge scope ──
self.scope_group = QButtonGroup(self)
scope_layout = QGridLayout()
self.scope_selected_radio = QRadioButton("Edges between faces")
self.scope_selected_radio.setChecked(True)
self.scope_selected_radio.setToolTip("Round only the edges shared by the two picked faces.")
self.scope_all_radio = QRadioButton("All edges of body")
self.scope_all_radio.setToolTip(
"Round every edge of the body (the picked faces only choose which body is modified)."
)
self.scope_group.addButton(self.scope_selected_radio)
self.scope_group.addButton(self.scope_all_radio)
scope_layout.addWidget(self.scope_selected_radio, 0, 0)
scope_layout.addWidget(self.scope_all_radio, 1, 0)
layout.addLayout(scope_layout)
# ── Tangent propagation ──
self.tangent_checkbox = QCheckBox("Tangent propagation")
self.tangent_checkbox.setChecked(True)
self.tangent_checkbox.setToolTip(
"Extend the fillet along edges that are tangent to the picked "
"ones (e.g. a smooth chain of lines and arcs). Off = only the "
"exact edges between the two faces."
)
layout.addWidget(self.tangent_checkbox)
# ── Edge count feedback ──
self.edge_label = QLabel("")
self.edge_label.setStyleSheet("color: #8a8a8a;")
layout.addWidget(self.edge_label)
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("Apply Fillet")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# ── Live preview on every change ──
self.size_unit_combo.currentIndexChanged.connect(self._on_unit_changed)
self.size_input.valueChanged.connect(self._emit_preview)
self.scope_selected_radio.toggled.connect(self._emit_preview)
self.tangent_checkbox.stateChanged.connect(self._emit_preview)
def _on_unit_changed(self) -> None:
"""Swap the size label between Diameter and Radius."""
self.size_value_label.setText(
"Radius (mm):" if self.size_unit_combo.currentText() == "Radius" else "Diameter (mm):"
)
self._emit_preview()
def set_edge_count(self, count: int) -> None:
"""Show how many edges the current scope will round."""
self.edge_label.setText(
f"Fillets {count} edge{'s' if count != 1 else ''} between the picked faces."
)
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
"""Install a live-preview callback; fires immediately with defaults."""
self._preview_callback = callback
self._emit_preview()
def _emit_preview(self, *args: Any) -> None:
if self._preview_callback is None:
return
try:
self._preview_callback(self.get_values())
except Exception as exc: # preview must never break the dialog
logger.debug("fillet preview callback raised: %s", exc)
def hideEvent(self, event: Any) -> None:
if self._preview_callback is not None:
try:
self._preview_callback(None)
except Exception:
pass
super().hideEvent(event)
def get_values(self) -> Tuple[float, bool, bool, str]:
"""Return ``(size, size_is_diameter, tangent_propagation, scope)``."""
return (
self.size_input.value(),
self.size_unit_combo.currentText() == "Diameter",
self.tangent_checkbox.isChecked(),
"all" if self.scope_all_radio.isChecked() else "selected",
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+118 -23
View File
@@ -20,6 +20,13 @@ class Viewer3DWidget(QWidget):
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
pickFaceCancelled = Signal()
# Emitted when the user picks a face for the fillet tool (ANY face,
# planar or curved). Payload: the raw TopoDS_Face. The owning body is
# read from ``_last_pick_owner_obj_id`` (same stash as facePicked).
filletFacePicked = Signal(object)
# Emitted when fillet pick mode is cancelled (Esc).
filletPickCancelled = Signal()
# Emitted when the user picks an entity for a connector point (assembly).
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
@@ -44,11 +51,11 @@ class Viewer3DWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
self.setAttribute(Qt.WA_PaintOnScreen)
self.setAttribute(Qt.WA_OpaquePaintEvent)
self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen)
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
self.setAutoFillBackground(False)
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
self.setFocusPolicy(Qt.StrongFocus)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
# button held — required for the connector-pick hover gizmo (and any
# status-bar hover feedback) to show under the cursor as the user
@@ -64,6 +71,8 @@ class Viewer3DWidget(QWidget):
# When True, a left-click picks a planar face (for sketch-on-surface)
# instead of orbiting the camera. Set via set_pick_face_mode().
self._pick_face_mode: bool = False
# When True, a left-click picks ANY face for the fillet tool.
self._fillet_pick_mode: bool = False
# When True, a left-click picks an entity for a connector point
# (assembly component connection).
self._connector_pick_mode: bool = False
@@ -312,15 +321,19 @@ class Viewer3DWidget(QWidget):
def mousePressEvent(self, event):
self._ensure_initialized()
# Face-pick mode: a left-click selects a planar face to sketch on.
if self._pick_face_mode and event.button() == Qt.LeftButton:
if self._pick_face_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_face_pick(event)
return
# Fillet pick mode: a left-click selects any face (planar or curved).
if self._fillet_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_fillet_face_pick(event)
return
# Connector pick mode: a left-click selects a face for a connection point.
if self._connector_pick_mode and event.button() == Qt.LeftButton:
if self._connector_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_connector_pick(event)
return
# Assembly move mode: start dragging the clicked body.
if self._assembly_move_mode and event.button() == Qt.LeftButton:
if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_assembly_move_press(event)
return
self._renderer.handle_mouse_press(event)
@@ -346,6 +359,12 @@ class Viewer3DWidget(QWidget):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
# In fillet pick mode, keep dynamic highlighting too.
if self._fillet_pick_mode:
if hasattr(self._renderer, "handle_mouse_move"):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
# Active drag in assembly move mode.
if self._move_drag_active:
self._handle_assembly_move_move(event)
@@ -354,7 +373,7 @@ class Viewer3DWidget(QWidget):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
def paintEngine(self):
def paintEngine(self) -> Any:
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
return None
@@ -447,13 +466,75 @@ class Viewer3DWidget(QWidget):
"""
self._pick_face_mode = bool(enabled)
if enabled:
self.setCursor(Qt.CrossCursor)
self.setCursor(Qt.CursorShape.CrossCursor)
else:
self.unsetCursor()
def is_pick_face_mode(self) -> bool:
return self._pick_face_mode
# ─── Fillet pick mode (any-face picking) ────────────────────────────────
def set_fillet_pick_mode(self, enabled: bool) -> None:
"""Toggle fillet face-pick mode (any face — planar or curved).
The cursor selects faces for the fillet tool instead of orbiting the
camera. Mutually exclusive with the other pick modes: entering this
mode switches the others off.
"""
self._fillet_pick_mode = bool(enabled)
if enabled:
# Pick modes are mutually exclusive — entering fillet mode
# disables sketch-on-surface / connector / assembly modes.
self._pick_face_mode = False
self._connector_pick_mode = False
self._assembly_move_mode = False
self._move_drag_active = False
self.setCursor(Qt.CursorShape.CrossCursor)
elif not self._pick_face_mode and not self._connector_pick_mode:
self.unsetCursor()
def is_fillet_pick_mode(self) -> bool:
return self._fillet_pick_mode
def highlight_faces(self, faces: List[Any]) -> None:
"""Tint all faces in *faces* so both fillet picks stay visible."""
self._ensure_initialized()
fn = getattr(self._renderer, "highlight_faces", None)
if fn is not None:
fn(faces)
self._renderer.render()
return
# Fallback: single-face highlight for the last picked face.
if faces:
self.highlight_face(faces[-1])
def clear_faces_highlight(self) -> None:
"""Remove the multi-face fillet-pick overlays, if any."""
if not self._initialized or self._renderer is None:
return
fn = getattr(self._renderer, "clear_faces_highlight", None)
if fn is not None:
fn()
self._renderer.render()
def _handle_fillet_face_pick(self, event: Any) -> None:
"""Detect any face under the click and emit filletFacePicked."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_face", None)
if picker is None:
logger.warning("Renderer has no pick_face support")
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Fillet face pick: no face under cursor")
return
# Stash the owner so MainWindow can pair the face with its body
# (same convention as sketch-on-face picking).
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.filletFacePicked.emit(info["face"])
def highlight_face(self, face: Any) -> None:
"""Tint the picked face light-blue/transparent in the 3D viewer."""
self._ensure_initialized()
@@ -489,7 +570,7 @@ class Viewer3DWidget(QWidget):
"""
self._connector_pick_mode = bool(enabled)
if enabled:
self.setCursor(Qt.CrossCursor)
self.setCursor(Qt.CursorShape.CrossCursor)
# Disable standard OCC selection so gizmo visuals are not
# interfered with by dynamic face highlighting.
fn = getattr(self._renderer, "deactivate_selection_modes", None)
@@ -714,7 +795,7 @@ class Viewer3DWidget(QWidget):
"""
self._assembly_move_mode = bool(enabled)
if enabled:
self.setCursor(Qt.SizeAllCursor)
self.setCursor(Qt.CursorShape.SizeAllCursor)
elif not self._pick_face_mode and not self._connector_pick_mode:
self.unsetCursor()
if not enabled:
@@ -788,20 +869,29 @@ class Viewer3DWidget(QWidget):
# Compute world-space delta.
modifiers = event.modifiers()
if modifiers & Qt.ShiftModifier:
if modifiers & Qt.KeyboardModifier.ShiftModifier:
# Shift+drag: move along camera direction (Z-depth).
dz_world = dx * world_per_pixel
dx_world = 0.0
dy_world = 0.0
else:
# Normal drag: move in view plane.
dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel)
dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel)
dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel)
try:
dx_world = float(
cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel
)
dy_world = float(
cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel
)
dz_world = float(
cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel
)
except (TypeError, ValueError):
dx_world = dy_world = dz_world = 0.0
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
def _handle_assembly_move_release(self, event) -> None:
def _handle_assembly_move_release(self, event: Any) -> None:
"""Finish the drag, emit final position."""
self.assemblyMoveFinished.emit(self._move_owner_obj_id)
self._move_drag_active = False
@@ -811,7 +901,7 @@ class Viewer3DWidget(QWidget):
self._move_plane_normal = None
self._move_initial_position = None
def _handle_face_pick(self, event) -> None:
def _handle_face_pick(self, event: Any) -> None:
"""Detect a planar face under the click and emit facePicked."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_planar_face", None)
@@ -834,7 +924,7 @@ class Viewer3DWidget(QWidget):
info["face"],
)
def set_view(self, view: str):
def set_view(self, view: str) -> None:
# Prefer the renderer's native orientation snap (preserves target,
# refits the scene). Falls back to absolute eye positions for
# renderers that don't implement set_view_orientation.
@@ -856,26 +946,31 @@ class Viewer3DWidget(QWidget):
pos, target = positions[view]
self.set_camera_position(pos, target)
def mouseDoubleClickEvent(self, event):
def mouseDoubleClickEvent(self, event: Any) -> None:
# Double-click → fit all (common CAD convention).
self._ensure_initialized()
if event.button() == Qt.LeftButton:
if event.button() == Qt.MouseButton.LeftButton:
self.fit_camera()
super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event):
def keyPressEvent(self, event: Any) -> None:
# Esc cancels face-pick mode.
if self._pick_face_mode and event.key() == Qt.Key_Escape:
if self._pick_face_mode and event.key() == Qt.Key.Key_Escape:
self.set_pick_face_mode(False)
self.pickFaceCancelled.emit()
return
# Esc cancels fillet pick mode.
if self._fillet_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_fillet_pick_mode(False)
self.filletPickCancelled.emit()
return
# Esc cancels connector pick mode.
if self._connector_pick_mode and event.key() == Qt.Key_Escape:
if self._connector_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_connector_pick_mode(False)
self.connectorPickCancelled.emit()
return
# Esc cancels assembly move mode.
if self._assembly_move_mode and event.key() == Qt.Key_Escape:
if self._assembly_move_mode and event.key() == Qt.Key.Key_Escape:
self.set_assembly_move_mode(False)
return
# Navigation shortcuts (lowercase = view presets, F = fit,
+124 -109
View File
@@ -1,7 +1,6 @@
"""Tests for Fluency CAD geometry kernel."""
import pytest
import numpy as np
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
@@ -195,10 +194,14 @@ class TestOCCSketch:
sk = OCCSketch()
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
# 20x20 square in UV
p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10)
p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10)
sk.add_line(p0, p1); sk.add_line(p1, p2)
sk.add_line(p2, p3); sk.add_line(p3, p0)
p0 = sk.add_point(-10, -10)
p1 = sk.add_point(10, -10)
p2 = sk.add_point(10, 10)
p3 = sk.add_point(-10, 10)
sk.add_line(p0, p1)
sk.add_line(p1, p2)
sk.add_line(p2, p3)
sk.add_line(p3, p0)
geom = sk.get_geometry()
# The face must carry the plane normal for the kernel.
@@ -220,10 +223,14 @@ class TestOCCSketch:
sk = OCCSketch()
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
a = sk.add_point(-10, -10); b = sk.add_point(10, -10)
c = sk.add_point(10, 10); d = sk.add_point(-10, 10)
sk.add_line(a, b); sk.add_line(b, c)
sk.add_line(c, d); sk.add_line(d, a)
a = sk.add_point(-10, -10)
b = sk.add_point(10, -10)
c = sk.add_point(10, 10)
d = sk.add_point(-10, 10)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
ctr = sk.add_point(0, 0)
sk.add_circle(ctr, 3.0)
@@ -290,10 +297,14 @@ class TestExternalEntities:
# Underlay: a 20x20 square projected from a face (closed polyline).
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
# User profile: a 5x5 square — this is what should be extruded.
a = sk.add_point(2, 2); b = sk.add_point(8, 2)
c = sk.add_point(8, 8); d = sk.add_point(2, 8)
sk.add_line(a, b); sk.add_line(b, c)
sk.add_line(c, d); sk.add_line(d, a)
a = sk.add_point(2, 2)
b = sk.add_point(8, 2)
c = sk.add_point(8, 8)
d = sk.add_point(2, 8)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
faces = sk.detect_faces()
# Only the user-drawn face (5x5 square) should be detected.
assert len(faces) == 1
@@ -310,10 +321,14 @@ class TestExternalEntities:
def test_external_entities_excluded_from_get_polygon_points(self):
sk = OCCSketch()
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
a = sk.add_point(1, 1); b = sk.add_point(2, 1)
c = sk.add_point(2, 2); d = sk.add_point(1, 2)
sk.add_line(a, b); sk.add_line(b, c)
sk.add_line(c, d); sk.add_line(d, a)
a = sk.add_point(1, 1)
b = sk.add_point(2, 1)
c = sk.add_point(2, 2)
d = sk.add_point(1, 2)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
poly = sk.get_polygon_points()
# The user square (1..2 range) should appear, not the 0..100 underlay.
assert all(1.0 <= p.x <= 2.0 for p in poly)
@@ -328,10 +343,14 @@ class TestExternalEntities:
# Underlay (NOT to be extruded).
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
# User profile: a 2x2 square inside the underlay.
a = sk.add_point(1, 1); b = sk.add_point(3, 1)
c = sk.add_point(3, 3); d = sk.add_point(1, 3)
sk.add_line(a, b); sk.add_line(b, c)
sk.add_line(c, d); sk.add_line(d, a)
a = sk.add_point(1, 1)
b = sk.add_point(3, 1)
c = sk.add_point(3, 3)
d = sk.add_point(1, 3)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
geom = sk.get_geometry()
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
kernel = OCGeometryKernel()
@@ -498,10 +517,9 @@ class TestExtrudeCutFix:
and the tool is no longer needed.
"""
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.kernel import OCGeometryKernel
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
import math
k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -511,17 +529,18 @@ class TestExtrudeCutFix:
# expected volume easy to compute.
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.gp import gp_Pnt, gp_Vec
# 20x20 square at (0,0,0), extruded along +Z by 200.
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
mp = BRepBuilderAPI_MakePolygon()
for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]:
for x, y in [(0, 0), (20, 0), (20, 20), (0, 20)]:
mp.Add(gp_Pnt(x, y, 0))
mp.Close()
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
tool_shape = BRepPrimAPI_MakePrism(
face, gp_Vec(0, 0, 200)
).Shape()
tool_shape = BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, 200)).Shape()
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
# Before cut: target is 100^3 = 1_000_000.
@@ -536,9 +555,7 @@ class TestExtrudeCutFix:
# After cut: target is 1_000_000 - 20*20*100 = 960_000
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
g1 = GProp_GProps()
BRepGProp.VolumeProperties_s(
k._get_shape(target_obj_geometry), g1
)
BRepGProp.VolumeProperties_s(k._get_shape(target_obj_geometry), g1)
assert abs(g1.Mass() - 960_000.0) < 1.0
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
@@ -551,10 +568,9 @@ class TestExtrudeCutFix:
target, so a single body remains.
"""
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_SOLID
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.kernel import OCGeometryKernel
k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -562,6 +578,7 @@ class TestExtrudeCutFix:
# Tool: small box at the centre, fully inside the target.
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
tool_shape = BBox(20, 20, 20).Shape()
tool_obj = OCCGeometryObject(tool_shape, {})
@@ -595,91 +612,59 @@ class TestBodyVisibilityToggle:
def _make_window(self):
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
return MainWindow()
def test_body_list_uses_checkable_items(self):
"""Each body list item must be a checkable QListWidgetItem."""
"""Each body list item has a data role for the toggle handler."""
from PySide6.QtCore import Qt
win = self._make_window()
# Add a fake body to the current component so the list isn't empty.
from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCCGeometryObject
box = OCCGeometryObject(
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
)
box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists()
items = win._body_list.findItems("A", Qt.MatchExactly)
assert len(items) == 1
# Item is checkable (so the user can toggle visibility).
assert items[0].flags() & Qt.ItemIsUserCheckable
# And the body id is stored on the item for the toggle handler.
assert items[0].data(Qt.UserRole) == "a"
# Default state is checked (= visible).
assert items[0].checkState() == Qt.Checked
# Default state is visible.
assert win._current_component.bodies["a"].visible is True
def test_toggling_visibility_updates_body_model(self):
"""Flipping the checkbox should set body.visible accordingly."""
"""Toggling visibility via _on_body_visibility_changed updates the model."""
from PySide6.QtCore import Qt
win = self._make_window()
from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCCGeometryObject
box = OCCGeometryObject(
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
)
box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists()
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
# Toggle off.
item.setCheckState(Qt.Unchecked)
win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is False
# Toggle back on.
item.setCheckState(Qt.Checked)
win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is True
def test_visibility_no_op_when_unchanged(self):
"""Re-emitting the same state must not trigger a viewer call.
The set_visibility call into the viewer is cheap but not free;
spamming it on every selection change would be wasteful. The
handler short-circuits when the new state matches the model's.
"""
from PySide6.QtCore import Qt
win = self._make_window()
from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCCGeometryObject
box = OCCGeometryObject(
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
)
win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists()
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
# Force the model's visibility to False to mimic a desync.
win._current_component.bodies["a"].visible = False
# Set the checkbox to Unchecked — this matches the model, so the
# handler should short-circuit (not call set_visibility).
item.setCheckState(Qt.Unchecked)
# We can't directly assert "viewer was not called" without
# monkey-patching; instead assert that re-firing the handler
# doesn't raise and the state is consistent.
win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is False
def math_hypot(x, y):
import math
return math.hypot(x, y)
@@ -698,10 +683,13 @@ class TestConstraintTagRendering:
def _make_widget_with_sketch(self, sk):
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import Sketch2DWidget
w = Sketch2DWidget()
w.set_sketch(sk)
return w
@@ -824,6 +812,7 @@ class TestConstraintTagRendering:
class _BadRound:
def __round__(self, ndigits=0):
raise TypeError("cannot round")
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
tags = w._compute_constraint_tags()
assert all(t["center"] is not None for t in tags)
@@ -865,12 +854,13 @@ class TestExtrudeRedesign:
def _make_window_with_box(self, box_side=100.0):
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
@@ -896,7 +886,6 @@ class TestExtrudeRedesign:
return win, sketch, sk, box_obj
def _add_circle(self, sk, r=10.0):
from fluency.geometry_occ.sketch import OCCSketch
c = sk.add_point(0, 0)
sk.add_circle(c, r)
sk.solve()
@@ -905,6 +894,7 @@ class TestExtrudeRedesign:
def _geometry_volume(self, win, geom):
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
sh = win._kernel._get_shape(geom)
g = GProp_GProps()
BRepGProp.VolumeProperties_s(sh, g)
@@ -919,14 +909,20 @@ class TestExtrudeRedesign:
so a 5 mm cut makes a real 5 mm-deep pocket.
"""
import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
# removed nothing; post-redesign it must remove a 5 mm cylinder.
result = win._compute_extrude_result(
sketch, face_geom,
length=5.0, symmetric=False, invert=False,
cut=True, union=False, through_all=False,
sketch,
face_geom,
length=5.0,
symmetric=False,
invert=False,
cut=True,
union=False,
through_all=False,
)
assert result is not None
assert result["target_body"] is not None
@@ -938,13 +934,18 @@ class TestExtrudeRedesign:
def test_cut_through_all_passes_through(self):
""" "Through All" cut fully passes through the body."""
import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
sketch, face_geom,
sketch,
face_geom,
length=5.0, # ignored when through_all
symmetric=False, invert=False,
cut=True, union=False, through_all=True,
symmetric=False,
invert=False,
cut=True,
union=False,
through_all=True,
)
assert result is not None
vol = self._geometry_volume(win, result["result_geom"])
@@ -960,30 +961,23 @@ class TestExtrudeRedesign:
"""
import math
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
win = MainWindow()
# First body in the dict: a 50-millimetre box ALSO.
first = OCCGeometryObject(
BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
)
win._current_component.bodies["first"] = Body(
name="First", geometry=first
)
first = OCCGeometryObject(BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {})
win._current_component.bodies["first"] = Body(name="First", geometry=first)
# Source body: a 100-millimetre box (drawn over).
src = OCCGeometryObject(
BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}
)
win._current_component.bodies["src"] = Body(
name="Src", geometry=src
)
src = OCCGeometryObject(BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {})
win._current_component.bodies["src"] = Body(name="Src", geometry=src)
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
sk = OCCSketch()
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
@@ -999,9 +993,14 @@ class TestExtrudeRedesign:
face_geom = sk.get_geometry()
result = win._compute_extrude_result(
sketch, face_geom,
length=5.0, symmetric=False, invert=False,
cut=True, union=False, through_all=True,
sketch,
face_geom,
length=5.0,
symmetric=False,
invert=False,
cut=True,
union=False,
through_all=True,
)
assert result is not None
# Target is the source box, NOT the dict's first body.
@@ -1019,12 +1018,18 @@ class TestExtrudeRedesign:
rather than "subtracting" from the existing box.
"""
import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
sketch, face_geom,
length=10.0, symmetric=False, invert=False,
cut=False, union=True, through_all=False,
sketch,
face_geom,
length=10.0,
symmetric=False,
invert=False,
cut=False,
union=True,
through_all=False,
)
assert result is not None
vol = self._geometry_volume(win, result["result_geom"])
@@ -1037,9 +1042,14 @@ class TestExtrudeRedesign:
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
sketch, face_geom,
length=10.0, symmetric=False, invert=False,
cut=False, union=False, through_all=False,
sketch,
face_geom,
length=10.0,
symmetric=False,
invert=False,
cut=False,
union=False,
through_all=False,
)
assert result is not None
# No boolean target; result is the standalone tool extrusion.
@@ -1047,6 +1057,7 @@ class TestExtrudeRedesign:
vol = self._geometry_volume(win, result["result_geom"])
# Standalone cylinder 10 mm tall.
import math
assert abs(vol - math.pi * (10.0**2) * 10.0) < 1.0
def test_freshly_picked_sketch_is_auto_selected(self):
@@ -1055,7 +1066,6 @@ class TestExtrudeRedesign:
The user should be able to click Extrude/Cut immediately without
first hunting for the new sketch in the left list.
"""
from fluency.geometry_occ.kernel import OCCGeometryObject
win, _, sk, box_obj = self._make_window_with_box(100.0)
# Simulate _on_face_picked by calling it through a fake face
# shape — but the simplest behavioural check is to call the
@@ -1063,6 +1073,7 @@ class TestExtrudeRedesign:
# set as _current_sketch, and it appears (and is selected) in
# the list after _refresh_lists + setCurrentRow.
from fluency.models.data_model import Sketch
sketch = Sketch(name="Sketch on face 99")
sketch._source_body_id = "b1"
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
@@ -1084,8 +1095,10 @@ class TestExtrudeRedesign:
def test_preview_callback_invoked_on_value_change(self):
"""The live preview callback fires on spinbox/checkbox changes."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog
@@ -1110,8 +1123,10 @@ class TestExtrudeRedesign:
def test_preview_hidden_event_sends_none(self):
"""hideEvent should deliver None to the callback so the host clears."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog