Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1361ecc58 |
Generated
+21
-48
@@ -4,15 +4,13 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- arc improvements, fillets, operations, bodys">
|
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added contrain context menu - improved line pickability.">
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/gui.ui" 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/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
|
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -29,7 +27,7 @@
|
|||||||
<component name="Git.Settings">
|
<component name="Git.Settings">
|
||||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||||
<map>
|
<map>
|
||||||
<entry key="$PROJECT_DIR$" value="feature/surface-modifier-pyramid-patterns" />
|
<entry key="$PROJECT_DIR$" value="single_window" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
@@ -56,7 +54,6 @@
|
|||||||
"Python.Unnamed.executor": "Run",
|
"Python.Unnamed.executor": "Run",
|
||||||
"Python.base.executor": "Run",
|
"Python.base.executor": "Run",
|
||||||
"Python.data_model.executor": "Run",
|
"Python.data_model.executor": "Run",
|
||||||
"Python.debug_dragging.executor": "Run",
|
|
||||||
"Python.draw_widget2d.executor": "Run",
|
"Python.draw_widget2d.executor": "Run",
|
||||||
"Python.draw_widget_solve.executor": "Run",
|
"Python.draw_widget_solve.executor": "Run",
|
||||||
"Python.fluency.executor": "Run",
|
"Python.fluency.executor": "Run",
|
||||||
@@ -65,7 +62,6 @@
|
|||||||
"Python.gui_ui.executor": "Run",
|
"Python.gui_ui.executor": "Run",
|
||||||
"Python.kernel.executor": "Run",
|
"Python.kernel.executor": "Run",
|
||||||
"Python.main.executor": "Run",
|
"Python.main.executor": "Run",
|
||||||
"Python.main_window.executor": "Run",
|
|
||||||
"Python.meshtest.executor": "Run",
|
"Python.meshtest.executor": "Run",
|
||||||
"Python.occ_renderer.executor": "Run",
|
"Python.occ_renderer.executor": "Run",
|
||||||
"Python.occ_to_mesh.executor": "Run",
|
"Python.occ_to_mesh.executor": "Run",
|
||||||
@@ -73,6 +69,7 @@
|
|||||||
"Python.side_fluency.executor": "Run",
|
"Python.side_fluency.executor": "Run",
|
||||||
"Python.simple_mesh.executor": "Run",
|
"Python.simple_mesh.executor": "Run",
|
||||||
"Python.sketch.executor": "Run",
|
"Python.sketch.executor": "Run",
|
||||||
|
"Python.surface_modifier.executor": "Run",
|
||||||
"Python.vtk_widget.executor": "Run",
|
"Python.vtk_widget.executor": "Run",
|
||||||
"Python.vulkan.executor": "Run",
|
"Python.vulkan.executor": "Run",
|
||||||
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
@@ -81,8 +78,8 @@
|
|||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||||
"git-widget-placeholder": "feature/occ-migration",
|
"git-widget-placeholder": "feature/surface-modifier-pyramid-patterns",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency/Tesfiles",
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
@@ -97,11 +94,11 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="CopyFile.RECENT_KEYS">
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
<recent name="$PROJECT_DIR$/src/fluency/Tesfiles" />
|
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
<recent name="$PROJECT_DIR$/src/fluency" />
|
|
||||||
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
||||||
|
<recent name="$PROJECT_DIR$/src/fluency" />
|
||||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||||
|
<recent name="$PROJECT_DIR$/modules" />
|
||||||
</key>
|
</key>
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
<key name="MoveFile.RECENT_KEYS">
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
@@ -443,55 +440,31 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1783976498520</updated>
|
<updated>1783976498520</updated>
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00041" summary="- added "measurement lines"">
|
<task id="LOCAL-00041" summary="Improved render previews">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
<created>1785094789247</created>
|
<created>1784408804224</created>
|
||||||
<option name="number" value="00041" />
|
<option name="number" value="00041" />
|
||||||
<option name="presentableId" value="LOCAL-00041" />
|
<option name="presentableId" value="LOCAL-00041" />
|
||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1785094789248</updated>
|
<updated>1784408804224</updated>
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00042" summary="- added "measurement lines"">
|
<task id="LOCAL-00042" summary="- added contrain context menu - improved line pickability.">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
<created>1785697123545</created>
|
<created>1784452692776</created>
|
||||||
<option name="number" value="00042" />
|
<option name="number" value="00042" />
|
||||||
<option name="presentableId" value="LOCAL-00042" />
|
<option name="presentableId" value="LOCAL-00042" />
|
||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1785697123545</updated>
|
<updated>1784452692776</updated>
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00043" summary="- arc improvements, fillets, operations, bodys">
|
<task id="LOCAL-00043" summary="- added contrain context menu - improved line pickability.">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
<created>1785701099363</created>
|
<created>1784470032428</created>
|
||||||
<option name="number" value="00043" />
|
<option name="number" value="00043" />
|
||||||
<option name="presentableId" value="LOCAL-00043" />
|
<option name="presentableId" value="LOCAL-00043" />
|
||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1785701099363</updated>
|
<updated>1784470032428</updated>
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00044" summary="- arc improvements, fillets, operations, bodys">
|
<option name="localTasksCounter" value="44" />
|
||||||
<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>
|
|
||||||
<task id="LOCAL-00045" summary="- arc improvements, fillets, operations, bodys">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1785941246695</created>
|
|
||||||
<option name="number" value="00045" />
|
|
||||||
<option name="presentableId" value="LOCAL-00045" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1785941246695</updated>
|
|
||||||
</task>
|
|
||||||
<task id="LOCAL-00046" summary="- arc improvements, fillets, operations, bodys">
|
|
||||||
<option name="closed" value="true" />
|
|
||||||
<created>1785948828454</created>
|
|
||||||
<option name="number" value="00046" />
|
|
||||||
<option name="presentableId" value="LOCAL-00046" />
|
|
||||||
<option name="project" value="LOCAL" />
|
|
||||||
<updated>1785948828454</updated>
|
|
||||||
</task>
|
|
||||||
<option name="localTasksCounter" value="47" />
|
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -535,8 +508,8 @@
|
|||||||
<MESSAGE value="- added renderer" />
|
<MESSAGE value="- added renderer" />
|
||||||
<MESSAGE value="- added renderer - Added undo" />
|
<MESSAGE value="- added renderer - Added undo" />
|
||||||
<MESSAGE value="- Render improvements, camera plane, update" />
|
<MESSAGE value="- Render improvements, camera plane, update" />
|
||||||
<MESSAGE value="- added "measurement lines"" />
|
<MESSAGE value="Improved render previews" />
|
||||||
<MESSAGE value="- arc improvements, fillets, operations, bodys" />
|
<MESSAGE value="- added contrain context menu - improved line pickability." />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="- arc improvements, fillets, operations, bodys" />
|
<option name="LAST_COMMIT_MESSAGE" value="- added contrain context menu - improved line pickability." />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -5,7 +5,6 @@ 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
|
||||||
|
|
||||||
@@ -16,8 +15,6 @@ 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."""
|
||||||
@@ -78,7 +75,7 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
return OCCGeometryObject(edge, {"type": "circle"})
|
return OCCGeometryObject(edge, {"type": "circle"})
|
||||||
|
|
||||||
def create_arc(
|
def create_arc(
|
||||||
self, center: Point2D, radius: float , start_angle: float, end_angle: float
|
self, center: Point2D, radius: float, start_angle: float, end_angle: float
|
||||||
) -> GeometryObject:
|
) -> GeometryObject:
|
||||||
"""Create a 2D arc."""
|
"""Create a 2D arc."""
|
||||||
import math
|
import math
|
||||||
@@ -238,93 +235,6 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def find_coplanar_face(
|
|
||||||
shape: Any,
|
|
||||||
origin: Tuple[float, float, float],
|
|
||||||
normal: Tuple[float, float, float],
|
|
||||||
ref_center: Optional[Tuple[float, float, float]] = None,
|
|
||||||
angle_tol_deg: float = 5.0,
|
|
||||||
dist_tol: float = 1e-3,
|
|
||||||
) -> Optional[Tuple[Any, Tuple[float, float, float]]]:
|
|
||||||
"""Find a planar face on *shape* coplanar with the given plane.
|
|
||||||
|
|
||||||
Iterates the faces of *shape* and returns the first planar face whose
|
|
||||||
plane normal is parallel to *normal* (within *angle_tol_deg* degrees)
|
|
||||||
and whose plane passes through *origin* (within *dist_tol* distance).
|
|
||||||
When several faces match, the one whose surface centre is closest to
|
|
||||||
*ref_center* (if provided) is preferred.
|
|
||||||
|
|
||||||
Returns ``(face, center)`` where *center* is the surface centroid as a
|
|
||||||
3-tuple, or *None* if no matching face is found.
|
|
||||||
"""
|
|
||||||
import math
|
|
||||||
|
|
||||||
from OCP.TopExp import TopExp_Explorer
|
|
||||||
from OCP.TopAbs import TopAbs_FACE
|
|
||||||
from OCP.TopoDS import TopoDS
|
|
||||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
|
||||||
from OCP.GeomAbs import GeomAbs_Plane
|
|
||||||
from OCP.BRepGProp import BRepGProp
|
|
||||||
from OCP.GProp import GProp_GProps
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
if shape is None:
|
|
||||||
return None
|
|
||||||
n = np.asarray(normal, dtype=float)
|
|
||||||
n = n / (np.linalg.norm(n) + 1e-30)
|
|
||||||
ox, oy, oz = origin
|
|
||||||
|
|
||||||
cos_tol = math.cos(math.radians(angle_tol_deg))
|
|
||||||
candidates: list = []
|
|
||||||
|
|
||||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
|
||||||
while explorer.More():
|
|
||||||
face = TopoDS.Face_s(explorer.Current())
|
|
||||||
try:
|
|
||||||
surf = BRepAdaptor_Surface(face)
|
|
||||||
if surf.GetType() != GeomAbs_Plane:
|
|
||||||
explorer.Next()
|
|
||||||
continue
|
|
||||||
plane = surf.Plane()
|
|
||||||
pn = np.array(
|
|
||||||
[
|
|
||||||
plane.Axis().Direction().X(),
|
|
||||||
plane.Axis().Direction().Y(),
|
|
||||||
plane.Axis().Direction().Z(),
|
|
||||||
],
|
|
||||||
dtype=float,
|
|
||||||
)
|
|
||||||
# Check normals parallel (same or opposite direction)
|
|
||||||
cos_angle = abs(float(np.dot(n, pn)))
|
|
||||||
if cos_angle < cos_tol:
|
|
||||||
explorer.Next()
|
|
||||||
continue
|
|
||||||
# Check distance from plane to origin
|
|
||||||
pp = plane.Location()
|
|
||||||
d = abs(float(np.dot(n, np.array([pp.X() - ox, pp.Y() - oy, pp.Z() - oz]))))
|
|
||||||
if d > dist_tol:
|
|
||||||
explorer.Next()
|
|
||||||
continue
|
|
||||||
# Surface centroid via GProp (SurfaceProperties for faces)
|
|
||||||
props = GProp_GProps()
|
|
||||||
BRepGProp.SurfaceProperties_s(face, props)
|
|
||||||
c = props.CentreOfMass()
|
|
||||||
center = (float(c.X()), float(c.Y()), float(c.Z()))
|
|
||||||
candidates.append((face, center))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
explorer.Next()
|
|
||||||
|
|
||||||
if not candidates:
|
|
||||||
return None
|
|
||||||
if ref_center is not None and len(candidates) > 1:
|
|
||||||
rc = np.asarray(ref_center, dtype=float)
|
|
||||||
best = min(candidates, key=lambda fc: float(np.linalg.norm(np.asarray(fc[1]) - rc)))
|
|
||||||
return best
|
|
||||||
return candidates[0]
|
|
||||||
|
|
||||||
def revolve(
|
def revolve(
|
||||||
self,
|
self,
|
||||||
sketch: GeometryObject,
|
sketch: GeometryObject,
|
||||||
@@ -458,11 +368,10 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
else:
|
else:
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
from OCP.TopAbs import TopAbs_EDGE
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
from OCP.TopoDS import TopoDS
|
|
||||||
|
|
||||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||||
while explorer.More():
|
while explorer.More():
|
||||||
fillet.Add(radius, TopoDS.Edge_s(explorer.Current()))
|
fillet.Add(radius, explorer.Current())
|
||||||
explorer.Next()
|
explorer.Next()
|
||||||
|
|
||||||
fillet.Build()
|
fillet.Build()
|
||||||
@@ -483,11 +392,10 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
else:
|
else:
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
from OCP.TopAbs import TopAbs_EDGE
|
from OCP.TopAbs import TopAbs_EDGE
|
||||||
from OCP.TopoDS import TopoDS
|
|
||||||
|
|
||||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||||
while explorer.More():
|
while explorer.More():
|
||||||
chamfer.Add(size, TopoDS.Edge_s(explorer.Current()))
|
chamfer.Add(size, explorer.Current())
|
||||||
explorer.Next()
|
explorer.Next()
|
||||||
|
|
||||||
chamfer.Build()
|
chamfer.Build()
|
||||||
@@ -580,67 +488,6 @@ 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:
|
||||||
@@ -934,362 +781,66 @@ 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(
|
def apply_surface_modifier(
|
||||||
self,
|
self,
|
||||||
body: GeometryObject,
|
body_geometry: GeometryObject,
|
||||||
cylindrical_face: Any,
|
modifier_type: str = 'pyramid',
|
||||||
nominal_diameter: float,
|
height: float = 2.0,
|
||||||
pitch: float,
|
radius: float = 3.0,
|
||||||
thread_length: Optional[float] = None,
|
spacing: float = 8.0,
|
||||||
internal: bool = False,
|
num_rings: int = 3,
|
||||||
) -> Optional[GeometryObject]:
|
) -> Optional[GeometryObject]:
|
||||||
"""Cut (or add) an ISO metric thread on the cylindrical face of *body*.
|
"""Apply a surface modifier to a body geometry.
|
||||||
|
|
||||||
The geometry is driven by the PICKED face's actual radius and axis
|
Args:
|
||||||
(``nominal_diameter`` is only metadata used for the feature record).
|
body_geometry: The OCCGeometryObject to modify
|
||||||
External threads cut the ISO groove trapezoid (7P/8 at the surface,
|
modifier_type: Type of modifier ('pyramid', 'bump')
|
||||||
P/4 at the root, 5H/8 deep) out of the shaft; internal threads fuse
|
height: Height/depth of the pattern features
|
||||||
the ISO ridge trapezoid (3P/4 at the wall, P/8 crest) into the hole.
|
radius: Base radius of pattern features
|
||||||
|
spacing: Distance between pattern features
|
||||||
|
num_rings: Number of concentric rings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified geometry, or None on failure
|
||||||
"""
|
"""
|
||||||
import math
|
from fluency.geometry_occ.surface_modifier import SurfaceModifier
|
||||||
|
|
||||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
shape = self._get_shape(body_geometry)
|
||||||
from OCP.GeomAbs import GeomAbs_Cylinder
|
if shape is None:
|
||||||
from OCP.TopoDS import TopoDS
|
logger.error("No geometry found in body")
|
||||||
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
|
return None
|
||||||
|
|
||||||
cyl = surf.Cylinder() # gp_Cylinder
|
modifier = SurfaceModifier()
|
||||||
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:
|
try:
|
||||||
pipe = BRepOffsetAPI_MakePipeShell(spine_wire)
|
if modifier_type == 'pyramid':
|
||||||
pipe.SetMode(True) # Frenet frame
|
result_shape = modifier.apply_pyramid_pattern(
|
||||||
pipe.Add(profile_wire, False, False)
|
shape,
|
||||||
pipe.Build()
|
pyramid_height=height,
|
||||||
if not pipe.IsDone():
|
base_radius=radius,
|
||||||
logger.warning("create_thread: pipe sweep failed")
|
spacing=spacing,
|
||||||
return None
|
num_rings=num_rings,
|
||||||
solid_ok = False
|
direction=(0, 0, 1),
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
elif modifier_type == 'bump':
|
||||||
|
result_shape = modifier.apply_bump_pattern(
|
||||||
|
shape,
|
||||||
|
bump_height=height,
|
||||||
|
bump_radius=radius,
|
||||||
|
spacing=spacing,
|
||||||
|
num_rings=num_rings,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(f"Unknown modifier type: {modifier_type}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
cyl = surf.Cylinder()
|
if result_shape is None:
|
||||||
radius = cyl.Radius()
|
logger.error("Surface modifier application failed")
|
||||||
axis = cyl.Axis()
|
return None
|
||||||
origin = axis.Location()
|
|
||||||
direction = axis.Direction()
|
# Return the modified shape wrapped in OCCGeometryObject
|
||||||
# BRepAdaptor_Surface uses FirstUParameter/LastUParameter etc.
|
return OCCGeometryObject(result_shape)
|
||||||
u1 = surf.FirstUParameter()
|
|
||||||
u2 = surf.LastUParameter()
|
except Exception as e:
|
||||||
v1 = surf.FirstVParameter()
|
logger.error(f"Error applying surface modifier: {e}", exc_info=True)
|
||||||
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
|
return None
|
||||||
|
|||||||
+178
-1287
File diff suppressed because it is too large
Load Diff
@@ -1,385 +0,0 @@
|
|||||||
"""Surface modifier for OpenCASCADE geometry.
|
|
||||||
|
|
||||||
Applies geometric patterns (pyramids, bumps, grooves) to 3D surfaces using boolean operations.
|
|
||||||
This enables grip-enhancing textures and visual surface modifications on CAD models.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
from typing import Any, Optional, Tuple
|
|
||||||
|
|
||||||
# OCC imports at module level for common types
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SurfaceModifier:
|
|
||||||
"""Applies geometric patterns to 3D surfaces using OCC boolean operations."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._patterns_applied = []
|
|
||||||
|
|
||||||
def apply_pyramid_pattern(
|
|
||||||
self,
|
|
||||||
face_shape,
|
|
||||||
pyramid_height: float = 1.0,
|
|
||||||
base_radius: float = 2.0,
|
|
||||||
spacing: float = 5.0,
|
|
||||||
num_rings: Optional[int] = None,
|
|
||||||
direction: Tuple[float, float, float] = (0, 0, 1),
|
|
||||||
) -> Optional[Any]:
|
|
||||||
"""Apply a pyramid pattern to a face surface.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
face_shape: OCC TopoDS_Shape representing the face or solid
|
|
||||||
pyramid_height: Height of each pyramid
|
|
||||||
base_radius: Radius of pyramid base
|
|
||||||
spacing: Distance between pyramids
|
|
||||||
num_rings: Number of concentric rings (auto-calculated if None)
|
|
||||||
direction: Normal direction for pyramids
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Modified shape on success, None on failure
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from OCP.TopAbs import TopAbs_FACE
|
|
||||||
from OCP.TopoDS import TopoDS_Face, TopoDS_Shape
|
|
||||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
|
|
||||||
from OCP.TopExp import TopExp_Explorer
|
|
||||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
|
||||||
|
|
||||||
# Validate face shape
|
|
||||||
if not isinstance(face_shape, (TopoDS_Shape, TopoDS_Face)):
|
|
||||||
logger.error("Invalid face shape type")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Extract the first face for surface parameterization
|
|
||||||
if isinstance(face_shape, TopoDS_Shape):
|
|
||||||
explorer = TopExp_Explorer(face_shape, TopAbs_FACE)
|
|
||||||
if not explorer.More():
|
|
||||||
logger.error("No faces found in shape")
|
|
||||||
return None
|
|
||||||
from OCP import TopoDS
|
|
||||||
face = TopoDS.TopoDS.Face_s(explorer.Current())
|
|
||||||
else:
|
|
||||||
face = face_shape
|
|
||||||
|
|
||||||
# Get face surface for UV parameterization
|
|
||||||
surf = BRepAdaptor_Surface(face)
|
|
||||||
|
|
||||||
u_min, u_max = surf.FirstUParameter(), surf.LastUParameter()
|
|
||||||
v_min, v_max = surf.FirstVParameter(), surf.LastVParameter()
|
|
||||||
|
|
||||||
# Calculate number of rings if not specified
|
|
||||||
if num_rings is None:
|
|
||||||
# Estimate based on face area and spacing
|
|
||||||
u_range = u_max - u_min
|
|
||||||
v_range = v_max - v_min
|
|
||||||
avg_dim = (u_range + v_range) / 2.0
|
|
||||||
num_rings = max(1, min(int(avg_dim / spacing), 5))
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Applying pyramid pattern: {num_rings} rings, "
|
|
||||||
f"{base_radius:.2f} radius, {pyramid_height:.2f} height"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create pyramids distributed across the face UV space
|
|
||||||
result_shape = face_shape
|
|
||||||
pyramid_count = 0
|
|
||||||
|
|
||||||
for ring_idx in range(num_rings):
|
|
||||||
# Distribute rings evenly across UV parameter space
|
|
||||||
u_fraction = (ring_idx + 1) / (num_rings + 1)
|
|
||||||
v_fraction = 0.5 # Center vertically
|
|
||||||
|
|
||||||
# Map to actual UV coordinates on the face
|
|
||||||
u_pos = u_min + u_fraction * (u_max - u_min)
|
|
||||||
v_pos = v_min + v_fraction * (v_max - v_min)
|
|
||||||
|
|
||||||
# Get 3D position and tangent vectors at this UV point
|
|
||||||
from OCP.gp import gp_Pnt, gp_Vec
|
|
||||||
|
|
||||||
center_pt = gp_Pnt()
|
|
||||||
d1u = gp_Vec()
|
|
||||||
d1v = gp_Vec()
|
|
||||||
surf.D1(u_pos, v_pos, center_pt, d1u, d1v)
|
|
||||||
# Normal is cross product of tangent vectors
|
|
||||||
normal = d1u.Crossed(d1v)
|
|
||||||
normal.Normalize()
|
|
||||||
|
|
||||||
# Calculate number of pyramids in this ring based on spacing
|
|
||||||
if ring_idx == 0:
|
|
||||||
num_pyramids = 1 # Center pyramid
|
|
||||||
else:
|
|
||||||
circumference = 2.0 * math.pi * (ring_idx * spacing)
|
|
||||||
num_pyramids = max(3, int(circumference / spacing))
|
|
||||||
|
|
||||||
for i in range(num_pyramids):
|
|
||||||
if ring_idx == 0:
|
|
||||||
# Center pyramid - place at face center
|
|
||||||
place_u = u_pos
|
|
||||||
place_v = v_pos
|
|
||||||
else:
|
|
||||||
angle = (2.0 * math.pi * i) / num_pyramids
|
|
||||||
# Offset in UV space based on ring radius
|
|
||||||
offset_u = (ring_idx * spacing / (u_max - u_min)) * math.cos(angle)
|
|
||||||
offset_v = (ring_idx * spacing / (v_max - v_min)) * math.sin(angle)
|
|
||||||
place_u = max(u_min, min(u_max, u_pos + offset_u))
|
|
||||||
place_v = max(v_min, min(v_max, v_pos + offset_v))
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get 3D position and normal for this pyramid
|
|
||||||
pyramid_pt = gp_Pnt()
|
|
||||||
pd1u = gp_Vec()
|
|
||||||
pd1v = gp_Vec()
|
|
||||||
surf.D1(place_u, place_v, pyramid_pt, pd1u, pd1v)
|
|
||||||
pyramid_normal = pd1u.Crossed(pd1v)
|
|
||||||
pyramid_normal.Normalize()
|
|
||||||
|
|
||||||
# Create solid pyramid at this position
|
|
||||||
pyramid_shape = self._create_solid_pyramid(
|
|
||||||
pyramid_pt,
|
|
||||||
pyramid_normal,
|
|
||||||
pyramid_height,
|
|
||||||
base_radius,
|
|
||||||
)
|
|
||||||
|
|
||||||
if pyramid_shape is not None:
|
|
||||||
# Fuse with existing geometry
|
|
||||||
fuse = BRepAlgoAPI_Fuse(result_shape, pyramid_shape)
|
|
||||||
fuse.Build()
|
|
||||||
|
|
||||||
if fuse.IsDone():
|
|
||||||
result_shape = fuse.Shape()
|
|
||||||
pyramid_count += 1
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"Failed to fuse pyramid at ({place_u:.2f}, {place_v:.2f})"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(
|
|
||||||
f"Error creating pyramid at ring {ring_idx}, pyramid {i}: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._patterns_applied.append(
|
|
||||||
{
|
|
||||||
"type": "pyramid",
|
|
||||||
"parameters": {
|
|
||||||
"height": pyramid_height,
|
|
||||||
"base_radius": base_radius,
|
|
||||||
"spacing": spacing,
|
|
||||||
"num_rings": num_rings,
|
|
||||||
"direction": direction,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Successfully applied {pyramid_count} pyramids")
|
|
||||||
|
|
||||||
return result_shape
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error applying pyramid pattern: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _create_solid_pyramid(
|
|
||||||
self,
|
|
||||||
base_point, # gp_Pnt - position on the face
|
|
||||||
normal_vec, # gp_Dir or gp_Vec - surface normal direction
|
|
||||||
height: float,
|
|
||||||
base_radius: float,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
"""Create a solid pyramid at the specified position and orientation.
|
|
||||||
|
|
||||||
Uses BRepPrimAPI_MakePrism to extrude a square base into a solid pyramid.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_point: 3D point where pyramid base is centered
|
|
||||||
normal_vec: Direction vector for pyramid growth (surface normal)
|
|
||||||
height: Height of the pyramid from base to apex
|
|
||||||
base_radius: Half-width of the square base
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OCC solid shape for the pyramid, or None on failure
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from OCP.gp import gp_Dir, gp_Ax2, gp_Vec
|
|
||||||
from OCP.BRepBuilderAPI import (
|
|
||||||
BRepBuilderAPI_MakeEdge,
|
|
||||||
BRepBuilderAPI_MakeWire,
|
|
||||||
)
|
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
|
||||||
|
|
||||||
half = base_radius / 2.0
|
|
||||||
|
|
||||||
# Build orthonormal basis from normal vector
|
|
||||||
if isinstance(normal_vec, gp_Vec):
|
|
||||||
n_dir = gp_Dir(normal_vec.XYZ())
|
|
||||||
else:
|
|
||||||
n_dir = normal_vec
|
|
||||||
|
|
||||||
# Create a local coordinate system at the base point
|
|
||||||
local_ax2 = gp_Ax2(base_point, n_dir)
|
|
||||||
|
|
||||||
# Get X and Y axes from the local coordinate system
|
|
||||||
x_dir = local_ax2.XDirection()
|
|
||||||
y_dir = local_ax2.YDirection()
|
|
||||||
|
|
||||||
# Create 4 corners of the square base in the local plane
|
|
||||||
corner_points = [
|
|
||||||
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(half),
|
|
||||||
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(half),
|
|
||||||
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(-half),
|
|
||||||
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(-half),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Create edges connecting the corners
|
|
||||||
wire_maker = BRepBuilderAPI_MakeWire()
|
|
||||||
for idx in range(4):
|
|
||||||
next_idx = (idx + 1) % 4
|
|
||||||
edge = BRepBuilderAPI_MakeEdge(
|
|
||||||
corner_points[idx], corner_points[next_idx]
|
|
||||||
).Edge()
|
|
||||||
wire_maker.Add(edge)
|
|
||||||
|
|
||||||
if not wire_maker.IsDone():
|
|
||||||
logger.warning("Failed to create pyramid base wire")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Extrude the base wire in the normal direction by height to form a prism
|
|
||||||
extrusion_vec = gp_Vec(n_dir).Multiplied(height)
|
|
||||||
prism_maker = BRepPrimAPI_MakePrism(
|
|
||||||
wire_maker.Wire(), extrusion_vec, False # no check intersection
|
|
||||||
)
|
|
||||||
prism_maker.Build()
|
|
||||||
|
|
||||||
if not prism_maker.IsDone():
|
|
||||||
logger.warning("Failed to create pyramid prism")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return prism_maker.Shape()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error creating solid pyramid: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def apply_bump_pattern(
|
|
||||||
self,
|
|
||||||
face_shape,
|
|
||||||
bump_height: float = 1.0,
|
|
||||||
bump_radius: float = 2.0,
|
|
||||||
spacing: float = 5.0,
|
|
||||||
num_rings: Optional[int] = None,
|
|
||||||
) -> Optional[Any]:
|
|
||||||
"""Apply a simple bump pattern to a face surface.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
face_shape: OCC TopoDS_Shape representing the face
|
|
||||||
bump_height: Height of each bump
|
|
||||||
bump_radius: Radius of each bump base
|
|
||||||
spacing: Distance between bumps
|
|
||||||
num_rings: Number of concentric rings
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Modified shape on success, None on failure
|
|
||||||
"""
|
|
||||||
return self.apply_pyramid_pattern(
|
|
||||||
face_shape,
|
|
||||||
pyramid_height=bump_height,
|
|
||||||
base_radius=bump_radius,
|
|
||||||
spacing=spacing,
|
|
||||||
num_rings=num_rings,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_surface_modifier_to_body(
|
|
||||||
body_geometry, modifier_type: str = "pyramid", **parameters
|
|
||||||
) -> Optional[Any]:
|
|
||||||
"""Apply a surface modifier to a body geometry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
body_geometry: OCCGeometryObject or similar geometry object
|
|
||||||
modifier_type: Type of modifier ('pyramid', 'bump')
|
|
||||||
**parameters: Modifier-specific parameters
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Modified shape, or None on failure
|
|
||||||
"""
|
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
|
||||||
|
|
||||||
kernel = OCGeometryKernel()
|
|
||||||
shape = kernel._get_shape(body_geometry)
|
|
||||||
|
|
||||||
if shape is None:
|
|
||||||
logger.error("No geometry found in body")
|
|
||||||
return None
|
|
||||||
|
|
||||||
modifier = SurfaceModifier()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if modifier_type == "pyramid":
|
|
||||||
success = modifier.apply_pyramid_pattern(shape, **parameters)
|
|
||||||
elif modifier_type == "bump":
|
|
||||||
success = modifier.apply_bump_pattern(shape, **parameters)
|
|
||||||
else:
|
|
||||||
logger.error(f"Unknown modifier type: {modifier_type}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
logger.error("Surface modifier application failed")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Return the modified shape wrapped in OCCGeometryObject
|
|
||||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
|
||||||
|
|
||||||
return OCCGeometryObject(shape)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error applying surface modifier: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Example usage and testing
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Create a simple test case
|
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
|
||||||
|
|
||||||
# Create a box to modify
|
|
||||||
box_maker = BRepPrimAPI_MakeBox(50, 50, 10)
|
|
||||||
box_maker.Build()
|
|
||||||
|
|
||||||
if box_maker.IsDone():
|
|
||||||
print("Created test box")
|
|
||||||
|
|
||||||
# Apply pyramid pattern to top face (Z direction)
|
|
||||||
modifier = SurfaceModifier()
|
|
||||||
success = modifier.apply_pyramid_pattern(
|
|
||||||
box_maker.Shape(),
|
|
||||||
pyramid_height=2.0,
|
|
||||||
base_radius=3.0,
|
|
||||||
spacing=8.0,
|
|
||||||
num_rings=2,
|
|
||||||
direction=(0, 0, 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print("Successfully applied pyramid pattern")
|
|
||||||
|
|
||||||
# Export modified shape
|
|
||||||
from OCP.StlAPI import StlAPI_Writer
|
|
||||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
|
||||||
|
|
||||||
tess = BRepMesh_IncrementalMesh(box_maker.Shape(), 0.1)
|
|
||||||
tess.Perform()
|
|
||||||
|
|
||||||
writer = StlAPI_Writer()
|
|
||||||
writer.SetASCIIMode(False)
|
|
||||||
writer.Write(box_maker.Shape(), "/tmp/test_pyramid_pattern.stl")
|
|
||||||
print("Exported modified shape to STL")
|
|
||||||
else:
|
|
||||||
print("Failed to apply pyramid pattern")
|
|
||||||
else:
|
|
||||||
print("Failed to create test box")
|
|
||||||
+50
-205
@@ -28,7 +28,6 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
|
||||||
import zipfile
|
import zipfile
|
||||||
from dataclasses import asdict, is_dataclass
|
from dataclasses import asdict, is_dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -43,7 +42,6 @@ from fluency.models.data_model import (
|
|||||||
Body,
|
Body,
|
||||||
Component,
|
Component,
|
||||||
Connector,
|
Connector,
|
||||||
Feature,
|
|
||||||
Project,
|
Project,
|
||||||
Sketch,
|
Sketch,
|
||||||
Workplane,
|
Workplane,
|
||||||
@@ -67,7 +65,7 @@ def _json_default(obj: Any) -> Any:
|
|||||||
return sorted(obj)
|
return sorted(obj)
|
||||||
if isinstance(obj, tuple):
|
if isinstance(obj, tuple):
|
||||||
return list(obj)
|
return list(obj)
|
||||||
if is_dataclass(obj) and not isinstance(obj, type):
|
if is_dataclass(obj):
|
||||||
return asdict(obj)
|
return asdict(obj)
|
||||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||||
|
|
||||||
@@ -87,77 +85,42 @@ 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:
|
seq = list(value)
|
||||||
seq = list(value)
|
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:
|
|
||||||
return np.zeros(3, dtype=float)
|
|
||||||
seq = list(value)
|
|
||||||
if len(seq) < 3:
|
|
||||||
seq = list(seq) + [0.0] * (3 - len(seq))
|
|
||||||
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
|
|
||||||
except (TypeError, ValueError, IndexError):
|
|
||||||
return np.zeros(3, dtype=float)
|
return np.zeros(3, dtype=float)
|
||||||
|
seq = list(value)
|
||||||
|
if len(seq) < 3:
|
||||||
|
seq = list(seq) + [0.0] * (3 - len(seq))
|
||||||
|
return np.array([float(seq[0]), float(seq[1]), float(seq[2])], dtype=float)
|
||||||
|
|
||||||
|
|
||||||
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)
|
if value is None:
|
||||||
if value is None:
|
|
||||||
return np.eye(3, dtype=float)
|
|
||||||
flat = list(np.asarray(value, dtype=float).flatten())
|
|
||||||
if len(flat) < 9:
|
|
||||||
flat = flat + [0.0] * (9 - len(flat))
|
|
||||||
return np.array(flat[:9], dtype=float).reshape(3, 3)
|
|
||||||
except (TypeError, ValueError, IndexError):
|
|
||||||
return np.eye(3, dtype=float)
|
return np.eye(3, dtype=float)
|
||||||
|
flat = list(np.asarray(value, dtype=float).flatten())
|
||||||
|
if len(flat) < 9:
|
||||||
|
flat = flat + [0.0] * (9 - len(flat))
|
||||||
|
return np.array(flat[:9], dtype=float).reshape(3, 3)
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(value: Optional[str]) -> datetime:
|
def _parse_iso(value: Optional[str]) -> datetime:
|
||||||
@@ -188,11 +151,11 @@ def _workplane_to_dict(wp: Workplane) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
|
def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
|
||||||
wp = Workplane(
|
wp = Workplane(
|
||||||
id=_saved_id(data),
|
id=data.get("id") or None, # Workplane generates uuid if None
|
||||||
name=data.get("name", "Untitled Workplane"),
|
name=data.get("name", "Untitled Workplane"),
|
||||||
origin=_to_3tuple(data.get("origin", (0.0, 0.0, 0.0))),
|
origin=tuple(data.get("origin", (0.0, 0.0, 0.0))),
|
||||||
normal=_to_3tuple(data.get("normal", (0.0, 0.0, 1.0))),
|
normal=tuple(data.get("normal", (0.0, 0.0, 1.0))),
|
||||||
x_dir=_to_3tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
|
x_dir=tuple(data.get("x_dir", (1.0, 0.0, 0.0))),
|
||||||
visible=bool(data.get("visible", True)),
|
visible=bool(data.get("visible", True)),
|
||||||
)
|
)
|
||||||
wp.created_at = _parse_iso(data.get("created_at"))
|
wp.created_at = _parse_iso(data.get("created_at"))
|
||||||
@@ -200,76 +163,6 @@ def _workplane_from_dict(data: Dict[str, Any]) -> Workplane:
|
|||||||
return wp
|
return wp
|
||||||
|
|
||||||
|
|
||||||
def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
|
|
||||||
"""Serialize one parametric feature (sketch id + params).
|
|
||||||
|
|
||||||
"base" snapshot features are NOT serialized here — their frozen
|
|
||||||
geometry is written as a separate STEP member (``base_geometry_ref``)
|
|
||||||
and the ``features_base_snapshot`` flag on the body marks that the
|
|
||||||
list starts with one.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"id": feat.id,
|
|
||||||
"operation": feat.operation,
|
|
||||||
"sketch_id": feat.sketch.id if feat.sketch is not None else None,
|
|
||||||
"length": feat.length,
|
|
||||||
"symmetric": bool(feat.symmetric),
|
|
||||||
"invert": bool(feat.invert),
|
|
||||||
"through_all": bool(feat.through_all),
|
|
||||||
"cut_all_bodies": bool(feat.cut_all_bodies),
|
|
||||||
"face_index": feat.face_index,
|
|
||||||
"angle": _to_float(feat.angle, 360.0),
|
|
||||||
"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))],
|
|
||||||
"mirror_plane_origin": [float(v) for v in (feat.mirror_plane_origin or (0.0, 0.0, 0.0))],
|
|
||||||
"mirror_plane_normal": [float(v) for v in (feat.mirror_plane_normal or (1, 0, 0))],
|
|
||||||
"keep_original": bool(feat.keep_original),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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))),
|
|
||||||
mirror_plane_origin=tuple(float(v) for v in (data.get("mirror_plane_origin") or (0.0, 0.0, 0.0))),
|
|
||||||
mirror_plane_normal=tuple(float(v) for v in (data.get("mirror_plane_normal") or (1, 0, 0))),
|
|
||||||
keep_original=bool(data.get("keep_original", True)),
|
|
||||||
)
|
|
||||||
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."""
|
||||||
@@ -284,16 +177,12 @@ def _body_to_dict(body: Body) -> Dict[str, Any]:
|
|||||||
"extrude_cut": body.extrude_cut,
|
"extrude_cut": body.extrude_cut,
|
||||||
"extrude_union": body.extrude_union,
|
"extrude_union": body.extrude_union,
|
||||||
"extrude_through_all": body.extrude_through_all,
|
"extrude_through_all": body.extrude_through_all,
|
||||||
"extrude_cut_all_bodies": body.extrude_cut_all_bodies,
|
|
||||||
"extrude_face_index": body.extrude_face_index,
|
"extrude_face_index": body.extrude_face_index,
|
||||||
"extrude_target_body_id": body.extrude_target_body_id,
|
"extrude_target_body_id": body.extrude_target_body_id,
|
||||||
"features": [_feature_to_dict(f) for f in body.features if f.operation != "base"],
|
|
||||||
"features_base_snapshot": bool(body.features and body.features[0].operation == "base"),
|
|
||||||
"base_geometry_ref": None, # filled in by save_project
|
|
||||||
"position": _coerce_listlike(body.position),
|
"position": _coerce_listlike(body.position),
|
||||||
"rotation": _coerce_listlike(body.rotation),
|
"rotation": _coerce_listlike(body.rotation),
|
||||||
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
|
"color": list(body.color) if body.color else [0.2, 0.4, 0.8],
|
||||||
"opacity": _to_float(body.opacity, 1.0),
|
"opacity": float(body.opacity),
|
||||||
"visible": bool(body.visible),
|
"visible": bool(body.visible),
|
||||||
"has_geometry": body.geometry is not None,
|
"has_geometry": body.geometry is not None,
|
||||||
"geometry_ref": None, # filled in by save_project
|
"geometry_ref": None, # filled in by save_project
|
||||||
@@ -312,7 +201,7 @@ def _body_from_dict(
|
|||||||
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||||
|
|
||||||
body = Body(
|
body = Body(
|
||||||
id=_saved_id(data),
|
id=data.get("id") or None,
|
||||||
name=data.get("name", "Untitled Body"),
|
name=data.get("name", "Untitled Body"),
|
||||||
geometry=geometry,
|
geometry=geometry,
|
||||||
source_sketch=source_sketch,
|
source_sketch=source_sketch,
|
||||||
@@ -323,13 +212,12 @@ def _body_from_dict(
|
|||||||
extrude_cut=bool(data.get("extrude_cut", False)),
|
extrude_cut=bool(data.get("extrude_cut", False)),
|
||||||
extrude_union=bool(data.get("extrude_union", False)),
|
extrude_union=bool(data.get("extrude_union", False)),
|
||||||
extrude_through_all=bool(data.get("extrude_through_all", False)),
|
extrude_through_all=bool(data.get("extrude_through_all", False)),
|
||||||
extrude_cut_all_bodies=bool(data.get("extrude_cut_all_bodies", False)),
|
|
||||||
extrude_face_index=data.get("extrude_face_index"),
|
extrude_face_index=data.get("extrude_face_index"),
|
||||||
extrude_target_body_id=data.get("extrude_target_body_id"),
|
extrude_target_body_id=data.get("extrude_target_body_id"),
|
||||||
position=_to_3vec(data.get("position")),
|
position=_to_3vec(data.get("position")),
|
||||||
rotation=_to_mat3(data.get("rotation")),
|
rotation=_to_mat3(data.get("rotation")),
|
||||||
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
|
color=tuple(data.get("color", [0.2, 0.4, 0.8])),
|
||||||
opacity=_to_float(data.get("opacity"), 1.0),
|
opacity=float(data.get("opacity", 1.0)),
|
||||||
visible=bool(data.get("visible", True)),
|
visible=bool(data.get("visible", True)),
|
||||||
)
|
)
|
||||||
body.created_at = _parse_iso(data.get("created_at"))
|
body.created_at = _parse_iso(data.get("created_at"))
|
||||||
@@ -379,9 +267,9 @@ def _sketch_from_dict(
|
|||||||
# Re-apply the workplane (from_dict already does this internally, but be
|
# Re-apply the workplane (from_dict already does this internally, but be
|
||||||
# defensive in case the saved dict didn't carry the workplane fields).
|
# defensive in case the saved dict didn't carry the workplane fields).
|
||||||
occ_sketch.set_workplane(
|
occ_sketch.set_workplane(
|
||||||
_to_3tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
|
tuple(data.get("workplane_origin", (0.0, 0.0, 0.0))),
|
||||||
_to_3tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
|
tuple(data.get("workplane_normal", (0.0, 0.0, 1.0))),
|
||||||
_to_3tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
|
tuple(data.get("workplane_x_dir", (1.0, 0.0, 0.0))),
|
||||||
)
|
)
|
||||||
|
|
||||||
geometry: Optional[OCCGeometryObject] = None
|
geometry: Optional[OCCGeometryObject] = None
|
||||||
@@ -389,7 +277,7 @@ def _sketch_from_dict(
|
|||||||
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
geometry = geometry_loader(data["geometry_ref"]) if data.get("has_geometry") else None
|
||||||
|
|
||||||
sk = Sketch(
|
sk = Sketch(
|
||||||
id=_saved_id(data),
|
id=data.get("id") or None,
|
||||||
name=data.get("name", "Untitled Sketch"),
|
name=data.get("name", "Untitled Sketch"),
|
||||||
occ_sketch=occ_sketch,
|
occ_sketch=occ_sketch,
|
||||||
geometry=geometry,
|
geometry=geometry,
|
||||||
@@ -426,7 +314,7 @@ def _component_from_dict(
|
|||||||
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
|
||||||
) -> Component:
|
) -> Component:
|
||||||
comp = Component(
|
comp = Component(
|
||||||
id=_saved_id(data),
|
id=data.get("id") or None,
|
||||||
name=data.get("name", "Untitled Component"),
|
name=data.get("name", "Untitled Component"),
|
||||||
description=data.get("description", ""),
|
description=data.get("description", ""),
|
||||||
active_sketch=data.get("active_sketch"),
|
active_sketch=data.get("active_sketch"),
|
||||||
@@ -447,22 +335,7 @@ def _component_from_dict(
|
|||||||
src_id = body_data.get("source_sketch_id")
|
src_id = body_data.get("source_sketch_id")
|
||||||
if src_id and src_id in comp.sketches:
|
if src_id and src_id in comp.sketches:
|
||||||
src_sketch = comp.sketches[src_id]
|
src_sketch = comp.sketches[src_id]
|
||||||
body = _body_from_dict(body_data, body_geometry_loader, src_sketch)
|
comp.bodies[bid] = _body_from_dict(body_data, body_geometry_loader, src_sketch)
|
||||||
# Parametric feature history (new files). Old files carry no
|
|
||||||
# "features" key — the body keeps an empty list and is migrated
|
|
||||||
# lazily at update time (see ``_ensure_feature_history``).
|
|
||||||
for f_data in body_data.get("features") or []:
|
|
||||||
body.features.append(_feature_from_dict(f_data, comp.sketches))
|
|
||||||
if body_data.get("features_base_snapshot") and body.features:
|
|
||||||
# The list was saved WITHOUT its leading "base" snapshot;
|
|
||||||
# restore it from the dedicated STEP member.
|
|
||||||
base_geom: Optional[OCCGeometryObject] = None
|
|
||||||
base_ref = body_data.get("base_geometry_ref")
|
|
||||||
if base_ref and body_geometry_loader is not None:
|
|
||||||
base_geom = body_geometry_loader(base_ref)
|
|
||||||
if base_geom is not None:
|
|
||||||
body.features.insert(0, Feature(operation="base", geometry=base_geom))
|
|
||||||
comp.bodies[bid] = body
|
|
||||||
|
|
||||||
return comp
|
return comp
|
||||||
|
|
||||||
@@ -474,8 +347,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
|||||||
"position": list(conn.position),
|
"position": list(conn.position),
|
||||||
"normal": list(conn.normal),
|
"normal": list(conn.normal),
|
||||||
"x_dir": list(conn.x_dir),
|
"x_dir": list(conn.x_dir),
|
||||||
"axis_rotation": _to_float(conn.axis_rotation, 0.0),
|
"axis_rotation": float(conn.axis_rotation),
|
||||||
"offset": _to_float(conn.offset, 0.0),
|
"offset": float(conn.offset),
|
||||||
"assembly_component_id": conn.assembly_component_id,
|
"assembly_component_id": conn.assembly_component_id,
|
||||||
"source_obj_id": conn.source_obj_id,
|
"source_obj_id": conn.source_obj_id,
|
||||||
"partner_ac_id": conn.partner_ac_id,
|
"partner_ac_id": conn.partner_ac_id,
|
||||||
@@ -489,13 +362,13 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def _connector_from_dict(data: Dict[str, Any]) -> Connector:
|
def _connector_from_dict(data: Dict[str, Any]) -> Connector:
|
||||||
conn = Connector(
|
conn = Connector(
|
||||||
id=_saved_id(data),
|
id=data.get("id") or None,
|
||||||
name=data.get("name", "Untitled Connector"),
|
name=data.get("name", "Untitled Connector"),
|
||||||
position=_to_3tuple(data.get("position")),
|
position=_to_3tuple(data.get("position")),
|
||||||
normal=_to_3tuple(data.get("normal")),
|
normal=_to_3tuple(data.get("normal")),
|
||||||
x_dir=_to_3tuple(data.get("x_dir")),
|
x_dir=_to_3tuple(data.get("x_dir")),
|
||||||
axis_rotation=_to_float(data.get("axis_rotation"), 0.0),
|
axis_rotation=float(data.get("axis_rotation", 0.0)),
|
||||||
offset=_to_float(data.get("offset"), 0.0),
|
offset=float(data.get("offset", 0.0)),
|
||||||
assembly_component_id=data.get("assembly_component_id", ""),
|
assembly_component_id=data.get("assembly_component_id", ""),
|
||||||
source_obj_id=data.get("source_obj_id", ""),
|
source_obj_id=data.get("source_obj_id", ""),
|
||||||
)
|
)
|
||||||
@@ -523,7 +396,7 @@ def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
|
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
|
||||||
ac = AssemblyComponent(
|
ac = AssemblyComponent(
|
||||||
id=_saved_id(data),
|
id=data.get("id") or None,
|
||||||
component_id=data.get("component_id", ""),
|
component_id=data.get("component_id", ""),
|
||||||
name=data.get("name", "Untitled Instance"),
|
name=data.get("name", "Untitled Instance"),
|
||||||
position=_to_3vec(data.get("position")),
|
position=_to_3vec(data.get("position")),
|
||||||
@@ -549,7 +422,7 @@ def _assembly_connection_to_dict(c: AssemblyConnection) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
|
def _assembly_connection_from_dict(data: Dict[str, Any]) -> AssemblyConnection:
|
||||||
conn = AssemblyConnection(
|
conn = AssemblyConnection(
|
||||||
id=_saved_id(data),
|
id=data.get("id") or None,
|
||||||
first_ac_id=data.get("first_ac_id", ""),
|
first_ac_id=data.get("first_ac_id", ""),
|
||||||
second_ac_id=data.get("second_ac_id", ""),
|
second_ac_id=data.get("second_ac_id", ""),
|
||||||
first_connector_id=data.get("first_connector_id"),
|
first_connector_id=data.get("first_connector_id"),
|
||||||
@@ -573,7 +446,7 @@ def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
|
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
|
||||||
asm = Assembly(
|
asm = Assembly(
|
||||||
id=_saved_id(data),
|
id=data.get("id") or None,
|
||||||
name=data.get("name", "Untitled Assembly"),
|
name=data.get("name", "Untitled Assembly"),
|
||||||
active_assembly_component=data.get("active_assembly_component"),
|
active_assembly_component=data.get("active_assembly_component"),
|
||||||
)
|
)
|
||||||
@@ -581,7 +454,7 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
|
|||||||
asm.modified_at = _parse_iso(data.get("modified_at"))
|
asm.modified_at = _parse_iso(data.get("modified_at"))
|
||||||
for cid, ac_data in (data.get("components") or {}).items():
|
for cid, ac_data in (data.get("components") or {}).items():
|
||||||
asm.components[cid] = _assembly_component_from_dict(ac_data)
|
asm.components[cid] = _assembly_component_from_dict(ac_data)
|
||||||
for c_data in data.get("connections") or []:
|
for c_data in (data.get("connections") or []):
|
||||||
asm.connections.append(_assembly_connection_from_dict(c_data))
|
asm.connections.append(_assembly_connection_from_dict(c_data))
|
||||||
return asm
|
return asm
|
||||||
|
|
||||||
@@ -644,9 +517,7 @@ def _read_step_bytes(
|
|||||||
with open(tmp_path, "wb") as f:
|
with open(tmp_path, "wb") as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
geom = kernel.import_step(tmp_path)
|
geom = kernel.import_step(tmp_path)
|
||||||
from typing import cast
|
return geom
|
||||||
|
|
||||||
return cast(OCCGeometryObject, geom)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Failed to read STEP: %s", exc)
|
logger.warning("Failed to read STEP: %s", exc)
|
||||||
return None
|
return None
|
||||||
@@ -709,20 +580,6 @@ def save_project(
|
|||||||
arcname = f"bodies/{body_id}.step"
|
arcname = f"bodies/{body_id}.step"
|
||||||
body_files.append((arcname, step_bytes))
|
body_files.append((arcname, step_bytes))
|
||||||
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
|
manifest["components"][comp_id]["bodies"][body_id]["geometry_ref"] = arcname
|
||||||
# Base-snapshot STEP for migrated legacy bodies whose feature
|
|
||||||
# list starts with a frozen "base" geometry snapshot.
|
|
||||||
if (
|
|
||||||
body.features
|
|
||||||
and body.features[0].operation == "base"
|
|
||||||
and body.features[0].geometry is not None
|
|
||||||
):
|
|
||||||
base_bytes = _write_step_for_body(kernel, body.features[0].geometry)
|
|
||||||
if base_bytes is not None:
|
|
||||||
base_arcname = f"bodies/{body_id}_base.step"
|
|
||||||
body_files.append((base_arcname, base_bytes))
|
|
||||||
manifest["components"][comp_id]["bodies"][body_id]["base_geometry_ref"] = (
|
|
||||||
base_arcname
|
|
||||||
)
|
|
||||||
|
|
||||||
# Per-sketch STEP files (solved face geometry).
|
# Per-sketch STEP files (solved face geometry).
|
||||||
sketch_files: List[Tuple[str, bytes]] = []
|
sketch_files: List[Tuple[str, bytes]] = []
|
||||||
@@ -815,10 +672,7 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
|||||||
|
|
||||||
with zipfile.ZipFile(filepath, "r") as zipf:
|
with zipfile.ZipFile(filepath, "r") as zipf:
|
||||||
manifest_raw = zipf.read("project.json")
|
manifest_raw = zipf.read("project.json")
|
||||||
try:
|
manifest = json.loads(manifest_raw.decode("utf-8"))
|
||||||
manifest = json.loads(manifest_raw.decode("utf-8"))
|
|
||||||
except (ValueError, UnicodeDecodeError) as exc:
|
|
||||||
raise RuntimeError(f"Corrupt project file (bad JSON): {filepath}") from exc
|
|
||||||
view_state: Dict[str, Any] = manifest.get("view_state") or {}
|
view_state: Dict[str, Any] = manifest.get("view_state") or {}
|
||||||
|
|
||||||
# If a sketch's occ_sketch is referenced as a separate file, read
|
# If a sketch's occ_sketch is referenced as a separate file, read
|
||||||
@@ -833,21 +687,12 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
logger.warning("Sketch meta missing in archive: %s", ref)
|
logger.warning("Sketch meta missing in archive: %s", ref)
|
||||||
continue
|
continue
|
||||||
try:
|
meta = json.loads(meta_bytes.decode("utf-8"))
|
||||||
meta = json.loads(meta_bytes.decode("utf-8"))
|
|
||||||
except (ValueError, UnicodeDecodeError) as exc:
|
|
||||||
logger.warning("Sketch meta corrupt in archive: %s (%s)", ref, exc)
|
|
||||||
continue
|
|
||||||
sk_data["occ_sketch"] = meta.get("occ_sketch")
|
sk_data["occ_sketch"] = meta.get("occ_sketch")
|
||||||
# Workplane fields on the sketch-level file override the
|
# Workplane fields on the sketch-level file override the
|
||||||
# embedded ones (source of truth lives in the sidecar).
|
# embedded ones (source of truth lives in the sidecar).
|
||||||
for k in (
|
for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
|
||||||
"workplane_origin",
|
"is_solved", "is_fully_constrained"):
|
||||||
"workplane_normal",
|
|
||||||
"workplane_x_dir",
|
|
||||||
"is_solved",
|
|
||||||
"is_fully_constrained",
|
|
||||||
):
|
|
||||||
if k in meta:
|
if k in meta:
|
||||||
sk_data[k] = meta[k]
|
sk_data[k] = meta[k]
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ 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
|
||||||
@@ -38,7 +37,6 @@ __all__ = [
|
|||||||
"ExtrudeDialog",
|
"ExtrudeDialog",
|
||||||
"RevolveDialog",
|
"RevolveDialog",
|
||||||
"OffsetDialog",
|
"OffsetDialog",
|
||||||
"ThreadDialog",
|
|
||||||
"WorkplaneOrientationDialog",
|
"WorkplaneOrientationDialog",
|
||||||
"main",
|
"main",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ class Workplane:
|
|||||||
x = x / x_norm
|
x = x / x_norm
|
||||||
y = np.cross(n, x)
|
y = np.cross(n, x)
|
||||||
y = y / np.linalg.norm(y)
|
y = y / np.linalg.norm(y)
|
||||||
self.normal = (float(n[0]), float(n[1]), float(n[2]))
|
self.normal = tuple(float(v) for v in n)
|
||||||
self.x_dir = (float(x[0]), float(x[1]), float(x[2]))
|
self.x_dir = tuple(float(v) for v in x)
|
||||||
self._y_dir = (float(y[0]), float(y[1]), float(y[2]))
|
self._y_dir = tuple(float(v) for v in y)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def y_dir(self) -> Tuple[float, float, float]:
|
def y_dir(self) -> Tuple[float, float, float]:
|
||||||
@@ -206,93 +206,6 @@ class Sketch:
|
|||||||
self.modified_at = datetime.now()
|
self.modified_at = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Feature:
|
|
||||||
"""
|
|
||||||
One operation in a body's parametric feature history.
|
|
||||||
|
|
||||||
Bodies rebuild their geometry by replaying their ordered feature
|
|
||||||
list from scratch (see ``Body.features``). This is what makes
|
|
||||||
sketch edits propagate: a moved circle re-cuts at the new position
|
|
||||||
on a freshly rebuilt base instead of adding to the previous result.
|
|
||||||
|
|
||||||
``operation`` is one of:
|
|
||||||
- "extrude": base solid, ``kernel.extrude`` of the sketch profile
|
|
||||||
- "revolve": base solid, ``kernel.revolve`` of the sketch profile
|
|
||||||
- "cut": boolean difference of the running geometry with the
|
|
||||||
extruded sketch profile
|
|
||||||
- "union": boolean union of the running geometry with the
|
|
||||||
extruded sketch profile
|
|
||||||
- "fillet": round a set of edges of the running geometry
|
|
||||||
(``radius``, ``tangent_propagation``, ``scope``,
|
|
||||||
``edge_refs`` — see below)
|
|
||||||
- "chamfer": bevel a set of edges of the running geometry
|
|
||||||
(same fields as "fillet"; ``radius`` holds the
|
|
||||||
chamfer size)
|
|
||||||
- "mirror": mirror the running solid across a plane defined by
|
|
||||||
``mirror_plane_origin`` and ``mirror_plane_normal``.
|
|
||||||
When ``keep_original`` is True (the default), the
|
|
||||||
original solid is boolean-union'd with its mirror
|
|
||||||
- "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)
|
|
||||||
|
|
||||||
# "mirror" feature only: mirror plane definition.
|
|
||||||
mirror_plane_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
|
||||||
mirror_plane_normal: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
|
||||||
keep_original: bool = True
|
|
||||||
|
|
||||||
created_at: datetime = field(default_factory=datetime.now)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Body:
|
class Body:
|
||||||
"""
|
"""
|
||||||
@@ -309,12 +222,6 @@ class Body:
|
|||||||
source_sketch: Optional[Sketch] = None
|
source_sketch: Optional[Sketch] = None
|
||||||
source_operation: str = "extrude"
|
source_operation: str = "extrude"
|
||||||
|
|
||||||
# Parametric feature history. When non-empty, the body is rebuilt
|
|
||||||
# from scratch by replaying these features in order; the flat
|
|
||||||
# extrude_* / source_* fields below then only mirror the LAST
|
|
||||||
# feature for backward compatibility (old files, old code paths).
|
|
||||||
features: List[Feature] = field(default_factory=list)
|
|
||||||
|
|
||||||
# Re-extrusion parameters — stored so the body can be rebuilt from
|
# Re-extrusion parameters — stored so the body can be rebuilt from
|
||||||
# its source sketch when the sketch is edited. None means the body
|
# its source sketch when the sketch is edited. None means the body
|
||||||
# was not created by an extrude-type operation and cannot be auto-
|
# was not created by an extrude-type operation and cannot be auto-
|
||||||
@@ -325,7 +232,6 @@ class Body:
|
|||||||
extrude_cut: bool = False
|
extrude_cut: bool = False
|
||||||
extrude_union: bool = False
|
extrude_union: bool = False
|
||||||
extrude_through_all: bool = False
|
extrude_through_all: bool = False
|
||||||
extrude_cut_all_bodies: bool = False # cut through all bodies in component
|
|
||||||
extrude_face_index: Optional[int] = None # which sketch face was selected
|
extrude_face_index: Optional[int] = None # which sketch face was selected
|
||||||
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
|
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
|
||||||
needs_update: bool = False # True when source sketch changed since last extrude
|
needs_update: bool = False # True when source sketch changed since last extrude
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -187,29 +187,19 @@ class SolverSketch(SolverSystem):
|
|||||||
def constrain_distance(
|
def constrain_distance(
|
||||||
self, entity_a, entity_b, distance: float
|
self, entity_a, entity_b, distance: float
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Constrain distance between point-point or point-line.
|
"""Constrain distance between point-point or point-line."""
|
||||||
|
|
||||||
python-solvespace's ``distance`` accepts (point, line) ordering only,
|
|
||||||
so line-point pairs are normalised; line-line pairs and a point
|
|
||||||
constrained to itself are rejected (the solver cannot hold them).
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
|
handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle
|
||||||
logger.warning("distance: line-to-line distance is not supported")
|
handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle
|
||||||
return False
|
|
||||||
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
|
|
||||||
# Normalise to (point, line) ordering.
|
|
||||||
entity_a, entity_b = entity_b, entity_a
|
|
||||||
if entity_a is entity_b and distance != 0.0:
|
|
||||||
logger.warning("distance: refusing point-to-itself constraint")
|
|
||||||
return False
|
|
||||||
handle_a = entity_a.handle
|
|
||||||
handle_b = entity_b.handle
|
|
||||||
|
|
||||||
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
|
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
|
||||||
self.distance(handle_a, handle_b, distance, self.wp)
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
|
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
|
||||||
|
self.distance(handle_b, handle_a, distance, self.wp)
|
||||||
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
|
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
|
||||||
self.distance(handle_a, handle_b, distance, self.wp)
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
|
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
|
||||||
|
self.distance(handle_a, handle_b, distance, self.wp)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
|
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -13,58 +13,16 @@ 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, _feature_to_dict, _feature_from_dict
|
from fluency.io.project_io import save_project, load_project
|
||||||
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."""
|
||||||
|
|
||||||
|
|||||||
+160
-1169
File diff suppressed because it is too large
Load Diff
@@ -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"Array", None))
|
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", 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))
|
||||||
|
|||||||
+271
-2763
File diff suppressed because it is too large
Load Diff
+199
-1408
File diff suppressed because it is too large
Load Diff
+23
-446
@@ -20,24 +20,6 @@ class Viewer3DWidget(QWidget):
|
|||||||
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
|
# Emitted when face-pick mode is cancelled (Esc) so the host can uncheck.
|
||||||
pickFaceCancelled = Signal()
|
pickFaceCancelled = Signal()
|
||||||
|
|
||||||
# Emitted when the user picks a face for the fillet tool (ANY face,
|
|
||||||
# planar or curved). Payload: the raw TopoDS_Face. The owning body is
|
|
||||||
# read from ``_last_pick_owner_obj_id`` (same stash as facePicked).
|
|
||||||
filletFacePicked = Signal(object)
|
|
||||||
# Emitted when fillet pick mode is cancelled (Esc).
|
|
||||||
filletPickCancelled = Signal()
|
|
||||||
|
|
||||||
# Emitted when the user picks 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)
|
||||||
@@ -59,30 +41,14 @@ 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.WidgetAttribute.WA_PaintOnScreen)
|
self.setAttribute(Qt.WA_PaintOnScreen)
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
|
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
||||||
self.setAutoFillBackground(False)
|
self.setAutoFillBackground(False)
|
||||||
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
# Accept keyboard focus so navigation shortcuts (F, R, 1-7, P, O) work.
|
||||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
self.setFocusPolicy(Qt.StrongFocus)
|
||||||
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
|
# Enable mouse tracking so ``mouseMoveEvent`` fires even without a
|
||||||
# button held — required for the connector-pick hover gizmo (and any
|
# button held — required for the connector-pick hover gizmo (and any
|
||||||
# status-bar hover feedback) to show under the cursor as the user
|
# status-bar hover feedback) to show under the cursor as the user
|
||||||
@@ -98,12 +64,6 @@ class Viewer3DWidget(QWidget):
|
|||||||
# When True, a left-click picks a planar face (for sketch-on-surface)
|
# When True, a left-click picks a planar face (for sketch-on-surface)
|
||||||
# instead of orbiting the camera. Set via set_pick_face_mode().
|
# instead of orbiting the camera. Set via set_pick_face_mode().
|
||||||
self._pick_face_mode: bool = False
|
self._pick_face_mode: bool = False
|
||||||
# When True, a left-click picks ANY face for the fillet tool.
|
|
||||||
self._fillet_pick_mode: bool = False
|
|
||||||
# When True, a left-click picks 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
|
||||||
@@ -127,16 +87,6 @@ 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."""
|
||||||
@@ -325,35 +275,6 @@ 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(
|
||||||
@@ -391,43 +312,17 @@ class Viewer3DWidget(QWidget):
|
|||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
self._ensure_initialized()
|
self._ensure_initialized()
|
||||||
# Face-pick mode: a left-click selects a planar face to sketch on.
|
# Face-pick mode: a left-click selects a planar face to sketch on.
|
||||||
if self._pick_face_mode and event.button() == Qt.MouseButton.LeftButton:
|
if self._pick_face_mode and event.button() == Qt.LeftButton:
|
||||||
self._handle_face_pick(event)
|
self._handle_face_pick(event)
|
||||||
return
|
return
|
||||||
# Fillet pick mode: a left-click selects any face (planar or curved).
|
|
||||||
if self._fillet_pick_mode and event.button() == Qt.MouseButton.LeftButton:
|
|
||||||
self._handle_fillet_face_pick(event)
|
|
||||||
return
|
|
||||||
# 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.MouseButton.LeftButton:
|
if self._connector_pick_mode and event.button() == Qt.LeftButton:
|
||||||
self._handle_connector_pick(event)
|
self._handle_connector_pick(event)
|
||||||
return
|
return
|
||||||
# Assembly move mode: start dragging the clicked body.
|
# Assembly move mode: start dragging the clicked body.
|
||||||
if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton:
|
if self._assembly_move_mode and event.button() == Qt.LeftButton:
|
||||||
self._handle_assembly_move_press(event)
|
self._handle_assembly_move_press(event)
|
||||||
return
|
return
|
||||||
# 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)
|
||||||
|
|
||||||
@@ -451,39 +346,15 @@ 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) -> Any:
|
def paintEngine(self):
|
||||||
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
|
"""Return None to prevent Qt from painting over OCC's direct OpenGL."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -566,127 +437,6 @@ 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:
|
||||||
@@ -697,155 +447,13 @@ class Viewer3DWidget(QWidget):
|
|||||||
"""
|
"""
|
||||||
self._pick_face_mode = bool(enabled)
|
self._pick_face_mode = bool(enabled)
|
||||||
if enabled:
|
if enabled:
|
||||||
self._sketch_gizmo_pick_mode = False
|
self.setCursor(Qt.CrossCursor)
|
||||||
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()
|
||||||
@@ -881,8 +489,7 @@ class Viewer3DWidget(QWidget):
|
|||||||
"""
|
"""
|
||||||
self._connector_pick_mode = bool(enabled)
|
self._connector_pick_mode = bool(enabled)
|
||||||
if enabled:
|
if enabled:
|
||||||
self._sketch_gizmo_pick_mode = False
|
self.setCursor(Qt.CrossCursor)
|
||||||
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)
|
||||||
@@ -1107,8 +714,7 @@ class Viewer3DWidget(QWidget):
|
|||||||
"""
|
"""
|
||||||
self._assembly_move_mode = bool(enabled)
|
self._assembly_move_mode = bool(enabled)
|
||||||
if enabled:
|
if enabled:
|
||||||
self._sketch_gizmo_pick_mode = False
|
self.setCursor(Qt.SizeAllCursor)
|
||||||
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:
|
||||||
@@ -1182,29 +788,20 @@ class Viewer3DWidget(QWidget):
|
|||||||
|
|
||||||
# Compute world-space delta.
|
# Compute world-space delta.
|
||||||
modifiers = event.modifiers()
|
modifiers = event.modifiers()
|
||||||
if modifiers & Qt.KeyboardModifier.ShiftModifier:
|
if modifiers & Qt.ShiftModifier:
|
||||||
# Shift+drag: move along camera direction (Z-depth).
|
# Shift+drag: move along camera direction (Z-depth).
|
||||||
dz_world = dx * world_per_pixel
|
dz_world = dx * world_per_pixel
|
||||||
dx_world = 0.0
|
dx_world = 0.0
|
||||||
dy_world = 0.0
|
dy_world = 0.0
|
||||||
else:
|
else:
|
||||||
# Normal drag: move in view plane.
|
# Normal drag: move in view plane.
|
||||||
try:
|
dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel)
|
||||||
dx_world = float(
|
dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel)
|
||||||
cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel
|
dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel)
|
||||||
)
|
|
||||||
dy_world = float(
|
|
||||||
cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel
|
|
||||||
)
|
|
||||||
dz_world = float(
|
|
||||||
cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
dx_world = dy_world = dz_world = 0.0
|
|
||||||
|
|
||||||
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
|
self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world)
|
||||||
|
|
||||||
def _handle_assembly_move_release(self, event: Any) -> None:
|
def _handle_assembly_move_release(self, event) -> None:
|
||||||
"""Finish the drag, emit final position."""
|
"""Finish the drag, emit final position."""
|
||||||
self.assemblyMoveFinished.emit(self._move_owner_obj_id)
|
self.assemblyMoveFinished.emit(self._move_owner_obj_id)
|
||||||
self._move_drag_active = False
|
self._move_drag_active = False
|
||||||
@@ -1214,7 +811,7 @@ class Viewer3DWidget(QWidget):
|
|||||||
self._move_plane_normal = None
|
self._move_plane_normal = None
|
||||||
self._move_initial_position = None
|
self._move_initial_position = None
|
||||||
|
|
||||||
def _handle_face_pick(self, event: Any) -> None:
|
def _handle_face_pick(self, event) -> None:
|
||||||
"""Detect a planar face under the click and emit facePicked."""
|
"""Detect a planar face under the click and emit facePicked."""
|
||||||
self._ensure_initialized()
|
self._ensure_initialized()
|
||||||
picker = getattr(self._renderer, "pick_planar_face", None)
|
picker = getattr(self._renderer, "pick_planar_face", None)
|
||||||
@@ -1237,7 +834,7 @@ class Viewer3DWidget(QWidget):
|
|||||||
info["face"],
|
info["face"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_view(self, view: str) -> None:
|
def set_view(self, view: str):
|
||||||
# Prefer the renderer's native orientation snap (preserves target,
|
# Prefer the renderer's native orientation snap (preserves target,
|
||||||
# refits the scene). Falls back to absolute eye positions for
|
# refits the scene). Falls back to absolute eye positions for
|
||||||
# renderers that don't implement set_view_orientation.
|
# renderers that don't implement set_view_orientation.
|
||||||
@@ -1259,48 +856,28 @@ class Viewer3DWidget(QWidget):
|
|||||||
pos, target = positions[view]
|
pos, target = positions[view]
|
||||||
self.set_camera_position(pos, target)
|
self.set_camera_position(pos, target)
|
||||||
|
|
||||||
def mouseDoubleClickEvent(self, event: Any) -> None:
|
def mouseDoubleClickEvent(self, event):
|
||||||
# Double-click → fit all (common CAD convention).
|
# Double-click → fit all (common CAD convention).
|
||||||
self._ensure_initialized()
|
self._ensure_initialized()
|
||||||
if event.button() == Qt.MouseButton.LeftButton:
|
if event.button() == Qt.LeftButton:
|
||||||
self.fit_camera()
|
self.fit_camera()
|
||||||
super().mouseDoubleClickEvent(event)
|
super().mouseDoubleClickEvent(event)
|
||||||
|
|
||||||
def keyPressEvent(self, event: Any) -> None:
|
def keyPressEvent(self, event):
|
||||||
# Esc cancels face-pick mode.
|
# Esc cancels face-pick mode.
|
||||||
if self._pick_face_mode and event.key() == Qt.Key.Key_Escape:
|
if self._pick_face_mode and event.key() == Qt.Key_Escape:
|
||||||
self.set_pick_face_mode(False)
|
self.set_pick_face_mode(False)
|
||||||
self.pickFaceCancelled.emit()
|
self.pickFaceCancelled.emit()
|
||||||
return
|
return
|
||||||
# Esc cancels fillet pick mode.
|
|
||||||
if self._fillet_pick_mode and event.key() == Qt.Key.Key_Escape:
|
|
||||||
self.set_fillet_pick_mode(False)
|
|
||||||
self.filletPickCancelled.emit()
|
|
||||||
return
|
|
||||||
# Esc cancels 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.Key_Escape:
|
if self._connector_pick_mode and event.key() == Qt.Key_Escape:
|
||||||
self.set_connector_pick_mode(False)
|
self.set_connector_pick_mode(False)
|
||||||
self.connectorPickCancelled.emit()
|
self.connectorPickCancelled.emit()
|
||||||
return
|
return
|
||||||
# Esc cancels assembly move mode.
|
# Esc cancels assembly move mode.
|
||||||
if self._assembly_move_mode and event.key() == Qt.Key.Key_Escape:
|
if self._assembly_move_mode and event.key() == Qt.Key_Escape:
|
||||||
self.set_assembly_move_mode(False)
|
self.set_assembly_move_mode(False)
|
||||||
return
|
return
|
||||||
# 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()
|
||||||
|
|||||||
+115
-130
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for Fluency CAD geometry kernel."""
|
"""Tests for Fluency CAD geometry kernel."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
from fluency.geometry_occ.sketch import OCCSketch
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
@@ -194,14 +195,10 @@ class TestOCCSketch:
|
|||||||
sk = OCCSketch()
|
sk = OCCSketch()
|
||||||
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
|
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
|
||||||
# 20x20 square in UV
|
# 20x20 square in UV
|
||||||
p0 = sk.add_point(-10, -10)
|
p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10)
|
||||||
p1 = sk.add_point(10, -10)
|
p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10)
|
||||||
p2 = sk.add_point(10, 10)
|
sk.add_line(p0, p1); sk.add_line(p1, p2)
|
||||||
p3 = sk.add_point(-10, 10)
|
sk.add_line(p2, p3); sk.add_line(p3, p0)
|
||||||
sk.add_line(p0, p1)
|
|
||||||
sk.add_line(p1, p2)
|
|
||||||
sk.add_line(p2, p3)
|
|
||||||
sk.add_line(p3, p0)
|
|
||||||
|
|
||||||
geom = sk.get_geometry()
|
geom = sk.get_geometry()
|
||||||
# The face must carry the plane normal for the kernel.
|
# The face must carry the plane normal for the kernel.
|
||||||
@@ -223,14 +220,10 @@ class TestOCCSketch:
|
|||||||
|
|
||||||
sk = OCCSketch()
|
sk = OCCSketch()
|
||||||
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
|
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
|
||||||
a = sk.add_point(-10, -10)
|
a = sk.add_point(-10, -10); b = sk.add_point(10, -10)
|
||||||
b = sk.add_point(10, -10)
|
c = sk.add_point(10, 10); d = sk.add_point(-10, 10)
|
||||||
c = sk.add_point(10, 10)
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
d = sk.add_point(-10, 10)
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
sk.add_line(a, b)
|
|
||||||
sk.add_line(b, c)
|
|
||||||
sk.add_line(c, d)
|
|
||||||
sk.add_line(d, a)
|
|
||||||
ctr = sk.add_point(0, 0)
|
ctr = sk.add_point(0, 0)
|
||||||
sk.add_circle(ctr, 3.0)
|
sk.add_circle(ctr, 3.0)
|
||||||
|
|
||||||
@@ -297,14 +290,10 @@ class TestExternalEntities:
|
|||||||
# Underlay: a 20x20 square projected from a face (closed polyline).
|
# Underlay: a 20x20 square projected from a face (closed polyline).
|
||||||
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
|
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
|
||||||
# User profile: a 5x5 square — this is what should be extruded.
|
# User profile: a 5x5 square — this is what should be extruded.
|
||||||
a = sk.add_point(2, 2)
|
a = sk.add_point(2, 2); b = sk.add_point(8, 2)
|
||||||
b = sk.add_point(8, 2)
|
c = sk.add_point(8, 8); d = sk.add_point(2, 8)
|
||||||
c = sk.add_point(8, 8)
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
d = sk.add_point(2, 8)
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
sk.add_line(a, b)
|
|
||||||
sk.add_line(b, c)
|
|
||||||
sk.add_line(c, d)
|
|
||||||
sk.add_line(d, a)
|
|
||||||
faces = sk.detect_faces()
|
faces = sk.detect_faces()
|
||||||
# Only the user-drawn face (5x5 square) should be detected.
|
# Only the user-drawn face (5x5 square) should be detected.
|
||||||
assert len(faces) == 1
|
assert len(faces) == 1
|
||||||
@@ -321,14 +310,10 @@ class TestExternalEntities:
|
|||||||
def test_external_entities_excluded_from_get_polygon_points(self):
|
def test_external_entities_excluded_from_get_polygon_points(self):
|
||||||
sk = OCCSketch()
|
sk = OCCSketch()
|
||||||
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
|
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
|
||||||
a = sk.add_point(1, 1)
|
a = sk.add_point(1, 1); b = sk.add_point(2, 1)
|
||||||
b = sk.add_point(2, 1)
|
c = sk.add_point(2, 2); d = sk.add_point(1, 2)
|
||||||
c = sk.add_point(2, 2)
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
d = sk.add_point(1, 2)
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
sk.add_line(a, b)
|
|
||||||
sk.add_line(b, c)
|
|
||||||
sk.add_line(c, d)
|
|
||||||
sk.add_line(d, a)
|
|
||||||
poly = sk.get_polygon_points()
|
poly = sk.get_polygon_points()
|
||||||
# The user square (1..2 range) should appear, not the 0..100 underlay.
|
# The user square (1..2 range) should appear, not the 0..100 underlay.
|
||||||
assert all(1.0 <= p.x <= 2.0 for p in poly)
|
assert all(1.0 <= p.x <= 2.0 for p in poly)
|
||||||
@@ -343,14 +328,10 @@ class TestExternalEntities:
|
|||||||
# Underlay (NOT to be extruded).
|
# Underlay (NOT to be extruded).
|
||||||
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
|
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
|
||||||
# User profile: a 2x2 square inside the underlay.
|
# User profile: a 2x2 square inside the underlay.
|
||||||
a = sk.add_point(1, 1)
|
a = sk.add_point(1, 1); b = sk.add_point(3, 1)
|
||||||
b = sk.add_point(3, 1)
|
c = sk.add_point(3, 3); d = sk.add_point(1, 3)
|
||||||
c = sk.add_point(3, 3)
|
sk.add_line(a, b); sk.add_line(b, c)
|
||||||
d = sk.add_point(1, 3)
|
sk.add_line(c, d); sk.add_line(d, a)
|
||||||
sk.add_line(a, b)
|
|
||||||
sk.add_line(b, c)
|
|
||||||
sk.add_line(c, d)
|
|
||||||
sk.add_line(d, a)
|
|
||||||
geom = sk.get_geometry()
|
geom = sk.get_geometry()
|
||||||
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
|
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
|
||||||
kernel = OCGeometryKernel()
|
kernel = OCGeometryKernel()
|
||||||
@@ -517,9 +498,10 @@ class TestExtrudeCutFix:
|
|||||||
and the tool is no longer needed.
|
and the tool is no longer needed.
|
||||||
"""
|
"""
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
from OCP.GProp import GProp_GProps
|
from OCP.GProp import GProp_GProps
|
||||||
from OCP.BRepGProp import BRepGProp
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
import math
|
||||||
|
|
||||||
k = OCGeometryKernel()
|
k = OCGeometryKernel()
|
||||||
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||||
@@ -529,18 +511,17 @@ class TestExtrudeCutFix:
|
|||||||
# expected volume easy to compute.
|
# expected volume easy to compute.
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||||
from OCP.gp import gp_Pnt, gp_Vec
|
from OCP.gp import gp_Pnt, gp_Vec
|
||||||
|
|
||||||
# 20x20 square at (0,0,0), extruded along +Z by 200.
|
# 20x20 square at (0,0,0), extruded along +Z by 200.
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||||
|
|
||||||
mp = BRepBuilderAPI_MakePolygon()
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
for x, y in [(0, 0), (20, 0), (20, 20), (0, 20)]:
|
for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]:
|
||||||
mp.Add(gp_Pnt(x, y, 0))
|
mp.Add(gp_Pnt(x, y, 0))
|
||||||
mp.Close()
|
mp.Close()
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||||
|
|
||||||
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
|
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
|
||||||
tool_shape = BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, 200)).Shape()
|
tool_shape = BRepPrimAPI_MakePrism(
|
||||||
|
face, gp_Vec(0, 0, 200)
|
||||||
|
).Shape()
|
||||||
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
|
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
|
||||||
|
|
||||||
# Before cut: target is 100^3 = 1_000_000.
|
# Before cut: target is 100^3 = 1_000_000.
|
||||||
@@ -555,7 +536,9 @@ class TestExtrudeCutFix:
|
|||||||
# After cut: target is 1_000_000 - 20*20*100 = 960_000
|
# After cut: target is 1_000_000 - 20*20*100 = 960_000
|
||||||
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
|
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
|
||||||
g1 = GProp_GProps()
|
g1 = GProp_GProps()
|
||||||
BRepGProp.VolumeProperties_s(k._get_shape(target_obj_geometry), g1)
|
BRepGProp.VolumeProperties_s(
|
||||||
|
k._get_shape(target_obj_geometry), g1
|
||||||
|
)
|
||||||
assert abs(g1.Mass() - 960_000.0) < 1.0
|
assert abs(g1.Mass() - 960_000.0) < 1.0
|
||||||
|
|
||||||
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
|
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
|
||||||
@@ -568,9 +551,10 @@ class TestExtrudeCutFix:
|
|||||||
target, so a single body remains.
|
target, so a single body remains.
|
||||||
"""
|
"""
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
from OCP.TopAbs import TopAbs_SOLID
|
from OCP.TopAbs import TopAbs_SOLID
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
|
|
||||||
k = OCGeometryKernel()
|
k = OCGeometryKernel()
|
||||||
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||||
@@ -578,7 +562,6 @@ class TestExtrudeCutFix:
|
|||||||
|
|
||||||
# Tool: small box at the centre, fully inside the target.
|
# Tool: small box at the centre, fully inside the target.
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
|
||||||
|
|
||||||
tool_shape = BBox(20, 20, 20).Shape()
|
tool_shape = BBox(20, 20, 20).Shape()
|
||||||
tool_obj = OCCGeometryObject(tool_shape, {})
|
tool_obj = OCCGeometryObject(tool_shape, {})
|
||||||
|
|
||||||
@@ -612,59 +595,91 @@ class TestBodyVisibilityToggle:
|
|||||||
|
|
||||||
def _make_window(self):
|
def _make_window(self):
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import MainWindow
|
from fluency.main import MainWindow
|
||||||
|
|
||||||
return MainWindow()
|
return MainWindow()
|
||||||
|
|
||||||
def test_body_list_uses_checkable_items(self):
|
def test_body_list_uses_checkable_items(self):
|
||||||
"""Each body list item has a data role for the toggle handler."""
|
"""Each body list item must be a checkable QListWidgetItem."""
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
win = self._make_window()
|
win = self._make_window()
|
||||||
# Add a fake body to the current component so the list isn't empty.
|
# Add a fake body to the current component so the list isn't empty.
|
||||||
from fluency.models.data_model import Body
|
from fluency.models.data_model import Body
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
win._refresh_lists()
|
win._refresh_lists()
|
||||||
items = win._body_list.findItems("A", Qt.MatchExactly)
|
items = win._body_list.findItems("A", Qt.MatchExactly)
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
|
# Item is checkable (so the user can toggle visibility).
|
||||||
|
assert items[0].flags() & Qt.ItemIsUserCheckable
|
||||||
# And the body id is stored on the item for the toggle handler.
|
# And the body id is stored on the item for the toggle handler.
|
||||||
assert items[0].data(Qt.UserRole) == "a"
|
assert items[0].data(Qt.UserRole) == "a"
|
||||||
# Default state is visible.
|
# Default state is checked (= visible).
|
||||||
assert win._current_component.bodies["a"].visible is True
|
assert items[0].checkState() == Qt.Checked
|
||||||
|
|
||||||
def test_toggling_visibility_updates_body_model(self):
|
def test_toggling_visibility_updates_body_model(self):
|
||||||
"""Toggling visibility via _on_body_visibility_changed updates the model."""
|
"""Flipping the checkbox should set body.visible accordingly."""
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
win = self._make_window()
|
win = self._make_window()
|
||||||
from fluency.models.data_model import Body
|
from fluency.models.data_model import Body
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
win._refresh_lists()
|
win._refresh_lists()
|
||||||
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
||||||
|
|
||||||
# Toggle off.
|
# Toggle off.
|
||||||
|
item.setCheckState(Qt.Unchecked)
|
||||||
win._on_body_visibility_changed(item)
|
win._on_body_visibility_changed(item)
|
||||||
assert win._current_component.bodies["a"].visible is False
|
assert win._current_component.bodies["a"].visible is False
|
||||||
|
|
||||||
# Toggle back on.
|
# Toggle back on.
|
||||||
|
item.setCheckState(Qt.Checked)
|
||||||
win._on_body_visibility_changed(item)
|
win._on_body_visibility_changed(item)
|
||||||
assert win._current_component.bodies["a"].visible is True
|
assert win._current_component.bodies["a"].visible is True
|
||||||
|
|
||||||
|
def test_visibility_no_op_when_unchanged(self):
|
||||||
|
"""Re-emitting the same state must not trigger a viewer call.
|
||||||
|
|
||||||
|
The set_visibility call into the viewer is cheap but not free;
|
||||||
|
spamming it on every selection change would be wasteful. The
|
||||||
|
handler short-circuits when the new state matches the model's.
|
||||||
|
"""
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
win = self._make_window()
|
||||||
|
from fluency.models.data_model import Body
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
box = OCCGeometryObject(
|
||||||
|
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
|
win._refresh_lists()
|
||||||
|
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
||||||
|
|
||||||
|
# Force the model's visibility to False to mimic a desync.
|
||||||
|
win._current_component.bodies["a"].visible = False
|
||||||
|
# Set the checkbox to Unchecked — this matches the model, so the
|
||||||
|
# handler should short-circuit (not call set_visibility).
|
||||||
|
item.setCheckState(Qt.Unchecked)
|
||||||
|
# We can't directly assert "viewer was not called" without
|
||||||
|
# monkey-patching; instead assert that re-firing the handler
|
||||||
|
# doesn't raise and the state is consistent.
|
||||||
|
win._on_body_visibility_changed(item)
|
||||||
|
assert win._current_component.bodies["a"].visible is False
|
||||||
|
|
||||||
|
|
||||||
def math_hypot(x, y):
|
def math_hypot(x, y):
|
||||||
import math
|
import math
|
||||||
|
|
||||||
return math.hypot(x, y)
|
return math.hypot(x, y)
|
||||||
|
|
||||||
|
|
||||||
@@ -683,13 +698,10 @@ class TestConstraintTagRendering:
|
|||||||
def _make_widget_with_sketch(self, sk):
|
def _make_widget_with_sketch(self, sk):
|
||||||
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
|
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import Sketch2DWidget
|
from fluency.main import Sketch2DWidget
|
||||||
|
|
||||||
w = Sketch2DWidget()
|
w = Sketch2DWidget()
|
||||||
w.set_sketch(sk)
|
w.set_sketch(sk)
|
||||||
return w
|
return w
|
||||||
@@ -812,7 +824,6 @@ class TestConstraintTagRendering:
|
|||||||
class _BadRound:
|
class _BadRound:
|
||||||
def __round__(self, ndigits=0):
|
def __round__(self, ndigits=0):
|
||||||
raise TypeError("cannot round")
|
raise TypeError("cannot round")
|
||||||
|
|
||||||
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
|
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
|
||||||
tags = w._compute_constraint_tags()
|
tags = w._compute_constraint_tags()
|
||||||
assert all(t["center"] is not None for t in tags)
|
assert all(t["center"] is not None for t in tags)
|
||||||
@@ -854,13 +865,12 @@ class TestExtrudeRedesign:
|
|||||||
|
|
||||||
def _make_window_with_box(self, box_side=100.0):
|
def _make_window_with_box(self, box_side=100.0):
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import MainWindow
|
from fluency.main import MainWindow
|
||||||
from fluency.models.data_model import Sketch, Body
|
from fluency.models.data_model import Sketch, Body
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
from fluency.geometry_occ.sketch import OCCSketch
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
@@ -886,6 +896,7 @@ class TestExtrudeRedesign:
|
|||||||
return win, sketch, sk, box_obj
|
return win, sketch, sk, box_obj
|
||||||
|
|
||||||
def _add_circle(self, sk, r=10.0):
|
def _add_circle(self, sk, r=10.0):
|
||||||
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
c = sk.add_point(0, 0)
|
c = sk.add_point(0, 0)
|
||||||
sk.add_circle(c, r)
|
sk.add_circle(c, r)
|
||||||
sk.solve()
|
sk.solve()
|
||||||
@@ -894,7 +905,6 @@ class TestExtrudeRedesign:
|
|||||||
def _geometry_volume(self, win, geom):
|
def _geometry_volume(self, win, geom):
|
||||||
from OCP.GProp import GProp_GProps
|
from OCP.GProp import GProp_GProps
|
||||||
from OCP.BRepGProp import BRepGProp
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
sh = win._kernel._get_shape(geom)
|
sh = win._kernel._get_shape(geom)
|
||||||
g = GProp_GProps()
|
g = GProp_GProps()
|
||||||
BRepGProp.VolumeProperties_s(sh, g)
|
BRepGProp.VolumeProperties_s(sh, g)
|
||||||
@@ -909,48 +919,37 @@ class TestExtrudeRedesign:
|
|||||||
so a 5 mm cut makes a real 5 mm-deep pocket.
|
so a 5 mm cut makes a real 5 mm-deep pocket.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
face_geom = self._add_circle(sk, r=10.0)
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
|
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
|
||||||
# removed nothing; post-redesign it must remove a 5 mm cylinder.
|
# removed nothing; post-redesign it must remove a 5 mm cylinder.
|
||||||
result = win._compute_extrude_result(
|
result = win._compute_extrude_result(
|
||||||
sketch,
|
sketch, face_geom,
|
||||||
face_geom,
|
length=5.0, symmetric=False, invert=False,
|
||||||
length=5.0,
|
cut=True, union=False, through_all=False,
|
||||||
symmetric=False,
|
|
||||||
invert=False,
|
|
||||||
cut=True,
|
|
||||||
union=False,
|
|
||||||
through_all=False,
|
|
||||||
)
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["target_body"] is not None
|
assert result["target_body"] is not None
|
||||||
assert result["target_body"].name == "Box1"
|
assert result["target_body"].name == "Box1"
|
||||||
vol = self._geometry_volume(win, result["result_geom"])
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
expected = 100.0**3 - math.pi * (10.0**2) * 5.0
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 5.0
|
||||||
assert abs(vol - expected) < 1.0
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
def test_cut_through_all_passes_through(self):
|
def test_cut_through_all_passes_through(self):
|
||||||
""" "Through All" cut fully passes through the body."""
|
""""Through All" cut fully passes through the body."""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
face_geom = self._add_circle(sk, r=10.0)
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
result = win._compute_extrude_result(
|
result = win._compute_extrude_result(
|
||||||
sketch,
|
sketch, face_geom,
|
||||||
face_geom,
|
|
||||||
length=5.0, # ignored when through_all
|
length=5.0, # ignored when through_all
|
||||||
symmetric=False,
|
symmetric=False, invert=False,
|
||||||
invert=False,
|
cut=True, union=False, through_all=True,
|
||||||
cut=True,
|
|
||||||
union=False,
|
|
||||||
through_all=True,
|
|
||||||
)
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
vol = self._geometry_volume(win, result["result_geom"])
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
# Full through cylinder = pi * r^2 * box_depth.
|
# Full through cylinder = pi * r^2 * box_depth.
|
||||||
expected = 100.0**3 - math.pi * (10.0**2) * 100.0
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
|
||||||
assert abs(vol - expected) < 1.0
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
def test_cut_auto_targets_source_body_not_existing_zero(self):
|
def test_cut_auto_targets_source_body_not_existing_zero(self):
|
||||||
@@ -961,23 +960,30 @@ class TestExtrudeRedesign:
|
|||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import MainWindow
|
from fluency.main import MainWindow
|
||||||
from fluency.models.data_model import Sketch, Body
|
from fluency.models.data_model import Sketch, Body
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
from fluency.geometry_occ.sketch import OCCSketch
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
win = MainWindow()
|
win = MainWindow()
|
||||||
# First body in the dict: a 50-millimetre box ALSO.
|
# First body in the dict: a 50-millimetre box ALSO.
|
||||||
first = OCCGeometryObject(BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {})
|
first = OCCGeometryObject(
|
||||||
win._current_component.bodies["first"] = Body(name="First", geometry=first)
|
BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["first"] = Body(
|
||||||
|
name="First", geometry=first
|
||||||
|
)
|
||||||
# Source body: a 100-millimetre box (drawn over).
|
# Source body: a 100-millimetre box (drawn over).
|
||||||
src = OCCGeometryObject(BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {})
|
src = OCCGeometryObject(
|
||||||
win._current_component.bodies["src"] = Body(name="Src", geometry=src)
|
BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}
|
||||||
|
)
|
||||||
|
win._current_component.bodies["src"] = Body(
|
||||||
|
name="Src", geometry=src
|
||||||
|
)
|
||||||
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
|
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
|
||||||
sk = OCCSketch()
|
sk = OCCSketch()
|
||||||
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
@@ -993,21 +999,16 @@ class TestExtrudeRedesign:
|
|||||||
|
|
||||||
face_geom = sk.get_geometry()
|
face_geom = sk.get_geometry()
|
||||||
result = win._compute_extrude_result(
|
result = win._compute_extrude_result(
|
||||||
sketch,
|
sketch, face_geom,
|
||||||
face_geom,
|
length=5.0, symmetric=False, invert=False,
|
||||||
length=5.0,
|
cut=True, union=False, through_all=True,
|
||||||
symmetric=False,
|
|
||||||
invert=False,
|
|
||||||
cut=True,
|
|
||||||
union=False,
|
|
||||||
through_all=True,
|
|
||||||
)
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
# Target is the source box, NOT the dict's first body.
|
# Target is the source box, NOT the dict's first body.
|
||||||
assert result["target_body"].name == "Src"
|
assert result["target_body"].name == "Src"
|
||||||
vol = self._geometry_volume(win, result["result_geom"])
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
# 100^3 - pi*100*100 (through-all full-depth cut on the 100 box).
|
# 100^3 - pi*100*100 (through-all full-depth cut on the 100 box).
|
||||||
expected = 100.0**3 - math.pi * (10.0**2) * 100.0
|
expected = 100.0 ** 3 - math.pi * (10.0 ** 2) * 100.0
|
||||||
assert abs(vol - expected) < 1.0
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
def test_union_default_builds_outward(self):
|
def test_union_default_builds_outward(self):
|
||||||
@@ -1018,23 +1019,17 @@ class TestExtrudeRedesign:
|
|||||||
rather than "subtracting" from the existing box.
|
rather than "subtracting" from the existing box.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
face_geom = self._add_circle(sk, r=10.0)
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
result = win._compute_extrude_result(
|
result = win._compute_extrude_result(
|
||||||
sketch,
|
sketch, face_geom,
|
||||||
face_geom,
|
length=10.0, symmetric=False, invert=False,
|
||||||
length=10.0,
|
cut=False, union=True, through_all=False,
|
||||||
symmetric=False,
|
|
||||||
invert=False,
|
|
||||||
cut=False,
|
|
||||||
union=True,
|
|
||||||
through_all=False,
|
|
||||||
)
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
vol = self._geometry_volume(win, result["result_geom"])
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
# 100^3 + pi*100*10 — material added on top.
|
# 100^3 + pi*100*10 — material added on top.
|
||||||
expected = 100.0**3 + math.pi * (10.0**2) * 10.0
|
expected = 100.0 ** 3 + math.pi * (10.0 ** 2) * 10.0
|
||||||
assert abs(vol - expected) < 1.0
|
assert abs(vol - expected) < 1.0
|
||||||
|
|
||||||
def test_plain_extrude_untouched_by_source_body(self):
|
def test_plain_extrude_untouched_by_source_body(self):
|
||||||
@@ -1042,14 +1037,9 @@ class TestExtrudeRedesign:
|
|||||||
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
face_geom = self._add_circle(sk, r=10.0)
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
result = win._compute_extrude_result(
|
result = win._compute_extrude_result(
|
||||||
sketch,
|
sketch, face_geom,
|
||||||
face_geom,
|
length=10.0, symmetric=False, invert=False,
|
||||||
length=10.0,
|
cut=False, union=False, through_all=False,
|
||||||
symmetric=False,
|
|
||||||
invert=False,
|
|
||||||
cut=False,
|
|
||||||
union=False,
|
|
||||||
through_all=False,
|
|
||||||
)
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
# No boolean target; result is the standalone tool extrusion.
|
# No boolean target; result is the standalone tool extrusion.
|
||||||
@@ -1057,8 +1047,7 @@ class TestExtrudeRedesign:
|
|||||||
vol = self._geometry_volume(win, result["result_geom"])
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
# Standalone cylinder 10 mm tall.
|
# Standalone cylinder 10 mm tall.
|
||||||
import math
|
import math
|
||||||
|
assert abs(vol - math.pi * (10.0 ** 2) * 10.0) < 1.0
|
||||||
assert abs(vol - math.pi * (10.0**2) * 10.0) < 1.0
|
|
||||||
|
|
||||||
def test_freshly_picked_sketch_is_auto_selected(self):
|
def test_freshly_picked_sketch_is_auto_selected(self):
|
||||||
"""After _on_face_picked, the new sketch is the current list row.
|
"""After _on_face_picked, the new sketch is the current list row.
|
||||||
@@ -1066,6 +1055,7 @@ class TestExtrudeRedesign:
|
|||||||
The user should be able to click Extrude/Cut immediately without
|
The user should be able to click Extrude/Cut immediately without
|
||||||
first hunting for the new sketch in the left list.
|
first hunting for the new sketch in the left list.
|
||||||
"""
|
"""
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
win, _, sk, box_obj = self._make_window_with_box(100.0)
|
win, _, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
# Simulate _on_face_picked by calling it through a fake face
|
# Simulate _on_face_picked by calling it through a fake face
|
||||||
# shape — but the simplest behavioural check is to call the
|
# shape — but the simplest behavioural check is to call the
|
||||||
@@ -1073,7 +1063,6 @@ class TestExtrudeRedesign:
|
|||||||
# set as _current_sketch, and it appears (and is selected) in
|
# set as _current_sketch, and it appears (and is selected) in
|
||||||
# the list after _refresh_lists + setCurrentRow.
|
# the list after _refresh_lists + setCurrentRow.
|
||||||
from fluency.models.data_model import Sketch
|
from fluency.models.data_model import Sketch
|
||||||
|
|
||||||
sketch = Sketch(name="Sketch on face 99")
|
sketch = Sketch(name="Sketch on face 99")
|
||||||
sketch._source_body_id = "b1"
|
sketch._source_body_id = "b1"
|
||||||
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
@@ -1095,10 +1084,8 @@ class TestExtrudeRedesign:
|
|||||||
def test_preview_callback_invoked_on_value_change(self):
|
def test_preview_callback_invoked_on_value_change(self):
|
||||||
"""The live preview callback fires on spinbox/checkbox changes."""
|
"""The live preview callback fires on spinbox/checkbox changes."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import ExtrudeDialog
|
from fluency.main import ExtrudeDialog
|
||||||
|
|
||||||
@@ -1123,10 +1110,8 @@ class TestExtrudeRedesign:
|
|||||||
def test_preview_hidden_event_sends_none(self):
|
def test_preview_hidden_event_sends_none(self):
|
||||||
"""hideEvent should deliver None to the callback so the host clears."""
|
"""hideEvent should deliver None to the callback so the host clears."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import ExtrudeDialog
|
from fluency.main import ExtrudeDialog
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user