5 Commits

Author SHA1 Message Date
bklronin 1e9b0cab1c - arc improvements, fillets, operations, bodys 2026-08-05 16:47:25 +02:00
bklronin a7e7a9f2c3 - arc improvements, fillets, operations, bodys 2026-08-05 09:44:17 +02:00
bklronin 48042659fc - arc improvements, fillets, operations, bodys 2026-08-02 22:04:57 +02:00
bklronin baa2fd5d47 - added "measurement lines" 2026-08-02 20:58:43 +02:00
bklronin 0daa6152ee - added "measurement lines" 2026-07-26 21:39:47 +02:00
17 changed files with 9926 additions and 2408 deletions
+87 -47
View File
@@ -4,16 +4,22 @@
<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="- Render improvements, camera plane, update"> <list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys">
<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" 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$/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/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.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/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/main.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/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/tests/test_project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/tests/test_project_io.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/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.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" />
@@ -30,7 +36,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="single_window" /> <entry key="$PROJECT_DIR$" value="feature/surface-modifier-pyramid-patterns" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -50,45 +56,47 @@
<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"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"Python.2dtest.executor": "Run", &quot;Python.2dtest.executor&quot;: &quot;Run&quot;,
"Python.3d_windows.executor": "Run", &quot;Python.3d_windows.executor&quot;: &quot;Run&quot;,
"Python.Unnamed.executor": "Run", &quot;Python.Unnamed.executor&quot;: &quot;Run&quot;,
"Python.base.executor": "Run", &quot;Python.base.executor&quot;: &quot;Run&quot;,
"Python.data_model.executor": "Run", &quot;Python.data_model.executor&quot;: &quot;Run&quot;,
"Python.draw_widget2d.executor": "Run", &quot;Python.debug_dragging.executor&quot;: &quot;Run&quot;,
"Python.draw_widget_solve.executor": "Run", &quot;Python.draw_widget2d.executor&quot;: &quot;Run&quot;,
"Python.fluency.executor": "Run", &quot;Python.draw_widget_solve.executor&quot;: &quot;Run&quot;,
"Python.fluencyb.executor": "Run", &quot;Python.fluency.executor&quot;: &quot;Run&quot;,
"Python.gl_widget.executor": "Run", &quot;Python.fluencyb.executor&quot;: &quot;Run&quot;,
"Python.gui_ui.executor": "Run", &quot;Python.gl_widget.executor&quot;: &quot;Run&quot;,
"Python.kernel.executor": "Run", &quot;Python.gui_ui.executor&quot;: &quot;Run&quot;,
"Python.main.executor": "Run", &quot;Python.kernel.executor&quot;: &quot;Run&quot;,
"Python.meshtest.executor": "Run", &quot;Python.main.executor&quot;: &quot;Run&quot;,
"Python.occ_renderer.executor": "Run", &quot;Python.main_window.executor&quot;: &quot;Run&quot;,
"Python.occ_to_mesh.executor": "Run", &quot;Python.meshtest.executor&quot;: &quot;Run&quot;,
"Python.render_backend.executor": "Run", &quot;Python.occ_renderer.executor&quot;: &quot;Run&quot;,
"Python.side_fluency.executor": "Run", &quot;Python.occ_to_mesh.executor&quot;: &quot;Run&quot;,
"Python.simple_mesh.executor": "Run", &quot;Python.render_backend.executor&quot;: &quot;Run&quot;,
"Python.sketch.executor": "Run", &quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
"Python.vtk_widget.executor": "Run", &quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
"Python.vulkan.executor": "Run", &quot;Python.sketch.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.OpenProjectViewOnStart": "true", &quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"codeWithMe.voiceChat.enabledByDefault": "false", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"git-widget-placeholder": "feature/occ-migration", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency", &quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
"node.js.detected.package.eslint": "true", &quot;git-widget-placeholder&quot;: &quot;feature/occ-migration&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;last_opened_file_path&quot;: &quot;/Volumes/Data_drive/Programming/fluency&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"nodejs_package_manager_path": "npm", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings" &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;
} }
}]]></component> }</component>
<component name="QodanaReportsService"> <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,8 +105,8 @@
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$" /> <recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/src/fluency" /> <recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/drawing_modules" /> <recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" /> <recent name="$PROJECT_DIR$/modules" />
</key> </key>
@@ -442,7 +450,39 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1783976498520</updated> <updated>1783976498520</updated>
</task> </task>
<option name="localTasksCounter" value="41" /> <task id="LOCAL-00041" summary="- added &quot;measurement lines&quot;">
<option name="closed" value="true" />
<created>1785094789247</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" />
<updated>1785094789248</updated>
</task>
<task id="LOCAL-00042" summary="- added &quot;measurement lines&quot;">
<option name="closed" value="true" />
<created>1785697123545</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" />
<updated>1785697123545</updated>
</task>
<task id="LOCAL-00043" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1785701099363</created>
<option name="number" value="00043" />
<option name="presentableId" value="LOCAL-00043" />
<option name="project" value="LOCAL" />
<updated>1785701099363</updated>
</task>
<task id="LOCAL-00044" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1785915859137</created>
<option name="number" value="00044" />
<option name="presentableId" value="LOCAL-00044" />
<option name="project" value="LOCAL" />
<updated>1785915859137</updated>
</task>
<option name="localTasksCounter" value="45" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -463,8 +503,6 @@
<ignored-roots> <ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" /> <path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots> </ignored-roots>
<MESSAGE value="- Sketch projection partly works again :)" />
<MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" /> <MESSAGE value="- changing compos 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" />
@@ -488,6 +526,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" />
<option name="LAST_COMMIT_MESSAGE" value="- Render improvements, camera plane, update" /> <MESSAGE value="- added &quot;measurement lines&quot;" />
<MESSAGE value="- arc improvements, fillets, operations, bodys" />
<option name="LAST_COMMIT_MESSAGE" value="- arc improvements, fillets, operations, bodys" />
</component> </component>
</project> </project>
+856 -831
View File
File diff suppressed because it is too large Load Diff
+546 -523
View File
File diff suppressed because it is too large Load Diff
+515 -2
View File
@@ -5,6 +5,7 @@ This module provides a concrete implementation of the geometry kernel
using OCP (OpenCASCADE Python bindings). using OCP (OpenCASCADE Python bindings).
""" """
import logging
from typing import List, Tuple, Optional, Any, Dict from typing import List, Tuple, Optional, Any, Dict
import numpy as np import numpy as np
@@ -15,6 +16,8 @@ from fluency.geometry.base import (
Point3D, Point3D,
) )
logger = logging.getLogger(__name__)
class OCCGeometryObject(GeometryObject): class OCCGeometryObject(GeometryObject):
"""Geometry object wrapper for OpenCASCADE shapes.""" """Geometry object wrapper for OpenCASCADE shapes."""
@@ -235,6 +238,93 @@ 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,
@@ -368,10 +458,11 @@ 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, explorer.Current()) fillet.Add(radius, TopoDS.Edge_s(explorer.Current()))
explorer.Next() explorer.Next()
fillet.Build() fillet.Build()
@@ -392,10 +483,11 @@ 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():
chamfer.Add(size, explorer.Current()) chamfer.Add(size, TopoDS.Edge_s(explorer.Current()))
explorer.Next() explorer.Next()
chamfer.Build() chamfer.Build()
@@ -488,6 +580,67 @@ class OCGeometryKernel(GeometryKernel):
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"}) return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
def pattern(
self,
body: GeometryObject,
pattern_type: str = "linear",
count: int = 2,
direction: Tuple[float, float, float] = (1, 0, 0),
spacing: float = 10.0,
axis: Tuple[float, float, float] = (0, 0, 1),
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
angle: float = 360.0,
) -> GeometryObject:
"""Repeat *body* in a linear or circular array (pattern).
Linear: *count* copies spaced *spacing* mm apart along
*direction* (a negative spacing reverses the direction).
Circular: *count* copies rotated evenly around *axis* passing
through *origin*, distributed over a total angular span of
*angle* degrees (step = angle / count). ``angle=360`` gives the
classic evenly-spaced full-circle bolt pattern.
Returns the union (compound when the copies don't touch) of the
original solid and all its copies — disjoint copies keep their
separate volumes inside one result object, touching copies fuse.
"""
count = max(1, int(count))
if count <= 1:
return body
import math as _math
instances: list = [body]
if pattern_type == "circular":
# Normalize the rotation axis.
ax = float(axis[0]), float(axis[1]), float(axis[2])
norm = _math.sqrt(ax[0] * ax[0] + ax[1] * ax[1] + ax[2] * ax[2])
if norm < 1e-12:
ax = (0.0, 0.0, 1.0)
else:
ax = (ax[0] / norm, ax[1] / norm, ax[2] / norm)
step = _math.radians(float(angle)) / count
for i in range(1, count):
instances.append(self.rotate(body, ax, step * i, origin))
else:
d = float(direction[0]), float(direction[1]), float(direction[2])
norm = _math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2])
if norm < 1e-12:
d = (1.0, 0.0, 0.0)
else:
d = (d[0] / norm, d[1] / norm, d[2] / norm)
step = float(spacing)
for i in range(1, count):
instances.append(
self.translate(
body,
(d[0] * step * i, d[1] * step * i, d[2] * step * i),
)
)
return self.boolean_union(*instances)
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool: def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
"""Export to STEP format.""" """Export to STEP format."""
try: try:
@@ -780,3 +933,363 @@ 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 create_thread(
self,
body: GeometryObject,
cylindrical_face: Any,
nominal_diameter: float,
pitch: float,
thread_length: Optional[float] = None,
internal: bool = False,
) -> Optional[GeometryObject]:
"""Cut (or add) an ISO metric thread on the cylindrical face of *body*.
The geometry is driven by the PICKED face's actual radius and axis
(``nominal_diameter`` is only metadata used for the feature record).
External threads cut the ISO groove trapezoid (7P/8 at the surface,
P/4 at the root, 5H/8 deep) out of the shaft; internal threads fuse
the ISO ridge trapezoid (3P/4 at the wall, P/8 crest) into the hole.
"""
import math
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Cylinder
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Pnt, gp_Pnt2d, gp_Dir2d
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakeWire,
)
# ── 1. Cylinder parameters from the picked face ─────────────────
try:
surf = BRepAdaptor_Surface(cylindrical_face)
except Exception:
try:
surf = BRepAdaptor_Surface(TopoDS.Face_s(cylindrical_face))
except Exception as exc:
logger.warning(f"create_thread: cannot adapt face: {exc}")
return None
if surf.GetType() != GeomAbs_Cylinder:
logger.warning("create_thread: face is not cylindrical")
return None
cyl = surf.Cylinder() # gp_Cylinder
radius = cyl.Radius() # ACTUAL picked radius
ax3 = cyl.Position() # gp_Ax3 (location, Z, X)
loc = ax3.Location()
zdir = ax3.Direction()
xdir = ax3.XDirection()
axis_origin = np.array([loc.X(), loc.Y(), loc.Z()])
axis_dir = np.array([zdir.X(), zdir.Y(), zdir.Z()])
axis_dir = axis_dir / np.linalg.norm(axis_dir)
axis_x = np.array([xdir.X(), xdir.Y(), xdir.Z()])
axis_x = axis_x / np.linalg.norm(axis_x)
axis_y = np.cross(axis_dir, axis_x)
u_start = surf.FirstUParameter() # angular start of face
v1, v2 = surf.FirstVParameter(), surf.LastVParameter()
v_lo, v_hi = min(v1, v2), max(v1, v2)
face_height = v_hi - v_lo
if not thread_length or thread_length <= 0:
thread_length = face_height
thread_length = min(thread_length, face_height)
num_turns = thread_length / pitch
if num_turns < 0.05:
logger.warning("create_thread: thread too short for one turn")
return None
# ── 2. ISO metric profile dimensions ────────────────────────────
# Basic profile (H = P·√3/2, thread engagement depth 5H/8):
# • external shaft: groove cut is a trapezoid 7P/8 wide at the
# surface narrowing to P/4 at the root.
# • internal hole: ridge fused onto the wall is a trapezoid 3P/4
# wide at the wall narrowing to P/8 at the inner crest, leaving
# the 7P/8-wide groove open at the bore.
H = pitch * math.sqrt(3.0) / 2.0
depth = (5.0 / 8.0) * H
overcut = max(0.1 * depth, 0.02) # overhang past the surface
if internal:
w_surf = 3.0 * pitch / 4.0
w_deep = pitch / 8.0
else:
w_surf = 7.0 * pitch / 8.0
w_deep = pitch / 4.0
# ── 3. Helix spine ON the picked cylinder's surface ─────────────
# The swept profile sits in the helix's normal plane, tilted by the
# lead angle; its end caps therefore stick out past the spine ends
# by roughly half the profile width along the axis. For a CUT that
# is harmless (the groove simply runs to the part edge), but a FUSE
# would leave the protruding cap as floating material outside the
# part, so inset the internal helix by exactly that amount.
lead = math.atan2(pitch, 2.0 * math.pi * radius)
cap_axial = (w_surf / 2.0) * math.cos(lead) # cap half-extent along axis
if internal:
v_start = v_lo + cap_axial
v_end = min(v_lo + thread_length, v_hi) - cap_axial
else:
# extend one pitch past each face end so the groove runs off
# the part edges cleanly
v_start = v_lo - pitch
v_end = min(v_lo + thread_length + pitch, v_hi + pitch)
thread_span = v_end - v_start
if thread_span < 0.5 * pitch:
logger.warning("create_thread: part too short for a thread")
return None
turns_ext = thread_span / pitch
spine_wire = None
# 3a. TRUE helix: a 2D straight line on the cylinder surface.
#
# NOTE 1: gp_Dir2d NORMALIZES its argument, so the 2D line
# parameter t advances the point by t·|(2π, pitch)| in (u, v)
# space — scale the trim range so t = n turns covers exactly
# n revolutions plus n·pitch of axial travel.
# NOTE 2: the edge from a pcurve has no 3D curve; the pipe sweep
# needs one, so force it with BRepLib.BuildCurves3d.
spine_wire = None
try:
from OCP.Geom import Geom_CylindricalSurface
from OCP.Geom2d import Geom2d_Line, Geom2d_TrimmedCurve
from OCP.BRepLib import BRepLib
dir_len = math.hypot(2.0 * math.pi, pitch)
cyl_surf = Geom_CylindricalSurface(cyl)
line2d = Geom2d_Line(
gp_Pnt2d(u_start, v_start), gp_Dir2d(2.0 * math.pi, pitch)
)
seg = Geom2d_TrimmedCurve(line2d, 0.0, turns_ext * dir_len)
helix_edge = BRepBuilderAPI_MakeEdge(seg, cyl_surf).Edge()
BRepLib.BuildCurves3d_s(helix_edge)
spine_wire = BRepBuilderAPI_MakeWire(helix_edge).Wire()
logger.info("create_thread: using exact helix spine")
except Exception as exc:
logger.info(f"create_thread: exact helix failed ({exc})")
# 3b. Fallback: smooth BSpline through sampled helix points
# (only if the exact construction is unavailable).
if spine_wire is None:
try:
from OCP.GeomAPI import GeomAPI_PointsToBSpline
from OCP.TColgp import TColgp_Array1OfPnt
from OCP.GeomAbs import GeomAbs_C2
pts_per_turn = 96
n_total = max(int(turns_ext * pts_per_turn) + 1, 2)
arr = TColgp_Array1OfPnt(1, n_total)
for i in range(1, n_total + 1):
u = u_start + ((i - 1) / pts_per_turn) * 2.0 * math.pi
v = v_start + ((i - 1) / pts_per_turn) * pitch
p = (
axis_origin
+ radius * (math.cos(u) * axis_x + math.sin(u) * axis_y)
+ v * axis_dir
)
arr.SetValue(i, gp_Pnt(float(p[0]), float(p[1]), float(p[2])))
bspline = GeomAPI_PointsToBSpline(arr, 3, 8, GeomAbs_C2, 1e-5)
bs_edge = BRepBuilderAPI_MakeEdge(bspline.Curve()).Edge()
spine_wire = BRepBuilderAPI_MakeWire(bs_edge).Wire()
logger.info("create_thread: using BSpline helix fallback")
except Exception as exc:
logger.warning(f"create_thread: BSpline helix failed ({exc})")
if spine_wire is None:
logger.warning("create_thread: no usable helix spine")
return None
# Start frame (same for both spine types — computed analytically).
def _cyl_pt(u: float, v: float) -> np.ndarray:
return (
axis_origin
+ radius * (math.cos(u) * axis_x + math.sin(u) * axis_y)
+ v * axis_dir
)
start_S = _cyl_pt(u_start, v_start)
start_T = (
2.0 * math.pi * radius
* (-math.sin(u_start) * axis_x + math.cos(u_start) * axis_y)
+ pitch * axis_dir
)
start_T = start_T / np.linalg.norm(start_T)
start_R = math.cos(u_start) * axis_x + math.sin(u_start) * axis_y # outward
# Profile width direction: perpendicular to tangent in the surface
# plane (≈ axial direction). Trapezoid is symmetric so sign is fine.
binormal = np.cross(start_T, start_R)
binormal = binormal / np.linalg.norm(binormal)
# ── 4. Trapezoidal profile at the spine start ───────────────────
# Built directly in world coords: base sits *overcut* OUTSIDE the
# surface so the boolean fuses/cuts cleanly across it; the working
# end reaches *depth* INSIDE the surface.
def _mk(b: float, r: float) -> gp_Pnt:
p = start_S + b * binormal + r * start_R
return gp_Pnt(float(p[0]), float(p[1]), float(p[2]))
p0 = _mk(-w_surf / 2.0, overcut)
p1 = _mk(-w_deep / 2.0, -depth)
p2 = _mk(+w_deep / 2.0, -depth)
p3 = _mk(+w_surf / 2.0, overcut)
prof_wb = BRepBuilderAPI_MakeWire()
for a, b in ((p0, p1), (p1, p2), (p2, p3), (p3, p0)):
prof_wb.Add(BRepBuilderAPI_MakeEdge(a, b).Edge())
profile_wire = prof_wb.Wire()
# ── 5. Sweep the profile along the helix ────────────────────────
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell
try:
pipe = BRepOffsetAPI_MakePipeShell(spine_wire)
pipe.SetMode(True) # Frenet frame
pipe.Add(profile_wire, False, False)
pipe.Build()
if not pipe.IsDone():
logger.warning("create_thread: pipe sweep failed")
return None
solid_ok = False
try:
solid_ok = bool(pipe.MakeSolid()) # cap the tube ends
except Exception as exc:
logger.info(f"create_thread: MakeSolid unavailable ({exc})")
tool_shape = pipe.Shape()
if not solid_ok:
logger.warning("create_thread: sweep is not a solid")
except Exception as exc:
logger.warning(f"create_thread: sweep failed: {exc}")
return None
# ── 6. Boolean cut (shaft) or fuse (hole) ───────────────────────
body_shape = self._get_shape(body)
if body_shape is None:
logger.warning("create_thread: body has no shape")
return None
tool = OCCGeometryObject(tool_shape)
vol_before = self.get_volume(body)
if internal:
result = self.boolean_union(body, tool)
else:
result = self.boolean_difference(body, tool)
if result is None or self._get_shape(result) is None:
logger.warning("create_thread: boolean op produced no shape")
return None
try:
vol_after = self.get_volume(result)
except Exception:
vol_after = -1.0
if internal and vol_after <= vol_before:
logger.warning(
f"create_thread: fuse did not add volume "
f"({vol_before:.4f}{vol_after:.4f}) — tool missed the body?"
)
return None
if not internal and vol_after >= vol_before:
logger.warning(
f"create_thread: cut did not remove volume "
f"({vol_before:.4f}{vol_after:.4f}) — tool missed the body?"
)
return None
logger.info(
f"create_thread: {'internal' if internal else 'external'} thread OK, "
f"volume {vol_before:.4f}{vol_after:.4f}"
)
return result
def detect_cylindrical_face(
self,
face: Any,
) -> Optional[Dict[str, Any]]:
"""Check if *face* is cylindrical and return its parameters.
The *face* can be a ``TopoDS_Face`` (from the picker) or a
``TopoDS_Shape`` that contains a face. We try several paths to
extract the underlying cylindrical surface.
Returns a dict with keys ``radius``, ``axis_origin``, ``axis_dir``,
``height``, or *None* if the face isn't cylindrical.
"""
import logging
import numpy as np
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Cylinder
from OCP.TopoDS import TopoDS
_log = logging.getLogger(__name__)
# ── Resolve the actual face from whatever the caller handed us ──
actual_face: Any = None
# Try direct BRepAdaptor_Surface first — the picker already returns
# a valid TopoDS_Face, and calling TopoDS.Face_s() again on an
# already-downcast face can fail in some OCP versions.
try:
surf = BRepAdaptor_Surface(face)
surf_type_test = surf.GetType()
actual_face = face
except Exception:
pass
if actual_face is None:
# Fallback: try the explicit TopoDS.Face_s downcast path.
try:
candidate = TopoDS.Face_s(face)
_ = BRepAdaptor_Surface(candidate)
actual_face = candidate
except Exception:
pass
if actual_face is None:
_log.warning("detect_cylindrical_face: could not resolve face from pick result")
return None
# ── Probe the surface type ──
try:
surf = BRepAdaptor_Surface(actual_face)
surf_type = surf.GetType()
if surf_type != GeomAbs_Cylinder:
type_names = {
0: "Plane", 1: "Cylinder", 2: "Cone", 3: "Sphere",
4: "Torus", 5: "Bezier", 6: "BSpline", 7: "Revolution",
8: "Extrusion", 9: "Offset", 10: "Other",
}
type_name = type_names.get(int(surf_type), f"Unknown({int(surf_type)})")
_log.warning(
f"detect_cylindrical_face: face is {type_name}, not a Cylinder"
)
return None
cyl = surf.Cylinder()
radius = cyl.Radius()
axis = cyl.Axis()
origin = axis.Location()
direction = axis.Direction()
# BRepAdaptor_Surface uses FirstUParameter/LastUParameter etc.
u1 = surf.FirstUParameter()
u2 = surf.LastUParameter()
v1 = surf.FirstVParameter()
v2 = surf.LastVParameter()
height = abs(v2 - v1)
return {
"radius": radius,
"diameter": 2.0 * radius,
"axis_origin": (origin.X(), origin.Y(), origin.Z()),
"axis_dir": (direction.X(), direction.Y(), direction.Z()),
"height": height,
}
except Exception as exc:
_log.warning(f"detect_cylindrical_face: surface probe failed: {exc}")
return None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
"""Surface modifier for OpenCASCADE geometry.
Applies geometric patterns (pyramids, bumps, grooves) to 3D surfaces using boolean operations.
This enables grip-enhancing textures and visual surface modifications on CAD models.
"""
from __future__ import annotations
import logging
import math
from typing import Any, Optional, Tuple
# OCC imports at module level for common types
logger = logging.getLogger(__name__)
class SurfaceModifier:
"""Applies geometric patterns to 3D surfaces using OCC boolean operations."""
def __init__(self):
self._patterns_applied = []
def apply_pyramid_pattern(
self,
face_shape,
pyramid_height: float = 1.0,
base_radius: float = 2.0,
spacing: float = 5.0,
num_rings: Optional[int] = None,
direction: Tuple[float, float, float] = (0, 0, 1),
) -> Optional[Any]:
"""Apply a pyramid pattern to a face surface.
Args:
face_shape: OCC TopoDS_Shape representing the face or solid
pyramid_height: Height of each pyramid
base_radius: Radius of pyramid base
spacing: Distance between pyramids
num_rings: Number of concentric rings (auto-calculated if None)
direction: Normal direction for pyramids
Returns:
Modified shape on success, None on failure
"""
try:
from OCP.TopAbs import TopAbs_FACE
from OCP.TopoDS import TopoDS_Face, TopoDS_Shape
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.TopExp import TopExp_Explorer
from OCP.BRepAdaptor import BRepAdaptor_Surface
# Validate face shape
if not isinstance(face_shape, (TopoDS_Shape, TopoDS_Face)):
logger.error("Invalid face shape type")
return None
# Extract the first face for surface parameterization
if isinstance(face_shape, TopoDS_Shape):
explorer = TopExp_Explorer(face_shape, TopAbs_FACE)
if not explorer.More():
logger.error("No faces found in shape")
return None
from OCP import TopoDS
face = TopoDS.TopoDS.Face_s(explorer.Current())
else:
face = face_shape
# Get face surface for UV parameterization
surf = BRepAdaptor_Surface(face)
u_min, u_max = surf.FirstUParameter(), surf.LastUParameter()
v_min, v_max = surf.FirstVParameter(), surf.LastVParameter()
# Calculate number of rings if not specified
if num_rings is None:
# Estimate based on face area and spacing
u_range = u_max - u_min
v_range = v_max - v_min
avg_dim = (u_range + v_range) / 2.0
num_rings = max(1, min(int(avg_dim / spacing), 5))
logger.info(
f"Applying pyramid pattern: {num_rings} rings, "
f"{base_radius:.2f} radius, {pyramid_height:.2f} height"
)
# Create pyramids distributed across the face UV space
result_shape = face_shape
pyramid_count = 0
for ring_idx in range(num_rings):
# Distribute rings evenly across UV parameter space
u_fraction = (ring_idx + 1) / (num_rings + 1)
v_fraction = 0.5 # Center vertically
# Map to actual UV coordinates on the face
u_pos = u_min + u_fraction * (u_max - u_min)
v_pos = v_min + v_fraction * (v_max - v_min)
# Get 3D position and tangent vectors at this UV point
from OCP.gp import gp_Pnt, gp_Vec
center_pt = gp_Pnt()
d1u = gp_Vec()
d1v = gp_Vec()
surf.D1(u_pos, v_pos, center_pt, d1u, d1v)
# Normal is cross product of tangent vectors
normal = d1u.Crossed(d1v)
normal.Normalize()
# Calculate number of pyramids in this ring based on spacing
if ring_idx == 0:
num_pyramids = 1 # Center pyramid
else:
circumference = 2.0 * math.pi * (ring_idx * spacing)
num_pyramids = max(3, int(circumference / spacing))
for i in range(num_pyramids):
if ring_idx == 0:
# Center pyramid - place at face center
place_u = u_pos
place_v = v_pos
else:
angle = (2.0 * math.pi * i) / num_pyramids
# Offset in UV space based on ring radius
offset_u = (ring_idx * spacing / (u_max - u_min)) * math.cos(angle)
offset_v = (ring_idx * spacing / (v_max - v_min)) * math.sin(angle)
place_u = max(u_min, min(u_max, u_pos + offset_u))
place_v = max(v_min, min(v_max, v_pos + offset_v))
try:
# Get 3D position and normal for this pyramid
pyramid_pt = gp_Pnt()
pd1u = gp_Vec()
pd1v = gp_Vec()
surf.D1(place_u, place_v, pyramid_pt, pd1u, pd1v)
pyramid_normal = pd1u.Crossed(pd1v)
pyramid_normal.Normalize()
# Create solid pyramid at this position
pyramid_shape = self._create_solid_pyramid(
pyramid_pt,
pyramid_normal,
pyramid_height,
base_radius,
)
if pyramid_shape is not None:
# Fuse with existing geometry
fuse = BRepAlgoAPI_Fuse(result_shape, pyramid_shape)
fuse.Build()
if fuse.IsDone():
result_shape = fuse.Shape()
pyramid_count += 1
else:
logger.warning(
f"Failed to fuse pyramid at ({place_u:.2f}, {place_v:.2f})"
)
except Exception as e:
logger.debug(
f"Error creating pyramid at ring {ring_idx}, pyramid {i}: {e}"
)
self._patterns_applied.append(
{
"type": "pyramid",
"parameters": {
"height": pyramid_height,
"base_radius": base_radius,
"spacing": spacing,
"num_rings": num_rings,
"direction": direction,
},
}
)
logger.info(f"Successfully applied {pyramid_count} pyramids")
return result_shape
except Exception as e:
logger.error(f"Error applying pyramid pattern: {e}", exc_info=True)
return None
def _create_solid_pyramid(
self,
base_point, # gp_Pnt - position on the face
normal_vec, # gp_Dir or gp_Vec - surface normal direction
height: float,
base_radius: float,
) -> Optional[Any]:
"""Create a solid pyramid at the specified position and orientation.
Uses BRepPrimAPI_MakePrism to extrude a square base into a solid pyramid.
Args:
base_point: 3D point where pyramid base is centered
normal_vec: Direction vector for pyramid growth (surface normal)
height: Height of the pyramid from base to apex
base_radius: Half-width of the square base
Returns:
OCC solid shape for the pyramid, or None on failure
"""
try:
from OCP.gp import gp_Dir, gp_Ax2, gp_Vec
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakeWire,
)
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
half = base_radius / 2.0
# Build orthonormal basis from normal vector
if isinstance(normal_vec, gp_Vec):
n_dir = gp_Dir(normal_vec.XYZ())
else:
n_dir = normal_vec
# Create a local coordinate system at the base point
local_ax2 = gp_Ax2(base_point, n_dir)
# Get X and Y axes from the local coordinate system
x_dir = local_ax2.XDirection()
y_dir = local_ax2.YDirection()
# Create 4 corners of the square base in the local plane
corner_points = [
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(half),
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(half),
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(-half),
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(-half),
]
# Create edges connecting the corners
wire_maker = BRepBuilderAPI_MakeWire()
for idx in range(4):
next_idx = (idx + 1) % 4
edge = BRepBuilderAPI_MakeEdge(
corner_points[idx], corner_points[next_idx]
).Edge()
wire_maker.Add(edge)
if not wire_maker.IsDone():
logger.warning("Failed to create pyramid base wire")
return None
# Extrude the base wire in the normal direction by height to form a prism
extrusion_vec = gp_Vec(n_dir).Multiplied(height)
prism_maker = BRepPrimAPI_MakePrism(
wire_maker.Wire(), extrusion_vec, False # no check intersection
)
prism_maker.Build()
if not prism_maker.IsDone():
logger.warning("Failed to create pyramid prism")
return None
return prism_maker.Shape()
except Exception as e:
logger.debug(f"Error creating solid pyramid: {e}")
return None
def apply_bump_pattern(
self,
face_shape,
bump_height: float = 1.0,
bump_radius: float = 2.0,
spacing: float = 5.0,
num_rings: Optional[int] = None,
) -> Optional[Any]:
"""Apply a simple bump pattern to a face surface.
Args:
face_shape: OCC TopoDS_Shape representing the face
bump_height: Height of each bump
bump_radius: Radius of each bump base
spacing: Distance between bumps
num_rings: Number of concentric rings
Returns:
Modified shape on success, None on failure
"""
return self.apply_pyramid_pattern(
face_shape,
pyramid_height=bump_height,
base_radius=bump_radius,
spacing=spacing,
num_rings=num_rings,
)
def apply_surface_modifier_to_body(
body_geometry, modifier_type: str = "pyramid", **parameters
) -> Optional[Any]:
"""Apply a surface modifier to a body geometry.
Args:
body_geometry: OCCGeometryObject or similar geometry object
modifier_type: Type of modifier ('pyramid', 'bump')
**parameters: Modifier-specific parameters
Returns:
Modified shape, or None on failure
"""
from fluency.geometry_occ.kernel import OCGeometryKernel
kernel = OCGeometryKernel()
shape = kernel._get_shape(body_geometry)
if shape is None:
logger.error("No geometry found in body")
return None
modifier = SurfaceModifier()
try:
if modifier_type == "pyramid":
success = modifier.apply_pyramid_pattern(shape, **parameters)
elif modifier_type == "bump":
success = modifier.apply_bump_pattern(shape, **parameters)
else:
logger.error(f"Unknown modifier type: {modifier_type}")
return None
if not success:
logger.error("Surface modifier application failed")
return None
# Return the modified shape wrapped in OCCGeometryObject
from fluency.geometry_occ.kernel import OCCGeometryObject
return OCCGeometryObject(shape)
except Exception as e:
logger.error(f"Error applying surface modifier: {e}", exc_info=True)
return None
# Example usage and testing
if __name__ == "__main__":
# Create a simple test case
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
# Create a box to modify
box_maker = BRepPrimAPI_MakeBox(50, 50, 10)
box_maker.Build()
if box_maker.IsDone():
print("Created test box")
# Apply pyramid pattern to top face (Z direction)
modifier = SurfaceModifier()
success = modifier.apply_pyramid_pattern(
box_maker.Shape(),
pyramid_height=2.0,
base_radius=3.0,
spacing=8.0,
num_rings=2,
direction=(0, 0, 1),
)
if success:
print("Successfully applied pyramid pattern")
# Export modified shape
from OCP.StlAPI import StlAPI_Writer
from OCP.BRepMesh import BRepMesh_IncrementalMesh
tess = BRepMesh_IncrementalMesh(box_maker.Shape(), 0.1)
tess.Perform()
writer = StlAPI_Writer()
writer.SetASCIIMode(False)
writer.Write(box_maker.Shape(), "/tmp/test_pyramid_pattern.stl")
print("Exported modified shape to STL")
else:
print("Failed to apply pyramid pattern")
else:
print("Failed to create test box")
+175 -26
View File
@@ -28,6 +28,7 @@ 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
@@ -42,6 +43,7 @@ from fluency.models.data_model import (
Body, Body,
Component, Component,
Connector, Connector,
Feature,
Project, Project,
Sketch, Sketch,
Workplane, Workplane,
@@ -65,7 +67,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): if is_dataclass(obj) and not isinstance(obj, type):
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")
@@ -85,10 +87,37 @@ 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:
@@ -96,10 +125,13 @@ 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:
@@ -108,10 +140,13 @@ 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)
@@ -121,6 +156,8 @@ 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:
@@ -151,11 +188,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=data.get("id") or None, # Workplane generates uuid if None id=_saved_id(data),
name=data.get("name", "Untitled Workplane"), name=data.get("name", "Untitled Workplane"),
origin=tuple(data.get("origin", (0.0, 0.0, 0.0))), origin=_to_3tuple(data.get("origin", (0.0, 0.0, 0.0))),
normal=tuple(data.get("normal", (0.0, 0.0, 1.0))), normal=_to_3tuple(data.get("normal", (0.0, 0.0, 1.0))),
x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))), x_dir=_to_3tuple(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"))
@@ -163,6 +200,70 @@ 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),
"axis": [float(v) for v in (feat.axis or (0, 0, 1))],
"origin": [float(v) for v in (feat.origin or (0.0, 0.0, 0.0))],
"axis_line_id": feat.axis_line_id,
"radius": feat.radius,
"tangent_propagation": bool(feat.tangent_propagation),
"scope": feat.scope,
"edge_refs": list(feat.edge_refs),
"pattern_type": feat.pattern_type,
"count": feat.count,
"spacing": feat.spacing,
"direction": [float(v) for v in (feat.direction or (1, 0, 0))],
}
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),
axis=tuple(float(v) for v in (data.get("axis") or (0, 0, 1))),
origin=tuple(float(v) for v in (data.get("origin") or (0.0, 0.0, 0.0))),
axis_line_id=data.get("axis_line_id"),
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 []),
pattern_type=data.get("pattern_type", "linear"),
count=int(data.get("count") or 2),
spacing=_to_float(data.get("spacing"), 10.0),
direction=tuple(float(v) for v in (data.get("direction") or (1, 0, 0))),
)
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."""
@@ -177,12 +278,16 @@ 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": float(body.opacity), "opacity": _to_float(body.opacity, 1.0),
"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
@@ -201,7 +306,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=data.get("id") or None, id=_saved_id(data),
name=data.get("name", "Untitled Body"), name=data.get("name", "Untitled Body"),
geometry=geometry, geometry=geometry,
source_sketch=source_sketch, source_sketch=source_sketch,
@@ -212,12 +317,13 @@ 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=float(data.get("opacity", 1.0)), opacity=_to_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"))
@@ -267,9 +373,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(
tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))), _to_3tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))), _to_3tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))), _to_3tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
) )
geometry: Optional[OCCGeometryObject] = None geometry: Optional[OCCGeometryObject] = None
@@ -277,7 +383,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=data.get("id") or None, id=_saved_id(data),
name=data.get("name", "Untitled Sketch"), name=data.get("name", "Untitled Sketch"),
occ_sketch=occ_sketch, occ_sketch=occ_sketch,
geometry=geometry, geometry=geometry,
@@ -314,7 +420,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=data.get("id") or None, id=_saved_id(data),
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"),
@@ -335,7 +441,22 @@ 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]
comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch) body = _body_from_dict(body_data, body_geometry_loader, src_sketch)
# Parametric feature history (new files). Old files carry no
# "features" key — the body keeps an empty list and is migrated
# lazily at update time (see ``_ensure_feature_history``).
for f_data in body_data.get("features") or []:
body.features.append(_feature_from_dict(f_data, comp.sketches))
if body_data.get("features_base_snapshot") and body.features:
# The list was saved WITHOUT its leading "base" snapshot;
# restore it from the dedicated STEP member.
base_geom: Optional[OCCGeometryObject] = None
base_ref = body_data.get("base_geometry_ref")
if base_ref and body_geometry_loader is not None:
base_geom = body_geometry_loader(base_ref)
if base_geom is not None:
body.features.insert(0, Feature(operation="base", geometry=base_geom))
comp.bodies[bid] = body
return comp return comp
@@ -347,8 +468,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": float(conn.axis_rotation), "axis_rotation": _to_float(conn.axis_rotation, 0.0),
"offset": float(conn.offset), "offset": _to_float(conn.offset, 0.0),
"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,
@@ -362,13 +483,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=data.get("id") or None, id=_saved_id(data),
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=float(data.get("axis_rotation", 0.0)), axis_rotation=_to_float(data.get("axis_rotation"), 0.0),
offset=float(data.get("offset", 0.0)), offset=_to_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", ""),
) )
@@ -396,7 +517,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=data.get("id") or None, id=_saved_id(data),
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")),
@@ -422,7 +543,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=data.get("id") or None, id=_saved_id(data),
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"),
@@ -446,7 +567,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=data.get("id") or None, id=_saved_id(data),
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"),
) )
@@ -454,7 +575,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
@@ -517,7 +638,9 @@ 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)
return geom from typing import cast
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
@@ -580,6 +703,20 @@ 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]] = []
@@ -672,7 +809,10 @@ 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
@@ -687,12 +827,21 @@ 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 ("workplane_origin", "workplane_normal", "workplane_x_dir", for k in (
"is_solved", "is_fully_constrained"): "workplane_origin",
"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]
+2
View File
@@ -24,6 +24,7 @@ from fluency.ui.dialogs import (
ExtrudeDialog, ExtrudeDialog,
OffsetDialog, OffsetDialog,
RevolveDialog, RevolveDialog,
ThreadDialog,
WorkplaneOrientationDialog, WorkplaneOrientationDialog,
) )
from fluency.ui.main_window import MainWindow from fluency.ui.main_window import MainWindow
@@ -37,6 +38,7 @@ __all__ = [
"ExtrudeDialog", "ExtrudeDialog",
"RevolveDialog", "RevolveDialog",
"OffsetDialog", "OffsetDialog",
"ThreadDialog",
"WorkplaneOrientationDialog", "WorkplaneOrientationDialog",
"main", "main",
] ]
+88 -3
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 = tuple(float(v) for v in n) self.normal = (float(n[0]), float(n[1]), float(n[2]))
self.x_dir = tuple(float(v) for v in x) self.x_dir = (float(x[0]), float(x[1]), float(x[2]))
self._y_dir = tuple(float(v) for v in y) self._y_dir = (float(y[0]), float(y[1]), float(y[2]))
@property @property
def y_dir(self) -> Tuple[float, float, float]: def y_dir(self) -> Tuple[float, float, float]:
@@ -206,6 +206,84 @@ 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)
- "chamfer": bevel a set of edges of the running geometry
(same fields as "fillet"; ``radius`` holds the
chamfer size)
- "array" / "pattern": repeat the running solid in a linear or
circular array. ``pattern_type`` is "linear" or
"circular"; ``count`` is the total number of items
(original + copies). Linear uses ``direction``
(unit vector) and ``spacing`` (mm between adjacent
items); circular reuses ``axis`` + ``origin`` for the
rotation axis and ``angle`` for the total angular
span in degrees (copies evenly distributed).
- "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)
axis: Tuple[float, float, float] = (0, 0, 1) # revolve only: unit axis vector
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0) # revolve only: axis point
axis_line_id: Optional[int] = None # revolve only: sketch line entity used as axis
# "base" features only: frozen pre-feature geometry snapshot.
geometry: Optional[OCCGeometryObject] = None
# "fillet" / "chamfer" features only: radius (mm) of the round or the
# chamfer size, whether the op 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)
# "array" / "pattern" features only: repeat the running solid.
# ``pattern_type`` is "linear" or "circular"; ``count`` is the total
# number of items including the original. Linear arrays use
# ``direction`` (unit vector) and ``spacing`` (mm); circular arrays
# reuse ``axis`` / ``origin`` / ``angle`` (total angular span, deg).
pattern_type: str = "linear"
count: int = 2
spacing: float = 10.0
direction: Tuple[float, float, float] = (1.0, 0.0, 0.0)
created_at: datetime = field(default_factory=datetime.now)
@dataclass @dataclass
class Body: class Body:
""" """
@@ -222,6 +300,12 @@ 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-
@@ -232,6 +316,7 @@ 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
File diff suppressed because it is too large Load Diff
+43 -1
View File
@@ -13,16 +13,58 @@ import unittest
# Allow running this file directly: ``python tests/test_project_io.py``. # Allow running this file directly: ``python tests/test_project_io.py``.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
from fluency.io.project_io import save_project, load_project from fluency.io.project_io import save_project, load_project, _feature_to_dict, _feature_from_dict
from fluency.models.data_model import ( from fluency.models.data_model import (
Project, Project,
Component, Component,
Body, Body,
Sketch,
Workplane, Workplane,
Assembly, Assembly,
Feature,
) )
class TestRevolveAxisSerialization(unittest.TestCase):
"""Revolve features persist their revolve axis across save/load."""
def test_axis_round_trip(self):
sk = Sketch()
feat = Feature(
operation="revolve",
angle=180.0,
axis=(1, 0, 0),
origin=(10.0, 5.0, 0.0),
axis_line_id=7,
)
feat.sketch = sk
data = _feature_to_dict(feat)
self.assertEqual(data["axis"], [1.0, 0.0, 0.0])
self.assertEqual(data["origin"], [10.0, 5.0, 0.0])
self.assertEqual(data["axis_line_id"], 7)
restored = _feature_from_dict(data, {sk.id: sk})
self.assertEqual(tuple(restored.axis), (1.0, 0.0, 0.0))
self.assertEqual(tuple(restored.origin), (10.0, 5.0, 0.0))
self.assertEqual(restored.axis_line_id, 7)
self.assertEqual(restored.angle, 180.0)
def test_default_axis(self):
"""Old files without axis fields fall back to Z axis at the origin."""
sk = Sketch()
data = _feature_to_dict(Feature(operation="revolve", angle=90.0))
restored = _feature_from_dict(data, {sk.id: sk})
self.assertEqual(tuple(restored.axis), (0, 0, 1))
self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0))
self.assertIsNone(restored.axis_line_id)
# legacy file without the keys
del data["axis"]
del data["origin"]
del data["axis_line_id"]
restored = _feature_from_dict(data, {sk.id: sk})
self.assertEqual(tuple(restored.axis), (0, 0, 1))
self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0))
class TestProjectIO(unittest.TestCase): class TestProjectIO(unittest.TestCase):
"""Round-trip the same project through save/load and check equivalence.""" """Round-trip the same project through save/load and check equivalence."""
+960 -109
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -892,7 +892,7 @@ class Ui_fluencyCAD(object):
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None)) self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None)) self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None)) self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None)) self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Array", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None)) self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None)) self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None)) self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+446 -23
View File
@@ -20,6 +20,24 @@ 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 a face for the chamfer tool (ANY face,
# planar or curved). Payload: the raw TopoDS_Face.
chamferFacePicked = Signal(object)
# Emitted when chamfer pick mode is cancelled (Esc).
chamferPickCancelled = Signal()
# Emitted when the user picks a cylindrical face for the thread tool.
threadFacePicked = Signal(object)
# Emitted when thread pick mode is cancelled (Esc).
threadPickCancelled = 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)
@@ -41,14 +59,30 @@ class Viewer3DWidget(QWidget):
# Payload: (eye, at, up) — each is a tuple of 3 floats. # Payload: (eye, at, up) — each is a tuple of 3 floats.
cameraChanged = Signal(tuple, tuple, tuple) cameraChanged = Signal(tuple, tuple, tuple)
# Emitted when the user clicks a part of the world-space sketch gizmo
# (the triad at the sketch midpoint). Payload:
# kind — "center" | "axis_x" | "axis_y" | "axis_z" |
# "plane_xy" | "plane_yz" | "plane_xz"
# position — picked world point (triad origin for axes/center,
# plane quad centre for planes)
# direction — axis unit vector / plane normal / (0,0,0) for center
# normal — sketch workplane normal
# x_dir — sketch workplane x direction
sketchGizmoPicked = Signal(str, tuple, tuple, tuple, tuple)
# Hover feedback: part kind string under the cursor, or None when the
# cursor left the gizmo.
sketchGizmoHover = Signal(object)
# Emitted when gizmo pick mode is cancelled (Esc) so the host can reset.
sketchGizmoCancelled = Signal()
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.WA_PaintOnScreen) self.setAttribute(Qt.WidgetAttribute.WA_PaintOnScreen)
self.setAttribute(Qt.WA_OpaquePaintEvent) self.setAttribute(Qt.WidgetAttribute.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.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.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
@@ -64,6 +98,12 @@ 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 ANY face for the chamfer tool.
self._chamfer_pick_mode: bool = False
# When True, a left-click picks a cylindrical face for the thread tool.
self._thread_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
@@ -87,6 +127,16 @@ class Viewer3DWidget(QWidget):
# target a cut/union extrude against the body the sketch was # target a cut/union extrude against the body the sketch was
# projected onto). # projected onto).
self._last_pick_owner_obj_id: Optional[str] = None self._last_pick_owner_obj_id: Optional[str] = None
# World-space sketch reference gizmo (triad at the sketch midpoint).
# ``_sketch_gizmo_frame`` is (origin, normal, x_dir) of the gizmo;
# None while no triad is shown.
self._sketch_gizmo_frame: Optional[Tuple[tuple, tuple, tuple]] = None
# When True, a left-click picks gizmo parts only (no orbit); Esc
# cancels. Otherwise the gizmo is pickable implicitly during normal
# navigation (Fusion-style) whenever it is shown.
self._sketch_gizmo_pick_mode: bool = False
# Currently hovered gizmo part kind (for highlight bookkeeping).
self._sketch_gizmo_hover_kind: Optional[str] = None
def _init_renderer(self) -> None: def _init_renderer(self) -> None:
"""Create the best available renderer.""" """Create the best available renderer."""
@@ -275,6 +325,35 @@ class Viewer3DWidget(QWidget):
self._renderer.fit_camera() self._renderer.fit_camera()
self._renderer.render() self._renderer.render()
def fit_camera_to_box(self, bnd_box: Any, padding: float = 0.05) -> None:
"""Fit the camera to a specific 3D bounding box (``Bnd_Box``).
Used by the array tool so the dialog preview frames exactly the
space the pattern copies occupy. Falls back to fitting the whole
scene on renderers without box fitting (e.g. the Pygfx fallback).
"""
self._ensure_initialized()
fn = getattr(self._renderer, "fit_camera_to_box", None)
if fn is not None:
fn(bnd_box, padding)
self._renderer.render()
return
self.fit_camera()
def box_fully_visible(self, bnd_box: Any, margin: float = 0.05) -> bool:
"""True when the box's 8 corners all project inside the viewport.
The array preview uses this to decide when a grown pattern has
moved copies off-screen and the camera needs re-fitting. On
renderers without the check (Pygfx fallback) it returns True so
no re-fit is forced.
"""
self._ensure_initialized()
fn = getattr(self._renderer, "box_fully_visible", None)
if fn is not None:
return bool(fn(bnd_box, margin))
return True
# ─── Workplane visualization ─────────────────────────────────────────── # ─── Workplane visualization ───────────────────────────────────────────
def show_workplane( def show_workplane(
@@ -312,17 +391,43 @@ 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.LeftButton: if self._pick_face_mode and event.button() == Qt.MouseButton.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
# Chamfer pick mode: a left-click selects any face (planar or curved).
if self._chamfer_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_chamfer_face_pick(event)
return
# Thread pick mode: a left-click selects a cylindrical face.
if self._thread_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_thread_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.LeftButton: if self._connector_pick_mode and event.button() == Qt.MouseButton.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.LeftButton: if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_assembly_move_press(event) self._handle_assembly_move_press(event)
return return
# World sketch gizmo: a click on a part selects it (Fusion-style),
# even during normal navigation. In explicit gizmo-pick mode an
# off-gizmo click does nothing (no orbit); otherwise it falls
# through to orbit/pan below.
if event.button() == Qt.MouseButton.LeftButton and self._sketch_gizmo_enabled():
fn = getattr(self._renderer, "pick_sketch_gizmo", None)
if fn is not None:
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
kind = fn(pos.x(), pos.y())
if kind is not None:
self._handle_sketch_gizmo_pick(event, kind)
return
if self._sketch_gizmo_pick_mode:
return # explicit mode: off-gizmo clicks do not orbit
self._renderer.handle_mouse_press(event) self._renderer.handle_mouse_press(event)
super().mousePressEvent(event) super().mousePressEvent(event)
@@ -346,15 +451,39 @@ 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
# In chamfer pick mode, keep dynamic highlighting too.
if self._chamfer_pick_mode:
if hasattr(self._renderer, "handle_mouse_move"):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
# In thread pick mode, keep dynamic highlighting.
if self._thread_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)
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
return return
# World sketch gizmo hover: highlight the part under the cursor.
if self._sketch_gizmo_enabled():
self._handle_sketch_gizmo_hover(event)
if self._sketch_gizmo_pick_mode:
super().mouseMoveEvent(event)
return
self._renderer.handle_mouse_move(event) self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
def paintEngine(self): def paintEngine(self) -> Any:
"""Return None to prevent Qt from painting over OCC's direct OpenGL.""" """Return None to prevent Qt from painting over OCC's direct OpenGL."""
return None return None
@@ -437,6 +566,127 @@ class Viewer3DWidget(QWidget):
return self._renderer.get_camera_fov() return self._renderer.get_camera_fov()
return 45.0 return 45.0
# ─── World sketch reference gizmo (triad at the sketch midpoint) ────────
def show_sketch_gizmo(
self,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
size: float = 40.0,
) -> None:
"""Show the selectable X/Y/Z triad in the 3D world at *origin*.
*origin* should be the midpoint of the active sketch's geometry and
the triad is aligned to the sketch's workplane frame. Call again
with a new origin to keep it in sync as the sketch is edited.
No-op on renderers without gizmo support (Pygfx fallback).
"""
self._ensure_initialized()
fn = getattr(self._renderer, "show_sketch_gizmo", None)
if fn is None:
return
fn(origin, normal, x_dir, size)
self._sketch_gizmo_frame = (tuple(origin), tuple(normal), tuple(x_dir))
self._sketch_gizmo_hover_kind = None
self._renderer.render()
def remove_sketch_gizmo(self) -> None:
"""Hide the world sketch triad, if any."""
if self._initialized and self._renderer is not None:
fn = getattr(self._renderer, "remove_sketch_gizmo", None)
if fn is not None:
fn()
self._renderer.render()
self._sketch_gizmo_frame = None
self._sketch_gizmo_hover_kind = None
def set_sketch_gizmo_pick_mode(self, enabled: bool) -> None:
"""Toggle explicit gizmo-pick mode.
When enabled, left-clicks select gizmo parts only (the camera does
not orbit) and Esc exits the mode. When disabled, the gizmo is
still pickable implicitly during normal navigation (Fusion-style)
whenever it is shown. Mutually exclusive with the other pick modes.
"""
self._sketch_gizmo_pick_mode = bool(enabled)
if enabled:
self._pick_face_mode = False
self._fillet_pick_mode = False
self._chamfer_pick_mode = False
self._thread_pick_mode = False
self._connector_pick_mode = False
self._assembly_move_mode = False
self._move_drag_active = False
self.setCursor(Qt.CursorShape.CrossCursor)
self.setFocus()
elif not (
self._pick_face_mode
or self._fillet_pick_mode
or self._chamfer_pick_mode
or self._thread_pick_mode
or self._connector_pick_mode
):
self.unsetCursor()
def is_sketch_gizmo_pick_mode(self) -> bool:
return self._sketch_gizmo_pick_mode
def get_sketch_gizmo_frame(self) -> Optional[Tuple[tuple, tuple, tuple]]:
"""Return the (origin, normal, x_dir) of the shown triad, or None."""
return self._sketch_gizmo_frame
def _sketch_gizmo_enabled(self) -> bool:
"""True when the triad is shown AND no other mode owns the pointer."""
if self._sketch_gizmo_frame is None:
return False
if self._sketch_gizmo_pick_mode:
return True
if self._assembly_move_mode or self._move_drag_active:
return False
return not (
self._pick_face_mode
or self._fillet_pick_mode
or self._chamfer_pick_mode
or self._thread_pick_mode
or self._connector_pick_mode
)
def _handle_sketch_gizmo_hover(self, event) -> None:
"""Highlight the gizmo part under the cursor and emit hover signal."""
fn = getattr(self._renderer, "pick_sketch_gizmo", None)
if fn is None:
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
kind = fn(pos.x(), pos.y())
if kind == self._sketch_gizmo_hover_kind:
return
self._sketch_gizmo_hover_kind = kind
if kind is not None:
hl = getattr(self._renderer, "highlight_sketch_gizmo_part", None)
if hl is not None:
hl(kind)
else:
cl = getattr(self._renderer, "clear_sketch_gizmo_highlight", None)
if cl is not None:
cl()
self.sketchGizmoHover.emit(kind)
def _handle_sketch_gizmo_pick(self, event, kind: str) -> None:
"""Emit sketchGizmoPicked for the clicked part with world metadata."""
frame = self._sketch_gizmo_frame
normal = tuple(frame[1]) if frame else (0.0, 0.0, 1.0)
x_dir = tuple(frame[2]) if frame else (1.0, 0.0, 0.0)
position = tuple(frame[0]) if frame else (0.0, 0.0, 0.0)
direction = (0.0, 0.0, 0.0)
info_fn = getattr(self._renderer, "sketch_gizmo_pick_info", None)
if info_fn is not None:
info = info_fn(kind)
if info:
position = tuple(info["position"])
direction = tuple(info["direction"])
self.sketchGizmoPicked.emit(kind, position, direction, normal, x_dir)
# ─── Face-pick mode (sketch-on-surface) ──────────────────────────────── # ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
def set_pick_face_mode(self, enabled: bool) -> None: def set_pick_face_mode(self, enabled: bool) -> None:
@@ -447,13 +697,155 @@ class Viewer3DWidget(QWidget):
""" """
self._pick_face_mode = bool(enabled) self._pick_face_mode = bool(enabled)
if enabled: if enabled:
self.setCursor(Qt.CrossCursor) self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.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:
self._sketch_gizmo_pick_mode = False
# Pick modes are mutually exclusive — entering fillet mode
# disables chamfer / sketch-on-surface / connector / assembly modes.
self._chamfer_pick_mode = False
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._chamfer_pick_mode and not self._connector_pick_mode:
self.unsetCursor()
def is_fillet_pick_mode(self) -> bool:
return self._fillet_pick_mode
# ─── Chamfer pick mode (any-face picking) ──────────────────────────────
def set_chamfer_pick_mode(self, enabled: bool) -> None:
"""Toggle chamfer face-pick mode (any face — planar or curved).
The cursor selects faces for the chamfer tool instead of orbiting
the camera. Mutually exclusive with the other pick modes.
"""
self._chamfer_pick_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self._fillet_pick_mode = False
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._fillet_pick_mode and not self._connector_pick_mode:
self.unsetCursor()
def is_chamfer_pick_mode(self) -> bool:
return self._chamfer_pick_mode
def _handle_chamfer_face_pick(self, event: Any) -> None:
"""Detect any face under the click and emit chamferFacePicked."""
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("Chamfer face pick: no face under cursor")
return
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.chamferFacePicked.emit(info["face"])
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"])
# ─── Thread pick mode ─────────────────────────────────────────────────
def set_thread_pick_mode(self, enabled: bool) -> None:
"""Toggle thread face-pick mode (cylindrical face only).
When enabled, a left-click picks a cylindrical face for the thread
tool. Mutually exclusive with other pick modes.
"""
self._thread_pick_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self._pick_face_mode = False
self._fillet_pick_mode = False
self._chamfer_pick_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._fillet_pick_mode and not self._chamfer_pick_mode and not self._connector_pick_mode:
self.unsetCursor()
def is_thread_pick_mode(self) -> bool:
return self._thread_pick_mode
def _handle_thread_face_pick(self, event: Any) -> None:
"""Detect any face under the click and emit threadFacePicked."""
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("Thread face pick: no face under cursor")
return
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.threadFacePicked.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()
@@ -489,7 +881,8 @@ class Viewer3DWidget(QWidget):
""" """
self._connector_pick_mode = bool(enabled) self._connector_pick_mode = bool(enabled)
if enabled: if enabled:
self.setCursor(Qt.CrossCursor) self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.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)
@@ -714,7 +1107,8 @@ class Viewer3DWidget(QWidget):
""" """
self._assembly_move_mode = bool(enabled) self._assembly_move_mode = bool(enabled)
if enabled: if enabled:
self.setCursor(Qt.SizeAllCursor) self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.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:
@@ -788,20 +1182,29 @@ class Viewer3DWidget(QWidget):
# Compute world-space delta. # Compute world-space delta.
modifiers = event.modifiers() modifiers = event.modifiers()
if modifiers & Qt.ShiftModifier: if modifiers & Qt.KeyboardModifier.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.
dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel) try:
dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel) dx_world = float(
dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel) cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel
)
dy_world = float(
cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel
)
dz_world = float(
cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel
)
except (TypeError, ValueError):
dx_world = dy_world = dz_world = 0.0
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world) self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
def _handle_assembly_move_release(self, event) -> None: def _handle_assembly_move_release(self, event: Any) -> None:
"""Finish the drag, emit final position.""" """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
@@ -811,7 +1214,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) -> None: def _handle_face_pick(self, event: Any) -> 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)
@@ -834,7 +1237,7 @@ class Viewer3DWidget(QWidget):
info["face"], info["face"],
) )
def set_view(self, view: str): def set_view(self, view: str) -> None:
# 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.
@@ -856,28 +1259,48 @@ 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): def mouseDoubleClickEvent(self, event: Any) -> None:
# Double-click → fit all (common CAD convention). # Double-click → fit all (common CAD convention).
self._ensure_initialized() self._ensure_initialized()
if event.button() == Qt.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
self.fit_camera() self.fit_camera()
super().mouseDoubleClickEvent(event) super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event): def keyPressEvent(self, event: Any) -> None:
# Esc cancels face-pick mode. # Esc cancels face-pick mode.
if self._pick_face_mode and event.key() == Qt.Key_Escape: if self._pick_face_mode and event.key() == Qt.Key.Key_Escape:
self.set_pick_face_mode(False) self.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 chamfer pick mode.
if self._chamfer_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_chamfer_pick_mode(False)
self.chamferPickCancelled.emit()
return
# Esc cancels thread pick mode.
if self._thread_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_thread_pick_mode(False)
self.threadPickCancelled.emit()
return
# Esc cancels connector pick mode. # Esc cancels connector pick mode.
if self._connector_pick_mode and event.key() == Qt.Key_Escape: if self._connector_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_connector_pick_mode(False) self.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_Escape: if self._assembly_move_mode and event.key() == Qt.Key.Key_Escape:
self.set_assembly_move_mode(False) self.set_assembly_move_mode(False)
return return
# Esc cancels explicit sketch-gizmo pick mode.
if self._sketch_gizmo_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_sketch_gizmo_pick_mode(False)
self.sketchGizmoCancelled.emit()
return
# Navigation shortcuts (lowercase = view presets, F = fit, # Navigation shortcuts (lowercase = view presets, F = fit,
# P/O = perspective/orthographic, R = reset). # P/O = perspective/orthographic, R = reset).
self._ensure_initialized() self._ensure_initialized()
+124 -109
View File
@@ -1,7 +1,6 @@
"""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
@@ -195,10 +194,14 @@ 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); p1 = sk.add_point(10, -10) p0 = sk.add_point(-10, -10)
p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10) p1 = sk.add_point(10, -10)
sk.add_line(p0, p1); sk.add_line(p1, p2) p2 = sk.add_point(10, 10)
sk.add_line(p2, p3); sk.add_line(p3, p0) p3 = sk.add_point(-10, 10)
sk.add_line(p0, p1)
sk.add_line(p1, p2)
sk.add_line(p2, p3)
sk.add_line(p3, p0)
geom = sk.get_geometry() geom = sk.get_geometry()
# The face must carry the plane normal for the kernel. # The face must carry the plane normal for the kernel.
@@ -220,10 +223,14 @@ 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); b = sk.add_point(10, -10) a = sk.add_point(-10, -10)
c = sk.add_point(10, 10); d = sk.add_point(-10, 10) b = sk.add_point(10, -10)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(10, 10)
sk.add_line(c, d); sk.add_line(d, a) d = sk.add_point(-10, 10)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
ctr = sk.add_point(0, 0) ctr = sk.add_point(0, 0)
sk.add_circle(ctr, 3.0) sk.add_circle(ctr, 3.0)
@@ -290,10 +297,14 @@ 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); b = sk.add_point(8, 2) a = sk.add_point(2, 2)
c = sk.add_point(8, 8); d = sk.add_point(2, 8) b = sk.add_point(8, 2)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(8, 8)
sk.add_line(c, d); sk.add_line(d, a) d = sk.add_point(2, 8)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
faces = sk.detect_faces() 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
@@ -310,10 +321,14 @@ 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); b = sk.add_point(2, 1) a = sk.add_point(1, 1)
c = sk.add_point(2, 2); d = sk.add_point(1, 2) b = sk.add_point(2, 1)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(2, 2)
sk.add_line(c, d); sk.add_line(d, a) d = sk.add_point(1, 2)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
poly = sk.get_polygon_points() 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)
@@ -328,10 +343,14 @@ 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); b = sk.add_point(3, 1) a = sk.add_point(1, 1)
c = sk.add_point(3, 3); d = sk.add_point(1, 3) b = sk.add_point(3, 1)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(3, 3)
sk.add_line(c, d); sk.add_line(d, a) d = sk.add_point(1, 3)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
geom = sk.get_geometry() 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()
@@ -498,10 +517,9 @@ 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, OCCGeometryObject from fluency.geometry_occ.kernel import OCGeometryKernel
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()
@@ -511,17 +529,18 @@ 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( tool_shape = BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, 200)).Shape()
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.
@@ -536,9 +555,7 @@ 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( BRepGProp.VolumeProperties_s(k._get_shape(target_obj_geometry), g1)
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):
@@ -551,10 +568,9 @@ 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, OCCGeometryObject from fluency.geometry_occ.kernel import OCGeometryKernel
k = OCGeometryKernel() k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape() target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -562,6 +578,7 @@ 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, {})
@@ -595,91 +612,59 @@ 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 must be a checkable QListWidgetItem.""" """Each body list item has a data role for the toggle handler."""
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( box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
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 checked (= visible). # Default state is visible.
assert items[0].checkState() == Qt.Checked assert win._current_component.bodies["a"].visible is True
def test_toggling_visibility_updates_body_model(self): def test_toggling_visibility_updates_body_model(self):
"""Flipping the checkbox should set body.visible accordingly.""" """Toggling visibility via _on_body_visibility_changed updates the model."""
from PySide6.QtCore import Qt 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( box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
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)
@@ -698,10 +683,13 @@ 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
@@ -824,6 +812,7 @@ 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)
@@ -865,12 +854,13 @@ 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
@@ -896,7 +886,6 @@ 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()
@@ -905,6 +894,7 @@ 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)
@@ -919,14 +909,20 @@ 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, face_geom, sketch,
length=5.0, symmetric=False, invert=False, face_geom,
cut=True, union=False, through_all=False, length=5.0,
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
@@ -938,13 +934,18 @@ class TestExtrudeRedesign:
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, face_geom, sketch,
face_geom,
length=5.0, # ignored when through_all length=5.0, # ignored when through_all
symmetric=False, invert=False, symmetric=False,
cut=True, union=False, through_all=True, invert=False,
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"])
@@ -960,30 +961,23 @@ 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( first = OCCGeometryObject(BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {})
BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {} win._current_component.bodies["first"] = Body(name="First", geometry=first)
)
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( src = OCCGeometryObject(BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {})
BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {} win._current_component.bodies["src"] = Body(name="Src", geometry=src)
)
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))
@@ -999,9 +993,14 @@ class TestExtrudeRedesign:
face_geom = sk.get_geometry() face_geom = sk.get_geometry()
result = win._compute_extrude_result( result = win._compute_extrude_result(
sketch, face_geom, sketch,
length=5.0, symmetric=False, invert=False, face_geom,
cut=True, union=False, through_all=True, length=5.0,
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.
@@ -1019,12 +1018,18 @@ 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, face_geom, sketch,
length=10.0, symmetric=False, invert=False, face_geom,
cut=False, union=True, through_all=False, length=10.0,
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"])
@@ -1037,9 +1042,14 @@ 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, face_geom, sketch,
length=10.0, symmetric=False, invert=False, face_geom,
cut=False, union=False, through_all=False, length=10.0,
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.
@@ -1047,6 +1057,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):
@@ -1055,7 +1066,6 @@ 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
@@ -1063,6 +1073,7 @@ 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))
@@ -1084,8 +1095,10 @@ 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
@@ -1110,8 +1123,10 @@ 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