1 Commits

Author SHA1 Message Date
bklronin a1361ecc58 - added contrain context menu
- improved line pickability.
2026-07-21 20:22:17 +02:00
14 changed files with 2564 additions and 6230 deletions
+62 -57
View File
@@ -4,15 +4,13 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added &quot;measurement lines&quot;"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added contrain context menu&#10;- improved line pickability.">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/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/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/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/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -29,7 +27,7 @@
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY"> <option name="RECENT_BRANCH_BY_REPOSITORY">
<map> <map>
<entry key="$PROJECT_DIR$" value="feature/surface-modifier-pyramid-patterns" /> <entry key="$PROJECT_DIR$" value="single_window" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -49,47 +47,46 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent">{ <component name="PropertiesComponent"><![CDATA[{
&quot;keyToString&quot;: { "keyToString": {
&quot;Python.2dtest.executor&quot;: &quot;Run&quot;, "Python.2dtest.executor": "Run",
&quot;Python.3d_windows.executor&quot;: &quot;Run&quot;, "Python.3d_windows.executor": "Run",
&quot;Python.Unnamed.executor&quot;: &quot;Run&quot;, "Python.Unnamed.executor": "Run",
&quot;Python.base.executor&quot;: &quot;Run&quot;, "Python.base.executor": "Run",
&quot;Python.data_model.executor&quot;: &quot;Run&quot;, "Python.data_model.executor": "Run",
&quot;Python.debug_dragging.executor&quot;: &quot;Run&quot;, "Python.draw_widget2d.executor": "Run",
&quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;, "Python.draw_widget_solve.executor": "Run",
&quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;, "Python.fluency.executor": "Run",
&quot;Python.fluency.executor&quot;: &quot;Run&quot;, "Python.fluencyb.executor": "Run",
&quot;Python.fluencyb.executor&quot;: &quot;Run&quot;, "Python.gl_widget.executor": "Run",
&quot;Python.gl_widget.executor&quot;: &quot;Run&quot;, "Python.gui_ui.executor": "Run",
&quot;Python.gui_ui.executor&quot;: &quot;Run&quot;, "Python.kernel.executor": "Run",
&quot;Python.kernel.executor&quot;: &quot;Run&quot;, "Python.main.executor": "Run",
&quot;Python.main.executor&quot;: &quot;Run&quot;, "Python.meshtest.executor": "Run",
&quot;Python.main_window.executor&quot;: &quot;Run&quot;, "Python.occ_renderer.executor": "Run",
&quot;Python.meshtest.executor&quot;: &quot;Run&quot;, "Python.occ_to_mesh.executor": "Run",
&quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;, "Python.render_backend.executor": "Run",
&quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;, "Python.side_fluency.executor": "Run",
&quot;Python.render_backend.executor&quot;: &quot;Run&quot;, "Python.simple_mesh.executor": "Run",
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;, "Python.sketch.executor": "Run",
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;, "Python.surface_modifier.executor": "Run",
&quot;Python.sketch.executor&quot;: &quot;Run&quot;, "Python.vtk_widget.executor": "Run",
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;, "Python.vulkan.executor": "Run",
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;, "RunOnceActivity.OpenProjectViewOnStart": "true",
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;, "RunOnceActivity.git.unshallow": "true",
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, "RunOnceActivity.typescript.service.memoryLimit.init": "true",
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;, "codeWithMe.voiceChat.enabledByDefault": "false",
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;, "git-widget-placeholder": "feature/surface-modifier-pyramid-patterns",
&quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;, "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
&quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency/src/fluency&quot;, "node.js.detected.package.eslint": "true",
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, "node.js.selected.package.eslint": "(autodetect)",
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.tslint": "(autodetect)",
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;, "nodejs_package_manager_path": "npm",
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, "settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
} }
}</component> }]]></component>
<component name="QodanaReportsService"> <component name="QodanaReportsService">
<option name="descriptions"> <option name="descriptions">
<ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" /> <ReportDescription localRun="true" path="/private/var/folders/kg/zm48w_r96yb68mlbzvb9gtq40000gn/T/qodana_output/qodana.sarif.json" reportGuid="5f5b823c-c594-48c5-ae1f-062e30303918" reportId="fluency/qodana/2024-02-04" />
@@ -97,9 +94,9 @@
</component> </component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" /> <recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" /> <recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$/drawing_modules" /> <recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" /> <recent name="$PROJECT_DIR$/modules" />
</key> </key>
@@ -443,23 +440,31 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1783976498520</updated> <updated>1783976498520</updated>
</task> </task>
<task id="LOCAL-00041" summary="- added &quot;measurement lines&quot;"> <task id="LOCAL-00041" summary="Improved render previews">
<option name="closed" value="true" /> <option name="closed" value="true" />
<created>1785094789247</created> <created>1784408804224</created>
<option name="number" value="00041" /> <option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" /> <option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1785094789248</updated> <updated>1784408804224</updated>
</task> </task>
<task id="LOCAL-00042" summary="- added &quot;measurement lines&quot;"> <task id="LOCAL-00042" summary="- added contrain context menu&#10;- improved line pickability.">
<option name="closed" value="true" /> <option name="closed" value="true" />
<created>1785697123545</created> <created>1784452692776</created>
<option name="number" value="00042" /> <option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" /> <option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1785697123545</updated> <updated>1784452692776</updated>
</task> </task>
<option name="localTasksCounter" value="43" /> <task id="LOCAL-00043" summary="- added contrain context menu&#10;- improved line pickability.">
<option name="closed" value="true" />
<created>1784470032428</created>
<option name="number" value="00043" />
<option name="presentableId" value="LOCAL-00043" />
<option name="project" value="LOCAL" />
<updated>1784470032428</updated>
</task>
<option name="localTasksCounter" value="44" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -480,7 +485,6 @@
<ignored-roots> <ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" /> <path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots> </ignored-roots>
<MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" /> <MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" /> <MESSAGE value="- changing compos including sketches and bodies" />
<MESSAGE value="- Drawing bodys depending on the selected compo&#10;- Cut working&#10;- Edit sketch working" /> <MESSAGE value="- Drawing bodys depending on the selected compo&#10;- Cut working&#10;- Edit sketch working" />
@@ -504,7 +508,8 @@
<MESSAGE value="- added renderer" /> <MESSAGE value="- added renderer" />
<MESSAGE value="- added renderer&#10;- Added undo" /> <MESSAGE value="- added renderer&#10;- Added undo" />
<MESSAGE value="- Render improvements, camera plane, update" /> <MESSAGE value="- Render improvements, camera plane, update" />
<MESSAGE value="- added &quot;measurement lines&quot;" /> <MESSAGE value="Improved render previews" />
<option name="LAST_COMMIT_MESSAGE" value="- added &quot;measurement lines&quot;" /> <MESSAGE value="- added contrain context menu&#10;- improved line pickability." />
<option name="LAST_COMMIT_MESSAGE" value="- added contrain context menu&#10;- improved line pickability." />
</component> </component>
</project> </project>
+842 -857
View File
File diff suppressed because it is too large Load Diff
+529 -543
View File
File diff suppressed because it is too large Load Diff
+65 -89
View File
@@ -235,93 +235,6 @@ class OCGeometryKernel(GeometryKernel):
pass pass
return None 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( def revolve(
self, self,
sketch: GeometryObject, sketch: GeometryObject,
@@ -455,11 +368,10 @@ class OCGeometryKernel(GeometryKernel):
else: else:
from OCP.TopExp import TopExp_Explorer from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE) explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More(): while explorer.More():
fillet.Add(radius, TopoDS.Edge_s(explorer.Current())) fillet.Add(radius, explorer.Current())
explorer.Next() explorer.Next()
fillet.Build() fillet.Build()
@@ -868,3 +780,67 @@ class OCGeometryKernel(GeometryKernel):
cg = props.CentreOfMass() cg = props.CentreOfMass()
return Point3D(cg.X(), cg.Y(), cg.Z()) return Point3D(cg.X(), cg.Y(), cg.Z())
def apply_surface_modifier(
self,
body_geometry: GeometryObject,
modifier_type: str = 'pyramid',
height: float = 2.0,
radius: float = 3.0,
spacing: float = 8.0,
num_rings: int = 3,
) -> Optional[GeometryObject]:
"""Apply a surface modifier to a body geometry.
Args:
body_geometry: The OCCGeometryObject to modify
modifier_type: Type of modifier ('pyramid', 'bump')
height: Height/depth of the pattern features
radius: Base radius of pattern features
spacing: Distance between pattern features
num_rings: Number of concentric rings
Returns:
Modified geometry, or None on failure
"""
from fluency.geometry_occ.surface_modifier import SurfaceModifier
shape = self._get_shape(body_geometry)
if shape is None:
logger.error("No geometry found in body")
return None
modifier = SurfaceModifier()
try:
if modifier_type == 'pyramid':
result_shape = modifier.apply_pyramid_pattern(
shape,
pyramid_height=height,
base_radius=radius,
spacing=spacing,
num_rings=num_rings,
direction=(0, 0, 1),
)
elif modifier_type == 'bump':
result_shape = modifier.apply_bump_pattern(
shape,
bump_height=height,
bump_radius=radius,
spacing=spacing,
num_rings=num_rings,
)
else:
logger.error(f"Unknown modifier type: {modifier_type}")
return None
if result_shape is None:
logger.error("Surface modifier application failed")
return None
# Return the modified shape wrapped in OCCGeometryObject
return OCCGeometryObject(result_shape)
except Exception as e:
logger.error(f"Error applying surface modifier: {e}", exc_info=True)
return None
File diff suppressed because it is too large Load Diff
@@ -1,385 +0,0 @@
"""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")
+26 -161
View File
@@ -28,7 +28,6 @@ import logging
import os import os
import shutil import shutil
import tempfile import tempfile
import uuid
import zipfile import zipfile
from dataclasses import asdict, is_dataclass from dataclasses import asdict, is_dataclass
from datetime import datetime from datetime import datetime
@@ -43,7 +42,6 @@ from fluency.models.data_model import (
Body, Body,
Component, Component,
Connector, Connector,
Feature,
Project, Project,
Sketch, Sketch,
Workplane, Workplane,
@@ -67,7 +65,7 @@ def _json_default(obj: Any) -> Any:
return sorted(obj) return sorted(obj)
if isinstance(obj, tuple): if isinstance(obj, tuple):
return list(obj) return list(obj)
if is_dataclass(obj) and not isinstance(obj, type): if is_dataclass(obj):
return asdict(obj) return asdict(obj)
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
@@ -87,37 +85,10 @@ def _coerce_listlike(value: Any) -> List[Any]:
return list(value) 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]: def _to_3tuple(value: Any) -> Tuple[float, float, float]:
"""Coerce a saved 3-vector to a tuple of floats (for OCC).""" """Coerce a saved 3-vector to a tuple of floats (for OCC)."""
if value is None: if value is None:
return (0.0, 0.0, 0.0) return (0.0, 0.0, 0.0)
try:
if isinstance(value, np.ndarray): if isinstance(value, np.ndarray):
seq = value.tolist() seq = value.tolist()
else: else:
@@ -125,13 +96,10 @@ def _to_3tuple(value: Any) -> Tuple[float, float, float]:
if len(seq) < 3: if len(seq) < 3:
seq = list(seq) + [0.0] * (3 - len(seq)) seq = list(seq) + [0.0] * (3 - len(seq))
return (float(seq[0]), float(seq[1]), float(seq[2])) 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: def _to_3vec(value: Any) -> np.ndarray:
"""Coerce a saved 3-vector to a 3-element numpy array.""" """Coerce a saved 3-vector to a 3-element numpy array."""
try:
if isinstance(value, np.ndarray): if isinstance(value, np.ndarray):
return value.astype(float).reshape(3) return value.astype(float).reshape(3)
if value is None: if value is None:
@@ -140,13 +108,10 @@ def _to_3vec(value: Any) -> np.ndarray:
if len(seq) < 3: if len(seq) < 3:
seq = list(seq) + [0.0] * (3 - len(seq)) seq = list(seq) + [0.0] * (3 - len(seq))
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float) 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: def _to_mat3(value: Any) -> np.ndarray:
"""Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray.""" """Coerce a saved 3×3 matrix (flat 9-list or nested) to np.ndarray."""
try:
if isinstance(value, np.ndarray): if isinstance(value, np.ndarray):
arr = value.astype(float) arr = value.astype(float)
return arr.reshape(3, 3) return arr.reshape(3, 3)
@@ -156,8 +121,6 @@ def _to_mat3(value: Any) -> np.ndarray:
if len(flat) < 9: if len(flat) < 9:
flat = flat + [0.0] * (9 - len(flat)) flat = flat + [0.0] * (9 - len(flat))
return np.array(flat[:9], dtype=float).reshape(3, 3) 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: def _parse_iso(value: Optional[str]) -> datetime:
@@ -188,11 +151,11 @@ def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]:
def _workplane_from_dict(data: Dict[str, Any]) -> Workplane: def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
wp = Workplane( wp = Workplane(
id=_saved_id(data), id=data.get("id") or None, # Workplane generates uuid if None
name=data.get("name", "Untitled Workplane"), name=data.get("name", "Untitled Workplane"),
origin=_to_3tuple(data.get("origin", (0.0, 0.0, 0.0))), origin=tuple(data.get("origin", (0.0, 0.0, 0.0))),
normal=_to_3tuple(data.get("normal", (0.0, 0.0, 1.0))), normal=tuple(data.get("normal", (0.0, 0.0, 1.0))),
x_dir=_to_3tuple(data.get("x_dir", (1.0, 0.0, 0.0))), x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
visible=bool(data.get("visible", True)), visible=bool(data.get("visible", True)),
) )
wp.created_at = _parse_iso(data.get("created_at")) wp.created_at = _parse_iso(data.get("created_at"))
@@ -200,56 +163,6 @@ def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
return wp 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]: def _body_to_dict(body: Body) -> Dict[str, Any]:
"""Body serialization. ``geometry_ref`` is set later by the ZIP writer """Body serialization. ``geometry_ref`` is set later by the ZIP writer
once the STEP file is written.""" once the STEP file is written."""
@@ -264,16 +177,12 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
"extrude_cut": body.extrude_cut, "extrude_cut": body.extrude_cut,
"extrude_union": body.extrude_union, "extrude_union": body.extrude_union,
"extrude_through_all": body.extrude_through_all, "extrude_through_all": body.extrude_through_all,
"extrude_cut_all_bodies": body.extrude_cut_all_bodies,
"extrude_face_index": body.extrude_face_index, "extrude_face_index": body.extrude_face_index,
"extrude_target_body_id": body.extrude_target_body_id, "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), "position": _coerce_listlike(body.position),
"rotation": _coerce_listlike(body.rotation), "rotation": _coerce_listlike(body.rotation),
"color": list(body.color) if body.color else [0.2, 0.4, 0.8], "color": list(body.color) if body.color else [0.2, 0.4, 0.8],
"opacity": _to_float(body.opacity, 1.0), "opacity": float(body.opacity),
"visible": bool(body.visible), "visible": bool(body.visible),
"has_geometry": body.geometry is not None, "has_geometry": body.geometry is not None,
"geometry_ref": None, # filled in by save_project "geometry_ref": None, # filled in by save_project
@@ -292,7 +201,7 @@ def _body_from_dict(
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
body = Body( body = Body(
id=_saved_id(data), id=data.get("id") or None,
name=data.get("name", "Untitled Body"), name=data.get("name", "Untitled Body"),
geometry=geometry, geometry=geometry,
source_sketch=source_sketch, source_sketch=source_sketch,
@@ -303,13 +212,12 @@ def _body_from_dict(
extrude_cut=bool(data.get("extrude_cut", False)), extrude_cut=bool(data.get("extrude_cut", False)),
extrude_union=bool(data.get("extrude_union", False)), extrude_union=bool(data.get("extrude_union", False)),
extrude_through_all=bool(data.get("extrude_through_all", 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_face_index=data.get("extrude_face_index"),
extrude_target_body_id=data.get("extrude_target_body_id"), extrude_target_body_id=data.get("extrude_target_body_id"),
position=_to_3vec(data.get("position")), position=_to_3vec(data.get("position")),
rotation=_to_mat3(data.get("rotation")), rotation=_to_mat3(data.get("rotation")),
color=tuple(data.get("color", [0.2, 0.4, 0.8])), color=tuple(data.get("color", [0.2, 0.4, 0.8])),
opacity=_to_float(data.get("opacity"), 1.0), opacity=float(data.get("opacity", 1.0)),
visible=bool(data.get("visible", True)), visible=bool(data.get("visible", True)),
) )
body.created_at = _parse_iso(data.get("created_at")) body.created_at = _parse_iso(data.get("created_at"))
@@ -359,9 +267,9 @@ def _sketch_from_dict(
# Re-apply the workplane (from_dict already does this internally, but be # Re-apply the workplane (from_dict already does this internally, but be
# defensive in case the saved dict didn't carry the workplane fields). # defensive in case the saved dict didn't carry the workplane fields).
occ_sketch.set_workplane( occ_sketch.set_workplane(
_to_3tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))), tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
_to_3tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))), tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
_to_3tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))), tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
) )
geometry: Optional[OCCGeometryObject] = None geometry: Optional[OCCGeometryObject] = None
@@ -369,7 +277,7 @@ def _sketch_from_dict(
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
sk = Sketch( sk = Sketch(
id=_saved_id(data), id=data.get("id") or None,
name=data.get("name", "Untitled Sketch"), name=data.get("name", "Untitled Sketch"),
occ_sketch=occ_sketch, occ_sketch=occ_sketch,
geometry=geometry, geometry=geometry,
@@ -406,7 +314,7 @@ def _component_from_dict(
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None, sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> Component: ) -> Component:
comp = Component( comp = Component(
id=_saved_id(data), id=data.get("id") or None,
name=data.get("name", "Untitled Component"), name=data.get("name", "Untitled Component"),
description=data.get("description", ""), description=data.get("description", ""),
active_sketch=data.get("active_sketch"), active_sketch=data.get("active_sketch"),
@@ -427,22 +335,7 @@ def _component_from_dict(
src_id = body_data.get("source_sketch_id") src_id = body_data.get("source_sketch_id")
if src_id and src_id in comp.sketches: if src_id and src_id in comp.sketches:
src_sketch = comp.sketches[src_id] src_sketch = comp.sketches[src_id]
body = _body_from_dict(body_data, body_geometry_loader, src_sketch) comp.bodies[bid] = _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 return comp
@@ -454,8 +347,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
"position": list(conn.position), "position": list(conn.position),
"normal": list(conn.normal), "normal": list(conn.normal),
"x_dir": list(conn.x_dir), "x_dir": list(conn.x_dir),
"axis_rotation": _to_float(conn.axis_rotation, 0.0), "axis_rotation": float(conn.axis_rotation),
"offset": _to_float(conn.offset, 0.0), "offset": float(conn.offset),
"assembly_component_id": conn.assembly_component_id, "assembly_component_id": conn.assembly_component_id,
"source_obj_id": conn.source_obj_id, "source_obj_id": conn.source_obj_id,
"partner_ac_id": conn.partner_ac_id, "partner_ac_id": conn.partner_ac_id,
@@ -469,13 +362,13 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
def _connector_from_dict(data: Dict[str, Any]) -> Connector: def _connector_from_dict(data: Dict[str, Any]) -> Connector:
conn = Connector( conn = Connector(
id=_saved_id(data), id=data.get("id") or None,
name=data.get("name", "Untitled Connector"), name=data.get("name", "Untitled Connector"),
position=_to_3tuple(data.get("position")), position=_to_3tuple(data.get("position")),
normal=_to_3tuple(data.get("normal")), normal=_to_3tuple(data.get("normal")),
x_dir=_to_3tuple(data.get("x_dir")), x_dir=_to_3tuple(data.get("x_dir")),
axis_rotation=_to_float(data.get("axis_rotation"), 0.0), axis_rotation=float(data.get("axis_rotation", 0.0)),
offset=_to_float(data.get("offset"), 0.0), offset=float(data.get("offset", 0.0)),
assembly_component_id=data.get("assembly_component_id", ""), assembly_component_id=data.get("assembly_component_id", ""),
source_obj_id=data.get("source_obj_id", ""), source_obj_id=data.get("source_obj_id", ""),
) )
@@ -503,7 +396,7 @@ def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent: def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
ac = AssemblyComponent( ac = AssemblyComponent(
id=_saved_id(data), id=data.get("id") or None,
component_id=data.get("component_id", ""), component_id=data.get("component_id", ""),
name=data.get("name", "Untitled Instance"), name=data.get("name", "Untitled Instance"),
position=_to_3vec(data.get("position")), position=_to_3vec(data.get("position")),
@@ -529,7 +422,7 @@ def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]:
def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection: def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
conn = AssemblyConnection( conn = AssemblyConnection(
id=_saved_id(data), id=data.get("id") or None,
first_ac_id=data.get("first_ac_id", ""), first_ac_id=data.get("first_ac_id", ""),
second_ac_id=data.get("second_ac_id", ""), second_ac_id=data.get("second_ac_id", ""),
first_connector_id=data.get("first_connector_id"), first_connector_id=data.get("first_connector_id"),
@@ -553,7 +446,7 @@ def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly: def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
asm = Assembly( asm = Assembly(
id=_saved_id(data), id=data.get("id") or None,
name=data.get("name", "Untitled Assembly"), name=data.get("name", "Untitled Assembly"),
active_assembly_component=data.get("active_assembly_component"), active_assembly_component=data.get("active_assembly_component"),
) )
@@ -561,7 +454,7 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
asm.modified_at = _parse_iso(data.get("modified_at")) asm.modified_at = _parse_iso(data.get("modified_at"))
for cid, ac_data in (data.get("components") or {}).items(): for cid, ac_data in (data.get("components") or {}).items():
asm.components[cid] = _assembly_component_from_dict(ac_data) 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)) asm.connections.append(_assembly_connection_from_dict(c_data))
return asm return asm
@@ -624,9 +517,7 @@ def _read_step_bytes(
with open(tmp_path, "wb") as f: with open(tmp_path, "wb") as f:
f.write(data) f.write(data)
geom = kernel.import_step(tmp_path) geom = kernel.import_step(tmp_path)
from typing import cast return geom
return cast(OCCGeometryObject, geom)
except Exception as exc: except Exception as exc:
logger.warning("Failed to read STEP: %s", exc) logger.warning("Failed to read STEP: %s", exc)
return None return None
@@ -689,20 +580,6 @@ def save_project(
arcname = f"bodies/{body_id}.step" arcname = f"bodies/{body_id}.step"
body_files.append((arcname, step_bytes)) body_files.append((arcname, step_bytes))
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname 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). # Per-sketch STEP files (solved face geometry).
sketch_files: List[Tuple[str, bytes]] = [] sketch_files: List[Tuple[str, bytes]] = []
@@ -795,10 +672,7 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
with zipfile.ZipFile(filepath, "r") as zipf: with zipfile.ZipFile(filepath, "r") as zipf:
manifest_raw = zipf.read("project.json") manifest_raw = zipf.read("project.json")
try:
manifest = json.loads(manifest_raw.decode("utf-8")) 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 {} view_state: Dict[str, Any] = manifest.get("view_state") or {}
# If a sketch's occ_sketch is referenced as a separate file, read # If a sketch's occ_sketch is referenced as a separate file, read
@@ -813,21 +687,12 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
except KeyError: except KeyError:
logger.warning("Sketch meta missing in archive: %s", ref) logger.warning("Sketch meta missing in archive: %s", ref)
continue continue
try:
meta = json.loads(meta_bytes.decode("utf-8")) 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") sk_data["occ_sketch"] = meta.get("occ_sketch")
# Workplane fields on the sketch-level file override the # Workplane fields on the sketch-level file override the
# embedded ones (source of truth lives in the sidecar). # embedded ones (source of truth lives in the sidecar).
for k in ( for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
"workplane_origin", "is_solved", "is_fully_constrained"):
"workplane_normal",
"workplane_x_dir",
"is_solved",
"is_fully_constrained",
):
if k in meta: if k in meta:
sk_data[k] = meta[k] sk_data[k] = meta[k]
+3 -63
View File
@@ -60,9 +60,9 @@ class Workplane:
x = x / x_norm x = x / x_norm
y = np.cross(n, x) y = np.cross(n, x)
y = y / np.linalg.norm(y) y = y / np.linalg.norm(y)
self.normal = (float(n[0]), float(n[1]), float(n[2])) self.normal = tuple(float(v) for v in n)
self.x_dir = (float(x[0]), float(x[1]), float(x[2])) self.x_dir = tuple(float(v) for v in x)
self._y_dir = (float(y[0]), float(y[1]), float(y[2])) self._y_dir = tuple(float(v) for v in y)
@property @property
def y_dir(self) -> Tuple[float, float, float]: def y_dir(self) -> Tuple[float, float, float]:
@@ -206,59 +206,6 @@ class Sketch:
self.modified_at = datetime.now() 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 @dataclass
class Body: class Body:
""" """
@@ -275,12 +222,6 @@ class Body:
source_sketch: Optional[Sketch] = None source_sketch: Optional[Sketch] = None
source_operation: str = "extrude" 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 # Re-extrusion parameters — stored so the body can be rebuilt from
# its source sketch when the sketch is edited. None means the body # its source sketch when the sketch is edited. None means the body
# was not created by an extrude-type operation and cannot be auto- # was not created by an extrude-type operation and cannot be auto-
@@ -291,7 +232,6 @@ class Body:
extrude_cut: bool = False extrude_cut: bool = False
extrude_union: bool = False extrude_union: bool = False
extrude_through_all: 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_face_index: Optional[int] = None # which sketch face was selected
extrude_target_body_id: Optional[str] = None # for cut/union: target body id 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 needs_update: bool = False # True when source sketch changed since last extrude
+94 -241
View File
@@ -126,8 +126,6 @@ class OCCRenderer(Renderer):
self._nav_mode: Optional[str] = None # "rotate" | "pan" | None self._nav_mode: Optional[str] = None # "rotate" | "pan" | None
# Persistent light-blue transparent overlay marking the selected face. # Persistent light-blue transparent overlay marking the selected face.
self._highlight_ais: Any = None 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. # Temporary transparent preview AIS for the live extrude/cut dialog.
self._preview_ais: Any = None self._preview_ais: Any = None
# Smart entity picker gizmo objects (snap markers, axis lines, rings). # Smart entity picker gizmo objects (snap markers, axis lines, rings).
@@ -139,7 +137,6 @@ class OCCRenderer(Renderer):
self._parent_widget = parent_widget self._parent_widget = parent_widget
import os as _os import os as _os
if _os.environ.get("QT_QPA_PLATFORM") == "offscreen": if _os.environ.get("QT_QPA_PLATFORM") == "offscreen":
logger.warning("OCCRenderer skipped (QT_QPA_PLATFORM=offscreen)") logger.warning("OCCRenderer skipped (QT_QPA_PLATFORM=offscreen)")
return False return False
@@ -162,14 +159,16 @@ class OCCRenderer(Renderer):
) )
from OCP.AIS import AIS_InteractiveContext from OCP.AIS import AIS_InteractiveContext
from OCP.Graphic3d import ( from OCP.Graphic3d import (
Graphic3d_Camera,
Graphic3d_TypeOfShadingModel, Graphic3d_TypeOfShadingModel,
Graphic3d_MaterialAspect,
Graphic3d_NameOfMaterial,
) )
from OCP.Quantity import ( from OCP.Quantity import (
Quantity_Color, Quantity_Color,
Quantity_TOC_RGB, Quantity_TOC_RGB,
Quantity_NameOfColor, Quantity_NameOfColor,
) )
logger.info("OCCRenderer imports complete") logger.info("OCCRenderer imports complete")
hwnd = int(parent_widget.winId()) hwnd = int(parent_widget.winId())
@@ -204,7 +203,9 @@ class OCCRenderer(Renderer):
Quantity_Color(0.5, 0.5, 0.55, Quantity_TOC_RGB), Quantity_Color(0.5, 0.5, 0.55, Quantity_TOC_RGB),
True, 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): for light in (key, fill, rim, ambient):
viewer.SetLightOn(light) viewer.SetLightOn(light)
@@ -248,7 +249,6 @@ class OCCRenderer(Renderer):
# pick preview matches the persistent selection overlay below. # pick preview matches the persistent selection overlay below.
try: try:
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
# Modify the existing dynamic-highlight drawer in place (per # Modify the existing dynamic-highlight drawer in place (per
# OCC docs this is safer than building a fresh Prs3d_Drawer). # OCC docs this is safer than building a fresh Prs3d_Drawer).
hd = context.HighlightStyle() hd = context.HighlightStyle()
@@ -305,6 +305,7 @@ class OCCRenderer(Renderer):
""" """
from OCP.AIS import AIS_Shape from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB 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]}" obj_id = name or f"shape_{uuid.uuid4().hex[:8]}"
@@ -344,7 +345,6 @@ class OCCRenderer(Renderer):
# explicit pick methods (pick_entity / pick_planar_face). # explicit pick methods (pick_entity / pick_planar_face).
try: try:
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
mode = AIS_Shape.SelectionMode_s(topo) mode = AIS_Shape.SelectionMode_s(topo)
self._context.Activate(ais, mode) self._context.Activate(ais, mode)
@@ -377,7 +377,9 @@ class OCCRenderer(Renderer):
""" """
from OCP.Graphic3d import Graphic3d_MaterialAspect, Graphic3d_NameOfMaterial 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 return mat
# ─── Legacy mesh / wireframe (kept for backward compat) ──────────── # ─── Legacy mesh / wireframe (kept for backward compat) ────────────
@@ -580,12 +582,10 @@ class OCCRenderer(Renderer):
self._context.RemoveAll(True) self._context.RemoveAll(True)
except Exception: except Exception:
from OCP.AIS import AIS_ListOfInteractive, AIS_KindOfInteractive from OCP.AIS import AIS_ListOfInteractive, AIS_KindOfInteractive
lst = AIS_ListOfInteractive() lst = AIS_ListOfInteractive()
self._context.DisplayedObjects(AIS_KindOfInteractive.AIS_KOI_None, -1, lst) self._context.DisplayedObjects(AIS_KindOfInteractive.AIS_KOI_None, -1, lst)
for ais in lst: for ais in lst:
self._context.Remove(ais, True) self._context.Remove(ais, True)
self._faces_highlight_ais = []
self._objects.clear() self._objects.clear()
def update_mesh( def update_mesh(
@@ -702,9 +702,10 @@ class OCCRenderer(Renderer):
# Check projection type. # Check projection type.
from OCP.Graphic3d import Graphic3d_Camera from OCP.Graphic3d import Graphic3d_Camera
proj_type = cam.ProjectionType() proj_type = cam.ProjectionType()
is_orthographic = proj_type == Graphic3d_Camera.Projection_Orthographic is_orthographic = (
proj_type == Graphic3d_Camera.Projection_Orthographic
)
if not is_orthographic: if not is_orthographic:
# Perspective mode: use the actual eye position directly. # Perspective mode: use the actual eye position directly.
@@ -725,7 +726,6 @@ class OCCRenderer(Renderer):
# Compute scene bounding box diagonal from displayed objects. # Compute scene bounding box diagonal from displayed objects.
from OCP.Bnd import Bnd_Box from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box() bbox = Bnd_Box()
try: try:
for robj in self._objects.values(): for robj in self._objects.values():
@@ -737,7 +737,13 @@ class OCCRenderer(Renderer):
pass pass
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() 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), # Fallback: if bbox is empty (no objects or all shapes failed),
# use the eye-to-at distance as a reasonable estimate. # use the eye-to-at distance as a reasonable estimate.
@@ -747,8 +753,9 @@ class OCCRenderer(Renderer):
# Base distance: how far the camera must be for the bbox diagonal # Base distance: how far the camera must be for the bbox diagonal
# to fill the frame at the given vertical FOV. # to fill the frame at the given vertical FOV.
import math import math
base_distance = diag / (
base_distance = diag / (2.0 * math.tan(math.radians(fov_y / 2.0))) 2.0 * math.tan(math.radians(fov_y / 2.0))
)
# Scale factor maps inversely: larger scale (zoomed in) → closer camera. # Scale factor maps inversely: larger scale (zoomed in) → closer camera.
# Dividing by view_scale ensures that when the user zooms in (scale increases) # Dividing by view_scale ensures that when the user zooms in (scale increases)
@@ -879,7 +886,9 @@ class OCCRenderer(Renderer):
def on_pick(self, callback: Any) -> None: def on_pick(self, callback: Any) -> None:
pass 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) return (0.0, 0.0)
def save_screenshot(self, path: str, width: int = 1920, height: int = 1080) -> None: def save_screenshot(self, path: str, width: int = 1920, height: int = 1080) -> None:
@@ -889,7 +898,6 @@ class OCCRenderer(Renderer):
if self._view is None: if self._view is None:
return return
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
qcol = Quantity_Color(*color, Quantity_TOC_RGB) qcol = Quantity_Color(*color, Quantity_TOC_RGB)
self._view.SetBackgroundColor(qcol) self._view.SetBackgroundColor(qcol)
@@ -923,6 +931,7 @@ class OCCRenderer(Renderer):
from OCP.gp import gp_Pln from OCP.gp import gp_Pln
from OCP.AIS import AIS_Shape from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB 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]}" obj_id = name or f"{self._WORKPLANE_BASE_ID}_{uuid.uuid4().hex[:8]}"
@@ -971,7 +980,6 @@ class OCCRenderer(Renderer):
) )
# Use gp_Pnt for the corners to make a bounded face. # Use gp_Pnt for the corners to make a bounded face.
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
mp = BRepBuilderAPI_MakePolygon() mp = BRepBuilderAPI_MakePolygon()
for c in corners_3d: for c in corners_3d:
mp.Add(gp_Pnt(*c)) mp.Add(gp_Pnt(*c))
@@ -1031,7 +1039,9 @@ class OCCRenderer(Renderer):
def take_screenshot(self) -> bytes: def take_screenshot(self) -> bytes:
return b"" 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) return (0.0, 0.0, 0.0)
# ─── Face picking (for sketch-on-surface) ──────────────────────────── # ─── Face picking (for sketch-on-surface) ────────────────────────────
@@ -1051,8 +1061,12 @@ class OCCRenderer(Renderer):
from OCP.BRepAdaptor import BRepAdaptor_Surface from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Plane from OCP.GeomAbs import GeomAbs_Plane
from OCP.TopoDS import TopoDS from OCP.TopoDS import TopoDS_Face, TopoDS
from OCP.gp import gp_Pln 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
# Detect what's under the cursor. # Detect what's under the cursor.
self._context.MoveTo(x, y, self._view, True) self._context.MoveTo(x, y, self._view, True)
@@ -1093,7 +1107,6 @@ class OCCRenderer(Renderer):
# default (non-inverted) extrude would punch back into the body # default (non-inverted) extrude would punch back into the body
# instead of building outward on top of it. # instead of building outward on top of it.
from OCP.TopAbs import TopAbs_REVERSED from OCP.TopAbs import TopAbs_REVERSED
n = pln.Axis().Direction() n = pln.Axis().Direction()
if face.Orientation() == TopAbs_REVERSED: if face.Orientation() == TopAbs_REVERSED:
n = n.Reversed() n = n.Reversed()
@@ -1102,7 +1115,6 @@ class OCCRenderer(Renderer):
# plane, so the UV frame is centred on the face (nicer for sketching). # plane, so the UV frame is centred on the face (nicer for sketching).
from OCP.Bnd import Bnd_Box from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib from OCP.BRepBndLib import BRepBndLib
bbox = Bnd_Box() bbox = Bnd_Box()
BRepBndLib.Add_s(face, bbox) BRepBndLib.Add_s(face, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
@@ -1145,56 +1157,6 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id, "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: def highlight_face(self, face: Any) -> None:
"""Overlay a persistent, mostly-transparent light-blue tint on *face*. """Overlay a persistent, mostly-transparent light-blue tint on *face*.
@@ -1243,58 +1205,6 @@ class OCCRenderer(Renderer):
logger.debug("clear_face_highlight remove failed", exc_info=True) logger.debug("clear_face_highlight remove failed", exc_info=True)
self._highlight_ais = None 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) ─────────── # ─── General entity picking (for assembly connectors / snaps) ───────────
def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]: def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]:
@@ -1336,13 +1246,12 @@ class OCCRenderer(Renderer):
except Exception: except Exception:
pass pass
if eye is not None: 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] return results[0]
def _classify_detected_shape( def _classify_detected_shape(
self, self, shape: Any, owner_obj_id: Optional[str] = None,
shape: Any,
owner_obj_id: Optional[str] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Classify a detected OCC sub-shape into snap-candidate dicts. """Classify a detected OCC sub-shape into snap-candidate dicts.
@@ -1358,14 +1267,17 @@ class OCCRenderer(Renderer):
if shape is None: if shape is None:
return [] return []
from OCP.TopoDS import TopoDS from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Vertex, TopoDS
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Circle from OCP.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Circle
from OCP.BRep import BRep_Tool from OCP.BRep import BRep_Tool
from OCP.TopExp import TopExp_Explorer from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE as TopAbs_EDGE_TYPE 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.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib from OCP.BRepBndLib import BRepBndLib
from OCP.TopExp import TopExp
import numpy as np import numpy as np
# Helper: find owner object id if not supplied. # Helper: find owner object id if not supplied.
@@ -1397,7 +1309,6 @@ class OCCRenderer(Renderer):
pln = adaptor.Plane() pln = adaptor.Plane()
n = pln.Axis().Direction() n = pln.Axis().Direction()
from OCP.TopAbs import TopAbs_REVERSED from OCP.TopAbs import TopAbs_REVERSED
if face.Orientation() == TopAbs_REVERSED: if face.Orientation() == TopAbs_REVERSED:
n = n.Reversed() n = n.Reversed()
nx, ny, nz = n.X(), n.Y(), n.Z() nx, ny, nz = n.X(), n.Y(), n.Z()
@@ -1408,26 +1319,20 @@ class OCCRenderer(Renderer):
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0 cx, cy, cz = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0
pln_origin = pln.Location() pln_origin = pln.Location()
d = ( d = (cx - pln_origin.X()) * nx + (cy - pln_origin.Y()) * ny + (cz - pln_origin.Z()) * nz
(cx - pln_origin.X()) * nx
+ (cy - pln_origin.Y()) * ny
+ (cz - pln_origin.Z()) * nz
)
origin = (cx - d * nx, cy - d * ny, cz - d * nz) origin = (cx - d * nx, cy - d * ny, cz - d * nz)
# x_dir: viewport-aligned so connector gizmo matches screen. # x_dir: viewport-aligned so connector gizmo matches screen.
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view) x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
return [ return [{
{
"type": "planar_face", "type": "planar_face",
"position": origin, "position": origin,
"normal": (nx, ny, nz), "normal": (nx, ny, nz),
"x_dir": x_dir, "x_dir": x_dir,
"face": face, "face": face,
"owner_obj_id": owner_obj_id, "owner_obj_id": owner_obj_id,
} }]
]
elif stype == GeomAbs_Cylinder: elif stype == GeomAbs_Cylinder:
cyl = adaptor.Cylinder() cyl = adaptor.Cylinder()
@@ -1451,11 +1356,9 @@ class OCCRenderer(Renderer):
if curve_adaptor.GetType() == GeomAbs_Circle: if curve_adaptor.GetType() == GeomAbs_Circle:
circ = curve_adaptor.Circle() circ = curve_adaptor.Circle()
center_pnt = circ.Location() center_pnt = circ.Location()
circle_centers.append( circle_centers.append(np.array([
np.array( center_pnt.X(), center_pnt.Y(), center_pnt.Z()
[center_pnt.X(), center_pnt.Y(), center_pnt.Z()], dtype=float ], dtype=float))
)
)
except Exception: except Exception:
pass pass
edge_explorer.Next() edge_explorer.Next()
@@ -1465,7 +1368,9 @@ class OCCRenderer(Renderer):
if len(circle_centers) >= 2: if len(circle_centers) >= 2:
# Project each center onto the axis direction to get a # Project each center onto the axis direction to get a
# scalar "height" value. Cluster into two groups. # 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] heights = [np.dot(c, ax_dir_np) for c in circle_centers]
# Sort by height (scalar) and split roughly in half. # Sort by height (scalar) and split roughly in half.
indexed = list(enumerate(heights)) indexed = list(enumerate(heights))
@@ -1486,22 +1391,16 @@ class OCCRenderer(Renderer):
# No circular edges found — fall back to parameter-based. # No circular edges found — fall back to parameter-based.
vmin = adaptor.FirstVParameter() vmin = adaptor.FirstVParameter()
vmax = adaptor.LastVParameter() vmax = adaptor.LastVParameter()
c0 = np.array( c0 = np.array([
[
ax_pos.X() + ax_dir.X() * vmin, ax_pos.X() + ax_dir.X() * vmin,
ax_pos.Y() + ax_dir.Y() * vmin, ax_pos.Y() + ax_dir.Y() * vmin,
ax_pos.Z() + ax_dir.Z() * vmin, ax_pos.Z() + ax_dir.Z() * vmin,
], ], dtype=float)
dtype=float, c1 = np.array([
)
c1 = np.array(
[
ax_pos.X() + ax_dir.X() * vmax, ax_pos.X() + ax_dir.X() * vmax,
ax_pos.Y() + ax_dir.Y() * vmax, ax_pos.Y() + ax_dir.Y() * vmax,
ax_pos.Z() + ax_dir.Z() * vmax, ax_pos.Z() + ax_dir.Z() * vmax,
], ], dtype=float)
dtype=float,
)
# Normal = the cylinder axis direction. This is the "bolt # Normal = the cylinder axis direction. This is the "bolt
# axis": the direction a bolt would travel INTO the hole. # axis": the direction a bolt would travel INTO the hole.
@@ -1524,8 +1423,7 @@ class OCCRenderer(Renderer):
results: List[Dict[str, Any]] = [] results: List[Dict[str, Any]] = []
for end_center in [c0, c1]: for end_center in [c0, c1]:
origin = (float(end_center[0]), float(end_center[1]), float(end_center[2])) origin = (float(end_center[0]), float(end_center[1]), float(end_center[2]))
results.append( results.append({
{
"type": "cylindrical_face", "type": "cylindrical_face",
"position": origin, "position": origin,
"normal": normal, "normal": normal,
@@ -1533,8 +1431,7 @@ class OCCRenderer(Renderer):
"face": face, "face": face,
"owner_obj_id": owner_obj_id, "owner_obj_id": owner_obj_id,
"radius": radius, "radius": radius,
} })
)
return results return results
# Try edge. # Try edge.
@@ -1578,16 +1475,14 @@ class OCCRenderer(Renderer):
x = x / xlen x = x / xlen
x_dir = (float(x[0]), float(x[1]), float(x[2])) x_dir = (float(x[0]), float(x[1]), float(x[2]))
return [ return [{
{
"type": "edge", "type": "edge",
"position": position, "position": position,
"normal": tangent, "normal": tangent,
"x_dir": x_dir, "x_dir": x_dir,
"edge": edge, "edge": edge,
"owner_obj_id": owner_obj_id, "owner_obj_id": owner_obj_id,
} }]
]
# Try vertex. # Try vertex.
vertex = None vertex = None
@@ -1595,26 +1490,21 @@ class OCCRenderer(Renderer):
vertex = TopoDS.Vertex_s(shape) vertex = TopoDS.Vertex_s(shape)
p = BRep_Tool.Pnt_s(vertex) p = BRep_Tool.Pnt_s(vertex)
position = (p.X(), p.Y(), p.Z()) position = (p.X(), p.Y(), p.Z())
return [ return [{
{
"type": "vertex", "type": "vertex",
"position": position, "position": position,
"normal": None, "normal": None,
"x_dir": None, "x_dir": None,
"vertex": vertex, "vertex": vertex,
"owner_obj_id": owner_obj_id, "owner_obj_id": owner_obj_id,
} }]
]
except Exception: except Exception:
pass pass
return [] return []
def probe_snap_candidates( def probe_snap_candidates(
self, self, x: int, y: int, radius: int = 30,
x: int,
y: int,
radius: int = 30,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Probe a pixel grid around (x, y) and return visible snap candidates. """Probe a pixel grid around (x, y) and return visible snap candidates.
@@ -1644,28 +1534,13 @@ class OCCRenderer(Renderer):
ring_offsets = [ ring_offsets = [
(0, 0), (0, 0),
# Full radius ring (cardinal + diagonal) # Full radius ring (cardinal + diagonal)
(-radius, 0), (-radius, 0), (radius, 0), (0, -radius), (0, radius),
(radius, 0), (-radius, -radius), (radius, radius), (-radius, radius), (radius, -radius),
(0, -radius),
(0, radius),
(-radius, -radius),
(radius, radius),
(-radius, radius),
(radius, -radius),
# Half-radius ring # Half-radius ring
(-h, 0), (-h, 0), (h, 0), (0, -h), (0, h),
(h, 0), (-h, -h), (h, h), (-h, h), (h, -h),
(0, -h),
(0, h),
(-h, -h),
(h, h),
(-h, h),
(h, -h),
# Quarter-radius ring for small features # Quarter-radius ring for small features
(-q, 0), (-q, 0), (q, 0), (0, -q), (0, q),
(q, 0),
(0, -q),
(0, q),
] ]
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {} candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
@@ -1701,11 +1576,7 @@ class OCCRenderer(Renderer):
# Sort by screen-space distance to the cursor, nearest first. # Sort by screen-space distance to the cursor, nearest first.
results = list(candidates.values()) results = list(candidates.values())
results.sort( 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 return results
def highlight_snap(self, position, color=None, size=6.0) -> Optional[str]: def highlight_snap(self, position, color=None, size=6.0) -> Optional[str]:
@@ -1721,7 +1592,6 @@ class OCCRenderer(Renderer):
from OCP.gp import gp_Pnt from OCP.gp import gp_Pnt
from OCP.AIS import AIS_Shape from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
try: try:
scaled_size = size * self._get_gizmo_scale(position) scaled_size = size * self._get_gizmo_scale(position)
sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), scaled_size).Shape() sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), scaled_size).Shape()
@@ -1872,9 +1742,7 @@ class OCCRenderer(Renderer):
continue continue
# Skip the primary itself — it gets its own bright marker. # Skip the primary itself — it gets its own bright marker.
if (round(cpos[0], 1), round(cpos[1], 1), round(cpos[2], 1)) == ( if (round(cpos[0], 1), round(cpos[1], 1), round(cpos[2], 1)) == (
round(px, 1), round(px, 1), round(py, 1), round(pz, 1)
round(py, 1),
round(pz, 1),
): ):
continue continue
cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7)) cc = default_colors.get(cand.get("type", ""), (0.7, 0.7, 0.7))
@@ -1904,7 +1772,9 @@ class OCCRenderer(Renderer):
ey = origin[1] + uy * length ey = origin[1] + uy * length
ez = origin[2] + uz * 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 = AIS_Shape(edge)
ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB)) ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB))
ais.SetDisplayMode(0) # wireframe ais.SetDisplayMode(0) # wireframe
@@ -1930,17 +1800,12 @@ class OCCRenderer(Renderer):
# visual balance. This reads as 'bolt axis through hole'. # 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, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in")
_make_axis_line( _make_axis_line(
position, position, (-normal[0], -normal[1], -normal[2]),
(-normal[0], -normal[1], -normal[2]), axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub",
axis_length * 0.4,
(0.6, 0.6, 0.6),
"axis_stub",
) )
# Radial reference (same colour as the marker). # Radial reference (same colour as the marker).
if x_dir is not None: if x_dir is not None:
_make_axis_line( _make_axis_line(position, x_dir, radius or (axis_length * 0.5), gizmo_color, "radial")
position, x_dir, radius or (axis_length * 0.5), gizmo_color, "radial"
)
elif entity_type == "edge" and normal is not None: elif entity_type == "edge" and normal is not None:
# Tangent direction at midpoint. # Tangent direction at midpoint.
@@ -1993,7 +1858,6 @@ class OCCRenderer(Renderer):
return return
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.AIS import AIS_Shape from OCP.AIS import AIS_Shape
for robj in self._objects.values(): for robj in self._objects.values():
if robj.ais_shape is not None: if robj.ais_shape is not None:
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
@@ -2014,7 +1878,6 @@ class OCCRenderer(Renderer):
return return
from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
from OCP.AIS import AIS_Shape from OCP.AIS import AIS_Shape
for robj in self._objects.values(): for robj in self._objects.values():
if robj.ais_shape is not None: if robj.ais_shape is not None:
for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE): for topo in (TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE):
@@ -2052,10 +1915,7 @@ class OCCRenderer(Renderer):
return None return None
def probe_snap_candidates_geometric( def probe_snap_candidates_geometric(
self, self, x: int, y: int, radius: int = 30,
x: int,
y: int,
radius: int = 30,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Probe snap candidates by iterating geometry directly (no selection system). """Probe snap candidates by iterating geometry directly (no selection system).
@@ -2077,6 +1937,7 @@ class OCCRenderer(Renderer):
from OCP.TopoDS import TopoDS from OCP.TopoDS import TopoDS
from OCP.Bnd import Bnd_Box from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib from OCP.BRepBndLib import BRepBndLib
import numpy as np
candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {} candidates: Dict[Tuple[str, str, Tuple[int, int, int]], Dict[str, Any]] = {}
# Expand the search radius for the bbox pre-filter so features near # Expand the search radius for the bbox pre-filter so features near
@@ -2104,14 +1965,10 @@ class OCCRenderer(Renderer):
bx0, by0, bz0, bx1, by1, bz1 = bbox.Get() bx0, by0, bz0, bx1, by1, bz1 = bbox.Get()
# Project the 8 AABB corners to screen. # Project the 8 AABB corners to screen.
corners = [ corners = [
(bx0, by0, bz0), (bx0, by0, bz0), (bx1, by0, bz0),
(bx1, by0, bz0), (bx0, by1, bz0), (bx1, by1, bz0),
(bx0, by1, bz0), (bx0, by0, bz1), (bx1, by0, bz1),
(bx1, by1, bz0), (bx0, by1, bz1), (bx1, by1, bz1),
(bx0, by0, bz1),
(bx1, by0, bz1),
(bx0, by1, bz1),
(bx1, by1, bz1),
] ]
sx_min, sy_min = 99999, 99999 sx_min, sy_min = 99999, 99999
sx_max, sy_max = -99999, -99999 sx_max, sy_max = -99999, -99999
@@ -2127,12 +1984,8 @@ class OCCRenderer(Renderer):
if all_behind: if all_behind:
continue continue
# Check if cursor is within margin of the screen bbox. # Check if cursor is within margin of the screen bbox.
if ( if (x < sx_min - margin or x > sx_max + margin or
x < sx_min - margin y < sy_min - margin or y > sy_max + margin):
or x > sx_max + margin
or y < sy_min - margin
or y > sy_max + margin
):
continue continue
except Exception: except Exception:
pass # If bbox fails, fall through and try features. pass # If bbox fails, fall through and try features.
@@ -2207,9 +2060,8 @@ class OCCRenderer(Renderer):
# Sort by screen-space distance to cursor, nearest first. # Sort by screen-space distance to cursor, nearest first.
results = list(candidates.values()) results = list(candidates.values())
results.sort( results.sort(
key=lambda c: ( key=lambda c: (c.get("screen", (x, y))[0] - x) ** 2
(c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2
)
) )
return results return results
@@ -2229,6 +2081,7 @@ class OCCRenderer(Renderer):
* ``suggestion`` human-readable snap suggestion * ``suggestion`` human-readable snap suggestion
* ``feature_data`` dict with feature-specific info (radius, axis, etc.) * ``feature_data`` dict with feature-specific info (radius, axis, etc.)
""" """
import numpy as np
from collections import defaultdict from collections import defaultdict
# Group candidates by owner_obj_id. # Group candidates by owner_obj_id.
@@ -2273,9 +2126,10 @@ class OCCRenderer(Renderer):
if etype == "edge": if etype == "edge":
# Look for other edges nearby that might form a loop. # Look for other edges nearby that might form a loop.
nearby_edges = [ nearby_edges = [
n n for n in candidates
for n in candidates if n.get("type") == "edge"
if n.get("type") == "edge" and n.get("owner_obj_id") == owner and n is not c and n.get("owner_obj_id") == owner
and n is not c
] ]
# For now, mark as edge — loop detection is complex. # For now, mark as edge — loop detection is complex.
ec["feature_type"] = "edge" ec["feature_type"] = "edge"
@@ -2291,9 +2145,9 @@ class OCCRenderer(Renderer):
if etype == "vertex": if etype == "vertex":
# Look for edges that share this vertex (nearby edges). # Look for edges that share this vertex (nearby edges).
nearby_edges = [ nearby_edges = [
n n for n in candidates
for n in candidates if n.get("type") == "edge"
if n.get("type") == "edge" and n.get("owner_obj_id") == owner and n.get("owner_obj_id") == owner
] ]
if len(nearby_edges) >= 2: if len(nearby_edges) >= 2:
ec["feature_type"] = "meeting_edges" ec["feature_type"] = "meeting_edges"
@@ -2326,7 +2180,6 @@ class OCCRenderer(Renderer):
def _qt_buttons(self, event) -> Any: def _qt_buttons(self, event) -> Any:
"""Return the PySide6 Qt enum module lazily.""" """Return the PySide6 Qt enum module lazily."""
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
return Qt return Qt
def handle_mouse_press(self, event) -> None: def handle_mouse_press(self, event) -> None:
+176 -252
View File
@@ -4,14 +4,14 @@ from __future__ import annotations
import logging import logging
import math import math
from typing import Any, Callable, Dict, Optional, Tuple from typing import Tuple
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QButtonGroup, QButtonGroup,
QCheckBox, QCheckBox,
QComboBox,
QDialog, QDialog,
QDoubleSpinBox, QDoubleSpinBox,
QFormLayout,
QFrame, QFrame,
QGridLayout, QGridLayout,
QHBoxLayout, QHBoxLayout,
@@ -20,20 +20,10 @@ from PySide6.QtWidgets import (
QPushButton, QPushButton,
QRadioButton, QRadioButton,
QVBoxLayout, QVBoxLayout,
QWidget,
) )
logger = logging.getLogger(__name__) 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): class ExtrudeDialog(QDialog):
"""Dialog for extrude options. """Dialog for extrude options.
@@ -43,7 +33,7 @@ class ExtrudeDialog(QDialog):
*None*) to the callback tells the host to clear the preview. *None*) to the callback tells the host to clear the preview.
""" """
def __init__(self, parent: Optional[QWidget] = None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Extrude Options") self.setWindowTitle("Extrude Options")
self.setMinimumWidth(320) self.setMinimumWidth(320)
@@ -81,19 +71,12 @@ class ExtrudeDialog(QDialog):
) )
layout.addWidget(self.through_all_checkbox) 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") self.rounded_checkbox = QCheckBox("Round Edges")
layout.addWidget(self.rounded_checkbox) layout.addWidget(self.rounded_checkbox)
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.Shape.HLine) line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken) line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line) layout.addWidget(line)
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -105,9 +88,9 @@ class ExtrudeDialog(QDialog):
button_layout.addWidget(cancel_button) button_layout.addWidget(cancel_button)
layout.addLayout(button_layout) layout.addLayout(button_layout)
# Live preview: recompute on every option change. Wire each widget # Live preview: recompute on every option change. Use a light-
# to its own signal by type — spinboxes emit ``valueChanged``, # weight guard so we don't emit before the host has wired up the
# checkboxes emit ``stateChanged``. # callback.
for w in ( for w in (
self.length_input, self.length_input,
self.symmetric_checkbox, self.symmetric_checkbox,
@@ -115,21 +98,29 @@ class ExtrudeDialog(QDialog):
self.cut_checkbox, self.cut_checkbox,
self.union_checkbox, self.union_checkbox,
self.through_all_checkbox, self.through_all_checkbox,
self.cut_all_bodies_checkbox,
self.rounded_checkbox, self.rounded_checkbox,
): ):
if isinstance(w, QDoubleSpinBox): # 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:
w.valueChanged.connect(self._emit_preview) w.valueChanged.connect(self._emit_preview)
else: except AttributeError:
pass
try:
w.stateChanged.connect(self._emit_preview) w.stateChanged.connect(self._emit_preview)
except AttributeError:
pass
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: def set_preview_callback(self, callback) -> None:
"""Install the live-preview callback (or *None* to disable).""" """Install the live-preview callback (or *None* to disable)."""
self._preview_callback = callback self._preview_callback = callback
# Emit once so the initial state shows a preview right away. # Emit once so the initial state shows a preview right away.
self._emit_preview() self._emit_preview()
def _emit_preview(self, *args: Any) -> None: def _emit_preview(self, *args) -> None:
if self._preview_callback is None: if self._preview_callback is None:
return return
try: try:
@@ -137,7 +128,7 @@ class ExtrudeDialog(QDialog):
except Exception as exc: # preview must never break the dialog except Exception as exc: # preview must never break the dialog
logger.debug("extrude preview callback raised: %s", exc) logger.debug("extrude preview callback raised: %s", exc)
def hideEvent(self, event: Any) -> None: def hideEvent(self, event):
# Tell the host to clear the preview when the dialog goes away # Tell the host to clear the preview when the dialog goes away
# (accept, reject, or close). The host is responsible for the # (accept, reject, or close). The host is responsible for the
# actual viewer cleanup. # actual viewer cleanup.
@@ -148,7 +139,7 @@ class ExtrudeDialog(QDialog):
pass pass
super().hideEvent(event) super().hideEvent(event)
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool, bool]: def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
return ( return (
self.length_input.value(), self.length_input.value(),
self.symmetric_checkbox.isChecked(), self.symmetric_checkbox.isChecked(),
@@ -156,7 +147,6 @@ class ExtrudeDialog(QDialog):
self.cut_checkbox.isChecked(), self.cut_checkbox.isChecked(),
self.union_checkbox.isChecked(), self.union_checkbox.isChecked(),
self.through_all_checkbox.isChecked(), self.through_all_checkbox.isChecked(),
self.cut_all_bodies_checkbox.isChecked(),
self.rounded_checkbox.isChecked(), self.rounded_checkbox.isChecked(),
) )
@@ -164,7 +154,7 @@ class ExtrudeDialog(QDialog):
class RevolveDialog(QDialog): class RevolveDialog(QDialog):
"""Dialog for revolve options.""" """Dialog for revolve options."""
def __init__(self, parent: Optional[QWidget] = None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Revolve Options") self.setWindowTitle("Revolve Options")
self.setMinimumWidth(300) self.setMinimumWidth(300)
@@ -182,8 +172,8 @@ class RevolveDialog(QDialog):
layout.addLayout(angle_layout) layout.addLayout(angle_layout)
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.Shape.HLine) line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken) line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line) layout.addWidget(line)
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -204,7 +194,7 @@ class OffsetDialog(QDialog):
time. On accept the caller retrieves ``get_values()`` distance. time. On accept the caller retrieves ``get_values()`` distance.
""" """
def __init__(self, parent: Optional[QWidget] = None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Offset Sketch") self.setWindowTitle("Offset Sketch")
self.setMinimumWidth(300) self.setMinimumWidth(300)
@@ -228,8 +218,8 @@ class OffsetDialog(QDialog):
layout.addWidget(self.inward_checkbox) layout.addWidget(self.inward_checkbox)
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.Shape.HLine) line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken) line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line) layout.addWidget(line)
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -245,12 +235,12 @@ class OffsetDialog(QDialog):
self.distance_input.valueChanged.connect(self._emit_preview) self.distance_input.valueChanged.connect(self._emit_preview)
self.inward_checkbox.stateChanged.connect(self._emit_preview) self.inward_checkbox.stateChanged.connect(self._emit_preview)
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: def set_preview_callback(self, callback) -> None:
"""Install the live-preview callback (or *None* to disable).""" """Install the live-preview callback (or *None* to disable)."""
self._preview_callback = callback self._preview_callback = callback
self._emit_preview() self._emit_preview()
def _emit_preview(self, *args: Any) -> None: def _emit_preview(self, *args) -> None:
if self._preview_callback is None: if self._preview_callback is None:
return return
try: try:
@@ -258,7 +248,7 @@ class OffsetDialog(QDialog):
except Exception as exc: except Exception as exc:
logger.debug("offset preview callback raised: %s", exc) logger.debug("offset preview callback raised: %s", exc)
def hideEvent(self, event: Any) -> None: def hideEvent(self, event):
if self._preview_callback is not None: if self._preview_callback is not None:
try: try:
self._preview_callback(None) self._preview_callback(None)
@@ -270,6 +260,95 @@ class OffsetDialog(QDialog):
return (self.distance_input.value(), self.inward_checkbox.isChecked()) return (self.distance_input.value(), self.inward_checkbox.isChecked())
class SurfaceModifierDialog(QDialog):
"""Dialog for surface modifier settings."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Surface Modifier")
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
# Pattern type selection
pattern_layout = QGridLayout()
self._pattern_group = QButtonGroup(self)
patterns = [
("Pyramid Pattern", "pyramid"),
("Bump Pattern", "bump"),
]
for idx, (label, pattern_type) in enumerate(patterns):
btn = QRadioButton(label)
btn.setChecked(idx == 0)
self._pattern_group.addButton(btn, idx)
btn.pattern_type = pattern_type
pattern_layout.addWidget(btn, 0, idx)
layout.addLayout(pattern_layout)
# Parameters for pyramid/bump patterns
param_layout = QFormLayout()
self.height_input = QDoubleSpinBox()
self.height_input.setDecimals(2)
self.height_input.setRange(0.1, 50.0)
self.height_input.setValue(2.0)
self.height_input.setSuffix(" mm")
param_layout.addRow("Height:", self.height_input)
self.radius_input = QDoubleSpinBox()
self.radius_input.setDecimals(2)
self.radius_input.setRange(0.5, 20.0)
self.radius_input.setValue(3.0)
self.radius_input.setSuffix(" mm")
param_layout.addRow("Base Radius:", self.radius_input)
self.spacing_input = QDoubleSpinBox()
self.spacing_input.setDecimals(1)
self.spacing_input.setRange(1.0, 50.0)
self.spacing_input.setValue(8.0)
self.spacing_input.setSuffix(" mm")
param_layout.addRow("Spacing:", self.spacing_input)
layout.addLayout(param_layout)
# Number of rings
self.rings_input = QSpinBox()
self.rings_input.setMinimum(1)
self.rings_input.setMaximum(20)
self.rings_input.setValue(3)
param_layout.addRow("Number of Rings:", self.rings_input)
line = QFrame()
line.setFrameShape(QFrame.HLine)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("Apply")
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)
def get_values(self) -> dict:
"""Return the modifier settings."""
pattern_type = None
for btn in self._pattern_group.buttons():
if btn.isChecked():
pattern_type = getattr(btn, 'pattern_type', None)
break
return {
'type': pattern_type or 'pyramid',
'height': self.height_input.value(),
'radius': self.radius_input.value(),
'spacing': self.spacing_input.value(),
'num_rings': self.rings_input.value(),
}
class WorkplaneOrientationDialog(QDialog): class WorkplaneOrientationDialog(QDialog):
"""Modal dialog to choose the orientation of a new workplane. """Modal dialog to choose the orientation of a new workplane.
@@ -278,7 +357,7 @@ class WorkplaneOrientationDialog(QDialog):
returns (normal, x_dir) pair (both as 3-tuples). returns (normal, x_dir) pair (both as 3-tuples).
""" """
def __init__(self, parent: Optional[QWidget] = None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("New Workplane Orientation") self.setWindowTitle("New Workplane Orientation")
self.setMinimumWidth(320) self.setMinimumWidth(320)
@@ -298,12 +377,6 @@ class WorkplaneOrientationDialog(QDialog):
layout.addWidget(lbl) layout.addWidget(lbl)
self._preset_group = QButtonGroup(self) 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() preset_layout = QGridLayout()
presets = [ presets = [
("XY (Top)", (0, 0, 1), (1, 0, 0)), ("XY (Top)", (0, 0, 1), (1, 0, 0)),
@@ -317,14 +390,15 @@ class WorkplaneOrientationDialog(QDialog):
btn = QRadioButton(label) btn = QRadioButton(label)
btn.setChecked(idx == 0) btn.setChecked(idx == 0)
self._preset_group.addButton(btn, idx) self._preset_group.addButton(btn, idx)
self._preset_vectors[idx] = (_vec3(normal), _vec3(x_dir)) btn.normal = normal
btn.x_dir = x_dir
preset_layout.addWidget(btn, idx // 2, idx % 2) preset_layout.addWidget(btn, idx // 2, idx % 2)
layout.addLayout(preset_layout) layout.addLayout(preset_layout)
# ── Custom angle (offset from XY) ── # ── Custom angle (offset from XY) ──
line = QFrame() line = QFrame()
line.setFrameShape(QFrame.Shape.HLine) line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken) line.setFrameShadow(QFrame.Sunken)
layout.addWidget(line) layout.addWidget(line)
self._custom_radio = QRadioButton("Custom (angle from XY):") self._custom_radio = QRadioButton("Custom (angle from XY):")
@@ -357,8 +431,8 @@ class WorkplaneOrientationDialog(QDialog):
# ── Buttons ── # ── Buttons ──
line2 = QFrame() line2 = QFrame()
line2.setFrameShape(QFrame.Shape.HLine) line2.setFrameShape(QFrame.HLine)
line2.setFrameShadow(QFrame.Shadow.Sunken) line2.setFrameShadow(QFrame.Sunken)
layout.addWidget(line2) layout.addWidget(line2)
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -377,7 +451,7 @@ class WorkplaneOrientationDialog(QDialog):
self._angle_x.valueChanged.connect(self._emit_preview) self._angle_x.valueChanged.connect(self._emit_preview)
self._angle_y.valueChanged.connect(self._emit_preview) self._angle_y.valueChanged.connect(self._emit_preview)
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: def set_preview_callback(self, callback) -> None:
"""Install a callback for live 3D preview of the workplane orientation. """Install a callback for live 3D preview of the workplane orientation.
*callback* is called with ``(normal, x_dir)`` whenever the user *callback* is called with ``(normal, x_dir)`` whenever the user
@@ -387,7 +461,7 @@ class WorkplaneOrientationDialog(QDialog):
# Emit once so the initial state shows a preview right away. # Emit once so the initial state shows a preview right away.
self._emit_preview() self._emit_preview()
def _emit_preview(self, *args: Any) -> None: def _emit_preview(self, *args) -> None:
"""Call the preview callback with the current orientation, if installed.""" """Call the preview callback with the current orientation, if installed."""
if self._preview_callback is None: if self._preview_callback is None:
return return
@@ -397,7 +471,7 @@ class WorkplaneOrientationDialog(QDialog):
except Exception as exc: except Exception as exc:
logger.debug("workplane preview callback raised: %s", exc) logger.debug("workplane preview callback raised: %s", exc)
def hideEvent(self, event: Any) -> None: def hideEvent(self, event):
"""Clear the live preview when the dialog closes.""" """Clear the live preview when the dialog closes."""
if self._preview_callback is not None: if self._preview_callback is not None:
try: try:
@@ -406,52 +480,39 @@ class WorkplaneOrientationDialog(QDialog):
pass pass
super().hideEvent(event) super().hideEvent(event)
def _on_preset_changed(self, btn: Any) -> None: def _on_preset_changed(self, btn):
"""When a preset is selected, deselect the custom radio and emit preview.""" """When a preset is selected, deselect the custom radio and emit preview."""
self._custom_radio.setChecked(False) self._custom_radio.setChecked(False)
self._emit_preview() self._emit_preview()
def _compute_custom_orientation( def _on_ok(self):
self, """Compute the final orientation and accept."""
) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]:
"""Compute ``(normal, x_dir)`` from the custom angle spinboxes.
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 import numpy as np
try: if self._custom_radio.isChecked():
# Custom: start from XY normal and rotate by the two angles.
ax = math.radians(self._angle_x.value()) ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value()) ay = math.radians(self._angle_y.value())
# Start from +Z normal, rotate around X then Y # Start from +Z normal, rotate around X then Y
n = np.array([0.0, 0.0, 1.0]) n = np.array([0.0, 0.0, 1.0])
# Rotate around X # Rotate around X
rx = np.array( rx = np.array([
[
[1, 0, 0], [1, 0, 0],
[0, math.cos(ax), -math.sin(ax)], [0, math.cos(ax), -math.sin(ax)],
[0, math.sin(ax), math.cos(ax)], [0, math.sin(ax), math.cos(ax)],
] ])
)
n = rx @ n n = rx @ n
# Rotate around Y # Rotate around Y
ry = np.array( ry = np.array([
[
[math.cos(ay), 0, math.sin(ay)], [math.cos(ay), 0, math.sin(ay)],
[0, 1, 0], [0, 1, 0],
[-math.sin(ay), 0, math.cos(ay)], [-math.sin(ay), 0, math.cos(ay)],
] ])
)
n = ry @ n n = ry @ n
n_norm = np.linalg.norm(n) n = n / 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 # 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]) world_y = np.array([0.0, 1.0, 0.0])
if abs(float(np.dot(n, world_y))) > 0.99: if abs(np.dot(n, world_y)) > 0.99:
world_y = np.array([0.0, 0.0, 1.0]) world_y = np.array([0.0, 0.0, 1.0])
x = np.cross(world_y, n) x = np.cross(world_y, n)
x_norm = np.linalg.norm(x) x_norm = np.linalg.norm(x)
@@ -459,24 +520,14 @@ class WorkplaneOrientationDialog(QDialog):
x = x / x_norm x = x / x_norm
else: else:
x = np.array([1.0, 0.0, 0.0]) x = np.array([1.0, 0.0, 0.0])
return ( self._normal = tuple(float(v) for v in n)
(float(n[0]), float(n[1]), float(n[2])), self._x_dir = tuple(float(v) for v in x)
(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: else:
btn = self._preset_group.checkedButton() btn = self._preset_group.checkedButton()
if btn is not None: if btn is not None:
self._normal, self._x_dir = self._preset_vectors[self._preset_group.id(btn)] self._normal = btn.normal
self._x_dir = btn.x_dir
self.accept() self.accept()
def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]: def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]:
@@ -485,171 +536,44 @@ class WorkplaneOrientationDialog(QDialog):
Computes the current selection from the UI state so it works Computes the current selection from the UI state so it works
whether called before or after ``_on_ok``. whether called before or after ``_on_ok``.
""" """
import numpy as np
if self._custom_radio.isChecked(): if self._custom_radio.isChecked():
orientation = self._compute_custom_orientation() ax = math.radians(self._angle_x.value())
if orientation is None: ay = math.radians(self._angle_y.value())
orientation = ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0)) n = np.array([0.0, 0.0, 1.0])
normal, x_dir = orientation 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:
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])
return ( return (
normal, tuple(float(v) for v in n),
x_dir, tuple(float(v) for v in x),
self._name_input.text().strip() or "Workplane", self._name_input.text().strip() or "Workplane",
) )
else: else:
btn = self._preset_group.checkedButton() btn = self._preset_group.checkedButton()
if btn is not None: if btn is not None:
normal, x_dir = self._preset_vectors[self._preset_group.id(btn)] return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
return (normal, x_dir, self._name_input.text().strip() or "Workplane")
# Fallback: XY default. # Fallback: XY default.
return ( return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
(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
+23 -118
View File
@@ -20,13 +20,6 @@ class Viewer3DWidget(QWidget):
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck. # Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
pickFaceCancelled = Signal() 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). # 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). # Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
connectorPicked = Signal(tuple, tuple, tuple, str, object, str) connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
@@ -51,11 +44,11 @@ class Viewer3DWidget(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
# For OCC's direct OpenGL rendering we need Qt to not paint over it. # For OCC's direct OpenGL rendering we need Qt to not paint over it.
self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen) self.setAttribute(Qt.WA_PaintOnScreen)
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent) self.setAttribute(Qt.WA_OpaquePaintEvent)
self.setAutoFillBackground(False) self.setAutoFillBackground(False)
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work. # Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.StrongFocus)
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a # Enable mouse tracking so ``mouseMoveEvent`` fires even without a
# button held — required for the connector-pick hover gizmo (and any # button held — required for the connector-pick hover gizmo (and any
# status-bar hover feedback) to show under the cursor as the user # status-bar hover feedback) to show under the cursor as the user
@@ -71,8 +64,6 @@ class Viewer3DWidget(QWidget):
# When True, a left-click picks a planar face (for sketch-on-surface) # When True, a left-click picks a planar face (for sketch-on-surface)
# instead of orbiting the camera. Set via set_pick_face_mode(). # instead of orbiting the camera. Set via set_pick_face_mode().
self._pick_face_mode: bool = False 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 # When True, a left-click picks an entity for a connector point
# (assembly component connection). # (assembly component connection).
self._connector_pick_mode: bool = False self._connector_pick_mode: bool = False
@@ -321,19 +312,15 @@ class Viewer3DWidget(QWidget):
def mousePressEvent(self, event): def mousePressEvent(self, event):
self._ensure_initialized() self._ensure_initialized()
# Face-pick mode: a left-click selects a planar face to sketch on. # Face-pick mode: a left-click selects a planar face to sketch on.
if self._pick_face_mode and event.button() == Qt.MouseButton.LeftButton: if self._pick_face_mode and event.button() == Qt.LeftButton:
self._handle_face_pick(event) self._handle_face_pick(event)
return 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. # Connector pick mode: a left-click selects a face for a connection point.
if self._connector_pick_mode and event.button() == Qt.MouseButton.LeftButton: if self._connector_pick_mode and event.button() == Qt.LeftButton:
self._handle_connector_pick(event) self._handle_connector_pick(event)
return return
# Assembly move mode: start dragging the clicked body. # Assembly move mode: start dragging the clicked body.
if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton: if self._assembly_move_mode and event.button() == Qt.LeftButton:
self._handle_assembly_move_press(event) self._handle_assembly_move_press(event)
return return
self._renderer.handle_mouse_press(event) self._renderer.handle_mouse_press(event)
@@ -359,12 +346,6 @@ class Viewer3DWidget(QWidget):
self._renderer.handle_mouse_move(event) self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
return 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. # Active drag in assembly move mode.
if self._move_drag_active: if self._move_drag_active:
self._handle_assembly_move_move(event) self._handle_assembly_move_move(event)
@@ -373,7 +354,7 @@ class Viewer3DWidget(QWidget):
self._renderer.handle_mouse_move(event) self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
def paintEngine(self) -> Any: def paintEngine(self):
"""Return None to prevent Qt from painting over OCC's direct OpenGL.""" """Return None to prevent Qt from painting over OCC's direct OpenGL."""
return None return None
@@ -466,75 +447,13 @@ class Viewer3DWidget(QWidget):
""" """
self._pick_face_mode = bool(enabled) self._pick_face_mode = bool(enabled)
if enabled: if enabled:
self.setCursor(Qt.CursorShape.CrossCursor) self.setCursor(Qt.CrossCursor)
else: else:
self.unsetCursor() self.unsetCursor()
def is_pick_face_mode(self) -> bool: def is_pick_face_mode(self) -> bool:
return self._pick_face_mode 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: def highlight_face(self, face: Any) -> None:
"""Tint the picked face light-blue/transparent in the 3D viewer.""" """Tint the picked face light-blue/transparent in the 3D viewer."""
self._ensure_initialized() self._ensure_initialized()
@@ -570,7 +489,7 @@ class Viewer3DWidget(QWidget):
""" """
self._connector_pick_mode = bool(enabled) self._connector_pick_mode = bool(enabled)
if enabled: if enabled:
self.setCursor(Qt.CursorShape.CrossCursor) self.setCursor(Qt.CrossCursor)
# Disable standard OCC selection so gizmo visuals are not # Disable standard OCC selection so gizmo visuals are not
# interfered with by dynamic face highlighting. # interfered with by dynamic face highlighting.
fn = getattr(self._renderer, "deactivate_selection_modes", None) fn = getattr(self._renderer, "deactivate_selection_modes", None)
@@ -795,7 +714,7 @@ class Viewer3DWidget(QWidget):
""" """
self._assembly_move_mode = bool(enabled) self._assembly_move_mode = bool(enabled)
if enabled: if enabled:
self.setCursor(Qt.CursorShape.SizeAllCursor) self.setCursor(Qt.SizeAllCursor)
elif not self._pick_face_mode and not self._connector_pick_mode: elif not self._pick_face_mode and not self._connector_pick_mode:
self.unsetCursor() self.unsetCursor()
if not enabled: if not enabled:
@@ -869,29 +788,20 @@ class Viewer3DWidget(QWidget):
# Compute world-space delta. # Compute world-space delta.
modifiers = event.modifiers() modifiers = event.modifiers()
if modifiers & Qt.KeyboardModifier.ShiftModifier: if modifiers & Qt.ShiftModifier:
# Shift+drag: move along camera direction (Z-depth). # Shift+drag: move along camera direction (Z-depth).
dz_world = dx * world_per_pixel dz_world = dx * world_per_pixel
dx_world = 0.0 dx_world = 0.0
dy_world = 0.0 dy_world = 0.0
else: else:
# Normal drag: move in view plane. # Normal drag: move in view plane.
try: dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel)
dx_world = float( dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel)
cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * 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) self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
def _handle_assembly_move_release(self, event: Any) -> None: def _handle_assembly_move_release(self, event) -> None:
"""Finish the drag, emit final position.""" """Finish the drag, emit final position."""
self.assemblyMoveFinished.emit(self._move_owner_obj_id) self.assemblyMoveFinished.emit(self._move_owner_obj_id)
self._move_drag_active = False self._move_drag_active = False
@@ -901,7 +811,7 @@ class Viewer3DWidget(QWidget):
self._move_plane_normal = None self._move_plane_normal = None
self._move_initial_position = None self._move_initial_position = None
def _handle_face_pick(self, event: Any) -> None: def _handle_face_pick(self, event) -> None:
"""Detect a planar face under the click and emit facePicked.""" """Detect a planar face under the click and emit facePicked."""
self._ensure_initialized() self._ensure_initialized()
picker = getattr(self._renderer, "pick_planar_face", None) picker = getattr(self._renderer, "pick_planar_face", None)
@@ -924,7 +834,7 @@ class Viewer3DWidget(QWidget):
info["face"], info["face"],
) )
def set_view(self, view: str) -> None: def set_view(self, view: str):
# Prefer the renderer's native orientation snap (preserves target, # Prefer the renderer's native orientation snap (preserves target,
# refits the scene). Falls back to absolute eye positions for # refits the scene). Falls back to absolute eye positions for
# renderers that don't implement set_view_orientation. # renderers that don't implement set_view_orientation.
@@ -946,31 +856,26 @@ class Viewer3DWidget(QWidget):
pos, target = positions[view] pos, target = positions[view]
self.set_camera_position(pos, target) self.set_camera_position(pos, target)
def mouseDoubleClickEvent(self, event: Any) -> None: def mouseDoubleClickEvent(self, event):
# Double-click → fit all (common CAD convention). # Double-click → fit all (common CAD convention).
self._ensure_initialized() self._ensure_initialized()
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.LeftButton:
self.fit_camera() self.fit_camera()
super().mouseDoubleClickEvent(event) super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event: Any) -> None: def keyPressEvent(self, event):
# Esc cancels face-pick mode. # Esc cancels face-pick mode.
if self._pick_face_mode and event.key() == Qt.Key.Key_Escape: if self._pick_face_mode and event.key() == Qt.Key_Escape:
self.set_pick_face_mode(False) self.set_pick_face_mode(False)
self.pickFaceCancelled.emit() self.pickFaceCancelled.emit()
return 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. # Esc cancels connector pick mode.
if self._connector_pick_mode and event.key() == Qt.Key.Key_Escape: if self._connector_pick_mode and event.key() == Qt.Key_Escape:
self.set_connector_pick_mode(False) self.set_connector_pick_mode(False)
self.connectorPickCancelled.emit() self.connectorPickCancelled.emit()
return return
# Esc cancels assembly move mode. # Esc cancels assembly move mode.
if self._assembly_move_mode and event.key() == Qt.Key.Key_Escape: if self._assembly_move_mode and event.key() == Qt.Key_Escape:
self.set_assembly_move_mode(False) self.set_assembly_move_mode(False)
return return
# Navigation shortcuts (lowercase = view presets, F = fit, # Navigation shortcuts (lowercase = view presets, F = fit,
+115 -130
View File
@@ -1,6 +1,7 @@
"""Tests for Fluency CAD geometry kernel.""" """Tests for Fluency CAD geometry kernel."""
import pytest import pytest
import numpy as np
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry_occ.sketch import OCCSketch
@@ -194,14 +195,10 @@ class TestOCCSketch:
sk = OCCSketch() sk = OCCSketch()
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir) sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
# 20x20 square in UV # 20x20 square in UV
p0 = sk.add_point(-10, -10) p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10)
p1 = sk.add_point(10, -10) p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10)
p2 = sk.add_point(10, 10) sk.add_line(p0, p1); sk.add_line(p1, p2)
p3 = sk.add_point(-10, 10) sk.add_line(p2, p3); sk.add_line(p3, p0)
sk.add_line(p0, p1)
sk.add_line(p1, p2)
sk.add_line(p2, p3)
sk.add_line(p3, p0)
geom = sk.get_geometry() geom = sk.get_geometry()
# The face must carry the plane normal for the kernel. # The face must carry the plane normal for the kernel.
@@ -223,14 +220,10 @@ class TestOCCSketch:
sk = OCCSketch() sk = OCCSketch()
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0)) sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
a = sk.add_point(-10, -10) a = sk.add_point(-10, -10); b = sk.add_point(10, -10)
b = sk.add_point(10, -10) c = sk.add_point(10, 10); d = sk.add_point(-10, 10)
c = sk.add_point(10, 10) sk.add_line(a, b); sk.add_line(b, c)
d = sk.add_point(-10, 10) sk.add_line(c, d); sk.add_line(d, a)
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) ctr = sk.add_point(0, 0)
sk.add_circle(ctr, 3.0) sk.add_circle(ctr, 3.0)
@@ -297,14 +290,10 @@ class TestExternalEntities:
# Underlay: a 20x20 square projected from a face (closed polyline). # Underlay: a 20x20 square projected from a face (closed polyline).
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)]) 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. # User profile: a 5x5 square — this is what should be extruded.
a = sk.add_point(2, 2) a = sk.add_point(2, 2); b = sk.add_point(8, 2)
b = sk.add_point(8, 2) c = sk.add_point(8, 8); d = sk.add_point(2, 8)
c = sk.add_point(8, 8) sk.add_line(a, b); sk.add_line(b, c)
d = sk.add_point(2, 8) sk.add_line(c, d); sk.add_line(d, a)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
faces = sk.detect_faces() faces = sk.detect_faces()
# Only the user-drawn face (5x5 square) should be detected. # Only the user-drawn face (5x5 square) should be detected.
assert len(faces) == 1 assert len(faces) == 1
@@ -321,14 +310,10 @@ class TestExternalEntities:
def test_external_entities_excluded_from_get_polygon_points(self): def test_external_entities_excluded_from_get_polygon_points(self):
sk = OCCSketch() sk = OCCSketch()
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)]) sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
a = sk.add_point(1, 1) a = sk.add_point(1, 1); b = sk.add_point(2, 1)
b = sk.add_point(2, 1) c = sk.add_point(2, 2); d = sk.add_point(1, 2)
c = sk.add_point(2, 2) sk.add_line(a, b); sk.add_line(b, c)
d = sk.add_point(1, 2) sk.add_line(c, d); sk.add_line(d, a)
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() poly = sk.get_polygon_points()
# The user square (1..2 range) should appear, not the 0..100 underlay. # 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) assert all(1.0 <= p.x <= 2.0 for p in poly)
@@ -343,14 +328,10 @@ class TestExternalEntities:
# Underlay (NOT to be extruded). # Underlay (NOT to be extruded).
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]) sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
# User profile: a 2x2 square inside the underlay. # User profile: a 2x2 square inside the underlay.
a = sk.add_point(1, 1) a = sk.add_point(1, 1); b = sk.add_point(3, 1)
b = sk.add_point(3, 1) c = sk.add_point(3, 3); d = sk.add_point(1, 3)
c = sk.add_point(3, 3) sk.add_line(a, b); sk.add_line(b, c)
d = sk.add_point(1, 3) sk.add_line(c, d); sk.add_line(d, a)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
geom = sk.get_geometry() geom = sk.get_geometry()
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400. # Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
kernel = OCGeometryKernel() kernel = OCGeometryKernel()
@@ -517,9 +498,10 @@ class TestExtrudeCutFix:
and the tool is no longer needed. and the tool is no longer needed.
""" """
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCGeometryKernel from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from OCP.GProp import GProp_GProps from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp from OCP.BRepGProp import BRepGProp
import math
k = OCGeometryKernel() k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape() target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -529,18 +511,17 @@ class TestExtrudeCutFix:
# expected volume easy to compute. # expected volume easy to compute.
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.gp import gp_Pnt, gp_Vec from OCP.gp import gp_Pnt, gp_Vec
# 20x20 square at (0,0,0), extruded along +Z by 200. # 20x20 square at (0,0,0), extruded along +Z by 200.
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
mp = 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.Add(gp_Pnt(x, y, 0))
mp.Close() mp.Close()
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face() 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"}) tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
# Before cut: target is 100^3 = 1_000_000. # Before cut: target is 100^3 = 1_000_000.
@@ -555,7 +536,9 @@ class TestExtrudeCutFix:
# After cut: target is 1_000_000 - 20*20*100 = 960_000 # 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). # (the prism only intersects the box in z=[0,100], i.e. 100 deep).
g1 = GProp_GProps() 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 assert abs(g1.Mass() - 960_000.0) < 1.0
def test_boolean_difference_does_not_leave_separate_cavity_body(self): def test_boolean_difference_does_not_leave_separate_cavity_body(self):
@@ -568,9 +551,10 @@ class TestExtrudeCutFix:
target, so a single body remains. target, so a single body remains.
""" """
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCP.TopExp import TopExp_Explorer from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_SOLID from OCP.TopAbs import TopAbs_SOLID
from fluency.geometry_occ.kernel import OCGeometryKernel from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
k = OCGeometryKernel() k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape() target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -578,7 +562,6 @@ class TestExtrudeCutFix:
# Tool: small box at the centre, fully inside the target. # Tool: small box at the centre, fully inside the target.
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
tool_shape = BBox(20, 20, 20).Shape() tool_shape = BBox(20, 20, 20).Shape()
tool_obj = OCCGeometryObject(tool_shape, {}) tool_obj = OCCGeometryObject(tool_shape, {})
@@ -612,59 +595,91 @@ class TestBodyVisibilityToggle:
def _make_window(self): def _make_window(self):
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow from fluency.main import MainWindow
return MainWindow() return MainWindow()
def test_body_list_uses_checkable_items(self): def test_body_list_uses_checkable_items(self):
"""Each body list item has a data role for the toggle handler.""" """Each body list item must be a checkable QListWidgetItem."""
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
win = self._make_window() win = self._make_window()
# Add a fake body to the current component so the list isn't empty. # Add a fake body to the current component so the list isn't empty.
from fluency.models.data_model import Body from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox 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._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists() win._refresh_lists()
items = win._body_list.findItems("A", Qt.MatchExactly) items = win._body_list.findItems("A", Qt.MatchExactly)
assert len(items) == 1 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. # And the body id is stored on the item for the toggle handler.
assert items[0].data(Qt.UserRole) == "a" assert items[0].data(Qt.UserRole) == "a"
# Default state is visible. # Default state is checked (= visible).
assert win._current_component.bodies["a"].visible is True assert items[0].checkState() == Qt.Checked
def test_toggling_visibility_updates_body_model(self): def test_toggling_visibility_updates_body_model(self):
"""Toggling visibility via _on_body_visibility_changed updates the model.""" """Flipping the checkbox should set body.visible accordingly."""
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
win = self._make_window() win = self._make_window()
from fluency.models.data_model import Body from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox 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._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists() win._refresh_lists()
item = win._body_list.findItems("A", Qt.MatchExactly)[0] item = win._body_list.findItems("A", Qt.MatchExactly)[0]
# Toggle off. # Toggle off.
item.setCheckState(Qt.Unchecked)
win._on_body_visibility_changed(item) win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is False assert win._current_component.bodies["a"].visible is False
# Toggle back on. # Toggle back on.
item.setCheckState(Qt.Checked)
win._on_body_visibility_changed(item) win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is True 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): def math_hypot(x, y):
import math import math
return math.hypot(x, y) return math.hypot(x, y)
@@ -683,13 +698,10 @@ class TestConstraintTagRendering:
def _make_widget_with_sketch(self, sk): def _make_widget_with_sketch(self, sk):
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it.""" """Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import Sketch2DWidget from fluency.main import Sketch2DWidget
w = Sketch2DWidget() w = Sketch2DWidget()
w.set_sketch(sk) w.set_sketch(sk)
return w return w
@@ -812,7 +824,6 @@ class TestConstraintTagRendering:
class _BadRound: class _BadRound:
def __round__(self, ndigits=0): def __round__(self, ndigits=0):
raise TypeError("cannot round") raise TypeError("cannot round")
sk._entities[c.id].geometry = (_BadRound(), _BadRound()) sk._entities[c.id].geometry = (_BadRound(), _BadRound())
tags = w._compute_constraint_tags() tags = w._compute_constraint_tags()
assert all(t["center"] is not None for t in tags) assert all(t["center"] is not None for t in tags)
@@ -854,13 +865,12 @@ class TestExtrudeRedesign:
def _make_window_with_box(self, box_side=100.0): def _make_window_with_box(self, box_side=100.0):
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body from fluency.models.data_model import Sketch, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
@@ -886,6 +896,7 @@ class TestExtrudeRedesign:
return win, sketch, sk, box_obj return win, sketch, sk, box_obj
def _add_circle(self, sk, r=10.0): def _add_circle(self, sk, r=10.0):
from fluency.geometry_occ.sketch import OCCSketch
c = sk.add_point(0, 0) c = sk.add_point(0, 0)
sk.add_circle(c, r) sk.add_circle(c, r)
sk.solve() sk.solve()
@@ -894,7 +905,6 @@ class TestExtrudeRedesign:
def _geometry_volume(self, win, geom): def _geometry_volume(self, win, geom):
from OCP.GProp import GProp_GProps from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp from OCP.BRepGProp import BRepGProp
sh = win._kernel._get_shape(geom) sh = win._kernel._get_shape(geom)
g = GProp_GProps() g = GProp_GProps()
BRepGProp.VolumeProperties_s(sh, g) BRepGProp.VolumeProperties_s(sh, g)
@@ -909,48 +919,37 @@ class TestExtrudeRedesign:
so a 5 mm cut makes a real 5 mm-deep pocket. so a 5 mm cut makes a real 5 mm-deep pocket.
""" """
import math import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0) win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0) face_geom = self._add_circle(sk, r=10.0)
# Plain cut, length=5, NOT inverted. Pre-redesign this would have # Plain cut, length=5, NOT inverted. Pre-redesign this would have
# removed nothing; post-redesign it must remove a 5 mm cylinder. # removed nothing; post-redesign it must remove a 5 mm cylinder.
result = win._compute_extrude_result( result = win._compute_extrude_result(
sketch, sketch, face_geom,
face_geom, length=5.0, symmetric=False, invert=False,
length=5.0, cut=True, union=False, through_all=False,
symmetric=False,
invert=False,
cut=True,
union=False,
through_all=False,
) )
assert result is not None assert result is not None
assert result["target_body"] is not None assert result["target_body"] is not None
assert result["target_body"].name == "Box1" assert result["target_body"].name == "Box1"
vol = self._geometry_volume(win, result["result_geom"]) vol = self._geometry_volume(win, result["result_geom"])
expected = 100.0**3 - math.pi * (10.0**2) * 5.0 expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 5.0
assert abs(vol - expected) < 1.0 assert abs(vol - expected) < 1.0
def test_cut_through_all_passes_through(self): def test_cut_through_all_passes_through(self):
""" "Through All" cut fully passes through the body.""" """"Through All" cut fully passes through the body."""
import math import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0) win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0) face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result( result = win._compute_extrude_result(
sketch, sketch, face_geom,
face_geom,
length=5.0, # ignored when through_all length=5.0, # ignored when through_all
symmetric=False, symmetric=False, invert=False,
invert=False, cut=True, union=False, through_all=True,
cut=True,
union=False,
through_all=True,
) )
assert result is not None assert result is not None
vol = self._geometry_volume(win, result["result_geom"]) vol = self._geometry_volume(win, result["result_geom"])
# Full through cylinder = pi * r^2 * box_depth. # Full through cylinder = pi * r^2 * box_depth.
expected = 100.0**3 - math.pi * (10.0**2) * 100.0 expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
assert abs(vol - expected) < 1.0 assert abs(vol - expected) < 1.0
def test_cut_auto_targets_source_body_not_existing_zero(self): def test_cut_auto_targets_source_body_not_existing_zero(self):
@@ -961,23 +960,30 @@ class TestExtrudeRedesign:
""" """
import math import math
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body from fluency.models.data_model import Sketch, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
win = MainWindow() win = MainWindow()
# First body in the dict: a 50-millimetre box ALSO. # First body in the dict: a 50-millimetre box ALSO.
first = OCCGeometryObject(BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}) first = OCCGeometryObject(
win._current_component.bodies["first"] = Body(name="First", geometry=first) BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
)
win._current_component.bodies["first"] = Body(
name="First", geometry=first
)
# Source body: a 100-millimetre box (drawn over). # Source body: a 100-millimetre box (drawn over).
src = OCCGeometryObject(BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}) src = OCCGeometryObject(
win._current_component.bodies["src"] = Body(name="Src", geometry=src) 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). # Sketch circle on top of the SOURCE box (0,0 so normal +Z).
sk = OCCSketch() sk = OCCSketch()
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0)) sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
@@ -993,21 +999,16 @@ class TestExtrudeRedesign:
face_geom = sk.get_geometry() face_geom = sk.get_geometry()
result = win._compute_extrude_result( result = win._compute_extrude_result(
sketch, sketch, face_geom,
face_geom, length=5.0, symmetric=False, invert=False,
length=5.0, cut=True, union=False, through_all=True,
symmetric=False,
invert=False,
cut=True,
union=False,
through_all=True,
) )
assert result is not None assert result is not None
# Target is the source box, NOT the dict's first body. # Target is the source box, NOT the dict's first body.
assert result["target_body"].name == "Src" assert result["target_body"].name == "Src"
vol = self._geometry_volume(win, result["result_geom"]) vol = self._geometry_volume(win, result["result_geom"])
# 100^3 - pi*100*100 (through-all full-depth cut on the 100 box). # 100^3 - pi*100*100 (through-all full-depth cut on the 100 box).
expected = 100.0**3 - math.pi * (10.0**2) * 100.0 expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
assert abs(vol - expected) < 1.0 assert abs(vol - expected) < 1.0
def test_union_default_builds_outward(self): def test_union_default_builds_outward(self):
@@ -1018,23 +1019,17 @@ class TestExtrudeRedesign:
rather than "subtracting" from the existing box. rather than "subtracting" from the existing box.
""" """
import math import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0) win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0) face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result( result = win._compute_extrude_result(
sketch, sketch, face_geom,
face_geom, length=10.0, symmetric=False, invert=False,
length=10.0, cut=False, union=True, through_all=False,
symmetric=False,
invert=False,
cut=False,
union=True,
through_all=False,
) )
assert result is not None assert result is not None
vol = self._geometry_volume(win, result["result_geom"]) vol = self._geometry_volume(win, result["result_geom"])
# 100^3 + pi*100*10 — material added on top. # 100^3 + pi*100*10 — material added on top.
expected = 100.0**3 + math.pi * (10.0**2) * 10.0 expected = 100.0 ** 3 + math.pi * (10.0 ** 2) * 10.0
assert abs(vol - expected) < 1.0 assert abs(vol - expected) < 1.0
def test_plain_extrude_untouched_by_source_body(self): def test_plain_extrude_untouched_by_source_body(self):
@@ -1042,14 +1037,9 @@ class TestExtrudeRedesign:
win, sketch, sk, box_obj = self._make_window_with_box(100.0) win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0) face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result( result = win._compute_extrude_result(
sketch, sketch, face_geom,
face_geom, length=10.0, symmetric=False, invert=False,
length=10.0, cut=False, union=False, through_all=False,
symmetric=False,
invert=False,
cut=False,
union=False,
through_all=False,
) )
assert result is not None assert result is not None
# No boolean target; result is the standalone tool extrusion. # No boolean target; result is the standalone tool extrusion.
@@ -1057,8 +1047,7 @@ class TestExtrudeRedesign:
vol = self._geometry_volume(win, result["result_geom"]) vol = self._geometry_volume(win, result["result_geom"])
# Standalone cylinder 10 mm tall. # Standalone cylinder 10 mm tall.
import math import math
assert abs(vol - math.pi * (10.0 ** 2) * 10.0) < 1.0
assert abs(vol - math.pi * (10.0**2) * 10.0) < 1.0
def test_freshly_picked_sketch_is_auto_selected(self): def test_freshly_picked_sketch_is_auto_selected(self):
"""After _on_face_picked, the new sketch is the current list row. """After _on_face_picked, the new sketch is the current list row.
@@ -1066,6 +1055,7 @@ class TestExtrudeRedesign:
The user should be able to click Extrude/Cut immediately without The user should be able to click Extrude/Cut immediately without
first hunting for the new sketch in the left list. 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) win, _, sk, box_obj = self._make_window_with_box(100.0)
# Simulate _on_face_picked by calling it through a fake face # Simulate _on_face_picked by calling it through a fake face
# shape — but the simplest behavioural check is to call the # shape — but the simplest behavioural check is to call the
@@ -1073,7 +1063,6 @@ class TestExtrudeRedesign:
# set as _current_sketch, and it appears (and is selected) in # set as _current_sketch, and it appears (and is selected) in
# the list after _refresh_lists + setCurrentRow. # the list after _refresh_lists + setCurrentRow.
from fluency.models.data_model import Sketch from fluency.models.data_model import Sketch
sketch = Sketch(name="Sketch on face 99") sketch = Sketch(name="Sketch on face 99")
sketch._source_body_id = "b1" sketch._source_body_id = "b1"
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0)) sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
@@ -1095,10 +1084,8 @@ class TestExtrudeRedesign:
def test_preview_callback_invoked_on_value_change(self): def test_preview_callback_invoked_on_value_change(self):
"""The live preview callback fires on spinbox/checkbox changes.""" """The live preview callback fires on spinbox/checkbox changes."""
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog from fluency.main import ExtrudeDialog
@@ -1123,10 +1110,8 @@ class TestExtrudeRedesign:
def test_preview_hidden_event_sends_none(self): def test_preview_hidden_event_sends_none(self):
"""hideEvent should deliver None to the callback so the host clears.""" """hideEvent should deliver None to the callback so the host clears."""
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog from fluency.main import ExtrudeDialog