- added "measurement lines"
This commit is contained in:
Generated
+11
-7
@@ -4,16 +4,18 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Render improvements, camera plane, update">
|
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- 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/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.py" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -30,7 +32,7 @@
|
|||||||
<component name="Git.Settings">
|
<component name="Git.Settings">
|
||||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||||
<map>
|
<map>
|
||||||
<entry key="$PROJECT_DIR$" value="single_window" />
|
<entry key="$PROJECT_DIR$" value="feature/surface-modifier-pyramid-patterns" />
|
||||||
</map>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
@@ -57,6 +59,7 @@
|
|||||||
"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,6 +68,7 @@
|
|||||||
"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",
|
||||||
@@ -81,7 +85,7 @@
|
|||||||
"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/occ-migration",
|
||||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/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)",
|
||||||
@@ -96,9 +100,9 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="CopyFile.RECENT_KEYS">
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
|
<recent name="$PROJECT_DIR$/src/fluency" />
|
||||||
<recent name="$PROJECT_DIR$" />
|
<recent name="$PROJECT_DIR$" />
|
||||||
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
||||||
<recent name="$PROJECT_DIR$/src/fluency" />
|
|
||||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||||
<recent name="$PROJECT_DIR$/modules" />
|
<recent name="$PROJECT_DIR$/modules" />
|
||||||
</key>
|
</key>
|
||||||
|
|||||||
@@ -235,6 +235,93 @@ class OCGeometryKernel(GeometryKernel):
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def find_coplanar_face(
|
||||||
|
shape: Any,
|
||||||
|
origin: Tuple[float, float, float],
|
||||||
|
normal: Tuple[float, float, float],
|
||||||
|
ref_center: Optional[Tuple[float, float, float]] = None,
|
||||||
|
angle_tol_deg: float = 5.0,
|
||||||
|
dist_tol: float = 1e-3,
|
||||||
|
) -> Optional[Tuple[Any, Tuple[float, float, float]]]:
|
||||||
|
"""Find a planar face on *shape* coplanar with the given plane.
|
||||||
|
|
||||||
|
Iterates the faces of *shape* and returns the first planar face whose
|
||||||
|
plane normal is parallel to *normal* (within *angle_tol_deg* degrees)
|
||||||
|
and whose plane passes through *origin* (within *dist_tol* distance).
|
||||||
|
When several faces match, the one whose surface centre is closest to
|
||||||
|
*ref_center* (if provided) is preferred.
|
||||||
|
|
||||||
|
Returns ``(face, center)`` where *center* is the surface centroid as a
|
||||||
|
3-tuple, or *None* if no matching face is found.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.TopAbs import TopAbs_FACE
|
||||||
|
from OCP.TopoDS import TopoDS
|
||||||
|
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||||
|
from OCP.GeomAbs import GeomAbs_Plane
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
if shape is None:
|
||||||
|
return None
|
||||||
|
n = np.asarray(normal, dtype=float)
|
||||||
|
n = n / (np.linalg.norm(n) + 1e-30)
|
||||||
|
ox, oy, oz = origin
|
||||||
|
|
||||||
|
cos_tol = math.cos(math.radians(angle_tol_deg))
|
||||||
|
candidates: list = []
|
||||||
|
|
||||||
|
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||||
|
while explorer.More():
|
||||||
|
face = TopoDS.Face_s(explorer.Current())
|
||||||
|
try:
|
||||||
|
surf = BRepAdaptor_Surface(face)
|
||||||
|
if surf.GetType() != GeomAbs_Plane:
|
||||||
|
explorer.Next()
|
||||||
|
continue
|
||||||
|
plane = surf.Plane()
|
||||||
|
pn = np.array(
|
||||||
|
[
|
||||||
|
plane.Axis().Direction().X(),
|
||||||
|
plane.Axis().Direction().Y(),
|
||||||
|
plane.Axis().Direction().Z(),
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
# Check normals parallel (same or opposite direction)
|
||||||
|
cos_angle = abs(float(np.dot(n, pn)))
|
||||||
|
if cos_angle < cos_tol:
|
||||||
|
explorer.Next()
|
||||||
|
continue
|
||||||
|
# Check distance from plane to origin
|
||||||
|
pp = plane.Location()
|
||||||
|
d = abs(float(np.dot(n, np.array([pp.X() - ox, pp.Y() - oy, pp.Z() - oz]))))
|
||||||
|
if d > dist_tol:
|
||||||
|
explorer.Next()
|
||||||
|
continue
|
||||||
|
# Surface centroid via GProp (SurfaceProperties for faces)
|
||||||
|
props = GProp_GProps()
|
||||||
|
BRepGProp.SurfaceProperties_s(face, props)
|
||||||
|
c = props.CentreOfMass()
|
||||||
|
center = (float(c.X()), float(c.Y()), float(c.Z()))
|
||||||
|
candidates.append((face, center))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
explorer.Next()
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
if ref_center is not None and len(candidates) > 1:
|
||||||
|
rc = np.asarray(ref_center, dtype=float)
|
||||||
|
best = min(candidates, key=lambda fc: float(np.linalg.norm(np.asarray(fc[1]) - rc)))
|
||||||
|
return best
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
def revolve(
|
def revolve(
|
||||||
self,
|
self,
|
||||||
sketch: GeometryObject,
|
sketch: GeometryObject,
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ class OCCSketch(SketchInterface):
|
|||||||
orthonormalised here. ``y_dir`` is derived as ``normal × x_dir``.
|
orthonormalised here. ``y_dir`` is derived as ``normal × x_dir``.
|
||||||
Existing UV coordinates are unchanged; only their world mapping moves.
|
Existing UV coordinates are unchanged; only their world mapping moves.
|
||||||
"""
|
"""
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
n = np.asarray(normal, dtype=float)
|
n = np.asarray(normal, dtype=float)
|
||||||
x = np.asarray(x_dir, dtype=float)
|
x = np.asarray(x_dir, dtype=float)
|
||||||
@@ -138,6 +137,7 @@ class OCCSketch(SketchInterface):
|
|||||||
def _uv_to_world(self, u: float, v: float):
|
def _uv_to_world(self, u: float, v: float):
|
||||||
"""Map a UV point to a world ``gp_Pnt`` on the workplane."""
|
"""Map a UV point to a world ``gp_Pnt`` on the workplane."""
|
||||||
from OCP.gp import gp_Pnt
|
from OCP.gp import gp_Pnt
|
||||||
|
|
||||||
ox, oy, oz = self._wp_origin
|
ox, oy, oz = self._wp_origin
|
||||||
xx, xy, xz = self._wp_x_dir
|
xx, xy, xz = self._wp_x_dir
|
||||||
yx, yy, yz = self._wp_y_dir
|
yx, yy, yz = self._wp_y_dir
|
||||||
@@ -150,6 +150,7 @@ class OCCSketch(SketchInterface):
|
|||||||
def _circle_axis(self, u: float, v: float):
|
def _circle_axis(self, u: float, v: float):
|
||||||
"""Return a ``gp_Ax2`` for a circle centred at UV on the workplane."""
|
"""Return a ``gp_Ax2`` for a circle centred at UV on the workplane."""
|
||||||
from OCP.gp import gp_Ax2, gp_Dir
|
from OCP.gp import gp_Ax2, gp_Dir
|
||||||
|
|
||||||
center = self._uv_to_world(u, v)
|
center = self._uv_to_world(u, v)
|
||||||
return gp_Ax2(
|
return gp_Ax2(
|
||||||
center,
|
center,
|
||||||
@@ -275,6 +276,7 @@ class OCCSketch(SketchInterface):
|
|||||||
When *None* the rendering will infer the shortest path between start and end.
|
When *None* the rendering will infer the shortest path between start and end.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
entity_id = self._next_id()
|
entity_id = self._next_id()
|
||||||
|
|
||||||
center_entity = self._entities.get(center.id)
|
center_entity = self._entities.get(center.id)
|
||||||
@@ -345,8 +347,10 @@ class OCCSketch(SketchInterface):
|
|||||||
self._solver.dragged(solver_handle, self._wp)
|
self._solver.dragged(solver_handle, self._wp)
|
||||||
|
|
||||||
entity = OCCSketchEntity(
|
entity = OCCSketchEntity(
|
||||||
entity_id=entity_id, entity_type="point",
|
entity_id=entity_id,
|
||||||
geometry=(x, y), handle=solver_handle,
|
entity_type="point",
|
||||||
|
geometry=(x, y),
|
||||||
|
handle=solver_handle,
|
||||||
)
|
)
|
||||||
entity.is_external = True
|
entity.is_external = True
|
||||||
entity.is_construction = True # external points are reference / dashed
|
entity.is_construction = True # external points are reference / dashed
|
||||||
@@ -377,7 +381,8 @@ class OCCSketch(SketchInterface):
|
|||||||
x1, y1 = s_ent.geometry
|
x1, y1 = s_ent.geometry
|
||||||
x2, y2 = e_ent.geometry
|
x2, y2 = e_ent.geometry
|
||||||
entity = OCCSketchEntity(
|
entity = OCCSketchEntity(
|
||||||
entity_id=entity_id, entity_type="line",
|
entity_id=entity_id,
|
||||||
|
entity_type="line",
|
||||||
geometry=((x1, y1), (x2, y2)),
|
geometry=((x1, y1), (x2, y2)),
|
||||||
handle=solver_handle,
|
handle=solver_handle,
|
||||||
)
|
)
|
||||||
@@ -388,6 +393,10 @@ class OCCSketch(SketchInterface):
|
|||||||
self._external_entity_ids.add(entity_id)
|
self._external_entity_ids.add(entity_id)
|
||||||
return entity
|
return entity
|
||||||
|
|
||||||
|
#: UV distance below which two projected points are considered the same
|
||||||
|
#: corner when importing / re-projecting external underlay geometry.
|
||||||
|
_EXTERNAL_MERGE_TOL: float = 1e-6
|
||||||
|
|
||||||
def add_external_polyline(
|
def add_external_polyline(
|
||||||
self, uv_points: List[Tuple[float, float]]
|
self, uv_points: List[Tuple[float, float]]
|
||||||
) -> Tuple[List[OCCSketchEntity], List[OCCSketchEntity]]:
|
) -> Tuple[List[OCCSketchEntity], List[OCCSketchEntity]]:
|
||||||
@@ -398,33 +407,108 @@ class OCCSketch(SketchInterface):
|
|||||||
``(points, lines)`` in the order they were created so the caller can
|
``(points, lines)`` in the order they were created so the caller can
|
||||||
keep references (e.g. for rendering or for toggling).
|
keep references (e.g. for rendering or for toggling).
|
||||||
|
|
||||||
Points very close to each other (within 1e-6 UV units) are merged
|
Points very close to each other (within ``_EXTERNAL_MERGE_TOL`` UV
|
||||||
into a single shared point, so a closed rectangle becomes 4 unique
|
units) are merged into a single shared point, so a closed rectangle
|
||||||
points and 4 line segments (not 4 points and 4 lines + 4 duplicates
|
becomes 4 unique points and 4 line segments (not 4 points and 4
|
||||||
at the corners).
|
lines + 4 duplicates at the corners). Merging also applies against
|
||||||
|
*previously imported* external points, so consecutive polylines that
|
||||||
|
share a corner (separate face edges meeting at a vertex) reuse one
|
||||||
|
point entity — the corner becomes a single connection hub for
|
||||||
|
coincident constraints instead of two stacked duplicates.
|
||||||
"""
|
"""
|
||||||
|
points, lines = self.add_external_polylines([uv_points])
|
||||||
|
return (points[0] if points else []), lines
|
||||||
|
|
||||||
|
def add_external_polylines(
|
||||||
|
self, polylines: List[List[Tuple[float, float]]]
|
||||||
|
) -> Tuple[List[List[OCCSketchEntity]], List[OCCSketchEntity]]:
|
||||||
|
"""Bulk-import several polylines with corner dedup *across* polylines.
|
||||||
|
|
||||||
|
A face projection yields one polyline per boundary edge; edges that
|
||||||
|
meet at a vertex must share a single external point entity, otherwise
|
||||||
|
every corner ends up as two independent fixed points and user geometry
|
||||||
|
coincident to one duplicate is *not* connected to geometry coincident
|
||||||
|
to the other. Returns ``(points_per_polyline, all_lines)``.
|
||||||
|
"""
|
||||||
|
tol = self._EXTERNAL_MERGE_TOL
|
||||||
|
|
||||||
|
def find_or_create(u: float, v: float) -> OCCSketchEntity:
|
||||||
|
# Tolerance-based nearest lookup against existing external points
|
||||||
|
# (entity counts are small — a face boundary has tens of points).
|
||||||
|
best: Optional[OCCSketchEntity] = None
|
||||||
|
best_d = tol
|
||||||
|
for eid in self._external_entity_ids:
|
||||||
|
ent = self._entities.get(eid)
|
||||||
|
if ent is None or ent.entity_type != "point" or ent.geometry is None:
|
||||||
|
continue
|
||||||
|
d = math.hypot(ent.geometry[0] - u, ent.geometry[1] - v)
|
||||||
|
if d <= best_d:
|
||||||
|
best_d = d
|
||||||
|
best = ent
|
||||||
|
if best is None:
|
||||||
|
best = self.add_external_point(float(u), float(v))
|
||||||
|
return best
|
||||||
|
|
||||||
|
all_points: List[List[OCCSketchEntity]] = []
|
||||||
|
all_lines: List[OCCSketchEntity] = []
|
||||||
|
for uv_points in polylines:
|
||||||
if len(uv_points) < 2:
|
if len(uv_points) < 2:
|
||||||
return [], []
|
continue
|
||||||
# Deduplicate nearby points so shared corners (e.g. a rectangle's
|
points = [find_or_create(float(u), float(v)) for (u, v) in uv_points]
|
||||||
# four vertices) are *one* point entity reused by two line segments.
|
all_points.append(points)
|
||||||
eps = 1e-6
|
|
||||||
points: List[OCCSketchEntity] = []
|
|
||||||
coord_to_entity: Dict[Tuple[int, int], OCCSketchEntity] = {}
|
|
||||||
for (u, v) in uv_points:
|
|
||||||
key = (int(round(u / eps)), int(round(v / eps)))
|
|
||||||
ent = coord_to_entity.get(key)
|
|
||||||
if ent is None:
|
|
||||||
ent = self.add_external_point(float(u), float(v))
|
|
||||||
coord_to_entity[key] = ent
|
|
||||||
points.append(ent)
|
|
||||||
lines: List[OCCSketchEntity] = []
|
|
||||||
for i in range(len(points) - 1):
|
for i in range(len(points) - 1):
|
||||||
|
if points[i] is points[i + 1]:
|
||||||
|
continue # degenerate zero-length segment after merging
|
||||||
|
# Skip duplicate segments (two edges projecting onto the
|
||||||
|
# same pair of corner points).
|
||||||
|
dupe = False
|
||||||
|
for lid, (sid, eid2) in self._lines.items():
|
||||||
|
if lid not in self._external_entity_ids:
|
||||||
|
continue
|
||||||
|
if (sid == points[i].id and eid2 == points[i + 1].id) or (
|
||||||
|
sid == points[i + 1].id and eid2 == points[i].id
|
||||||
|
):
|
||||||
|
dupe = True
|
||||||
|
break
|
||||||
|
if dupe:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
ln = self.add_external_line(points[i], points[i + 1])
|
ln = self.add_external_line(points[i], points[i + 1])
|
||||||
lines.append(ln)
|
all_lines.append(ln)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
return points, lines
|
return all_points, all_lines
|
||||||
|
|
||||||
|
def _drop_external_entities(self) -> set:
|
||||||
|
"""Remove external entities from local tracking + prune their constraints.
|
||||||
|
|
||||||
|
Does NOT rebuild the solver — the caller decides when to rebuild
|
||||||
|
(removal-only flows rebuild immediately; re-projection flows first
|
||||||
|
import the new externals so the rebuild sees them and doesn't
|
||||||
|
auto-anchor a user point instead).
|
||||||
|
"""
|
||||||
|
removed = set(self._external_entity_ids)
|
||||||
|
if not removed:
|
||||||
|
return removed
|
||||||
|
# Wipe external entities from local tracking.
|
||||||
|
for eid in list(removed):
|
||||||
|
if eid in self._entities:
|
||||||
|
del self._entities[eid]
|
||||||
|
self._points.pop(eid, None)
|
||||||
|
self._lines.pop(eid, None)
|
||||||
|
self._circles.pop(eid, None)
|
||||||
|
self._arcs.pop(eid, None)
|
||||||
|
# Also clean lines that USE an external point as an endpoint but
|
||||||
|
# somehow aren't themselves external (defensive — shouldn't happen
|
||||||
|
# via the public API, but rebuild_solver needs a clean graph).
|
||||||
|
for lid, (sid, eid2) in list(self._lines.items()):
|
||||||
|
if sid in removed or eid2 in removed:
|
||||||
|
del self._lines[lid]
|
||||||
|
if lid in self._entities:
|
||||||
|
del self._entities[lid]
|
||||||
|
self._external_entity_ids.clear()
|
||||||
|
self._prune_log_for(removed)
|
||||||
|
return removed
|
||||||
|
|
||||||
def remove_external_entities(self) -> None:
|
def remove_external_entities(self) -> None:
|
||||||
"""Remove every external / underlay entity and prune related constraints.
|
"""Remove every external / underlay entity and prune related constraints.
|
||||||
@@ -437,25 +521,7 @@ class OCCSketch(SketchInterface):
|
|||||||
"""
|
"""
|
||||||
if not self._external_entity_ids:
|
if not self._external_entity_ids:
|
||||||
return
|
return
|
||||||
# Wipe external entities from local tracking.
|
self._drop_external_entities()
|
||||||
for eid in list(self._external_entity_ids):
|
|
||||||
if eid in self._entities:
|
|
||||||
del self._entities[eid]
|
|
||||||
self._points.pop(eid, None)
|
|
||||||
self._lines.pop(eid, None)
|
|
||||||
self._circles.pop(eid, None)
|
|
||||||
self._arcs.pop(eid, None)
|
|
||||||
# Also clean lines that USE an external point as an endpoint but
|
|
||||||
# somehow aren't themselves external (defensive — shouldn't happen
|
|
||||||
# via the public API, but rebuild_solver needs a clean graph).
|
|
||||||
for lid, (sid, eid2) in list(self._lines.items()):
|
|
||||||
if sid in self._external_entity_ids or eid2 in self._external_entity_ids:
|
|
||||||
del self._lines[lid]
|
|
||||||
if lid in self._entities:
|
|
||||||
del self._entities[lid]
|
|
||||||
removed = set(self._external_entity_ids)
|
|
||||||
self._external_entity_ids.clear()
|
|
||||||
self._prune_log_for(removed)
|
|
||||||
self._rebuild_solver()
|
self._rebuild_solver()
|
||||||
self._rebuild_labels()
|
self._rebuild_labels()
|
||||||
|
|
||||||
@@ -463,6 +529,184 @@ class OCCSketch(SketchInterface):
|
|||||||
"""Return the set of external (underlay) entity ids currently in the sketch."""
|
"""Return the set of external (underlay) entity ids currently in the sketch."""
|
||||||
return set(self._external_entity_ids)
|
return set(self._external_entity_ids)
|
||||||
|
|
||||||
|
def update_external_entities(self, polylines: List[List[Tuple[float, float]]]) -> bool:
|
||||||
|
"""Re-project external (underlay) entities from updated source geometry.
|
||||||
|
|
||||||
|
Called when the 3D body the underlay was projected from has been
|
||||||
|
rebuilt (e.g. its source sketch was edited and re-extruded) and the
|
||||||
|
face edges were re-projected to UV. The underlay must follow the
|
||||||
|
body so user geometry constrained to it propagates through the
|
||||||
|
solver.
|
||||||
|
|
||||||
|
Two paths:
|
||||||
|
|
||||||
|
* **In-place update** (same topology): when the new projection has
|
||||||
|
the same number of unique corner points and segments, each existing
|
||||||
|
external point is paired with the nearest new position (greedy
|
||||||
|
one-to-one) and moved via ``set_params``. Entity ids and every
|
||||||
|
constraint referencing them survive untouched, and the next
|
||||||
|
:meth:`solve` pulls the user geometry along.
|
||||||
|
* **Rebuild + rebind** (topology changed): external entities are
|
||||||
|
removed and re-imported; constraints that referenced external
|
||||||
|
entities are re-created against the nearest new external entity
|
||||||
|
(point-to-point for coincident on corners, point-on-line for
|
||||||
|
coincident on edges) so user geometry stays anchored.
|
||||||
|
|
||||||
|
Returns True when the underlay was updated and solved OK.
|
||||||
|
"""
|
||||||
|
# Flatten the new projection into unique corner positions + segments.
|
||||||
|
tol = self._EXTERNAL_MERGE_TOL
|
||||||
|
new_pts: List[Tuple[float, float]] = []
|
||||||
|
|
||||||
|
def new_index(u: float, v: float) -> int:
|
||||||
|
for i, (x, y) in enumerate(new_pts):
|
||||||
|
if math.hypot(x - u, y - v) <= tol:
|
||||||
|
return i
|
||||||
|
new_pts.append((float(u), float(v)))
|
||||||
|
return len(new_pts) - 1
|
||||||
|
|
||||||
|
new_segs: List[Tuple[int, int]] = []
|
||||||
|
for poly in polylines:
|
||||||
|
if len(poly) < 2:
|
||||||
|
continue
|
||||||
|
idx = [new_index(float(u), float(v)) for (u, v) in poly]
|
||||||
|
for i in range(len(idx) - 1):
|
||||||
|
if idx[i] == idx[i + 1]:
|
||||||
|
continue
|
||||||
|
seg = (min(idx[i], idx[i + 1]), max(idx[i], idx[i + 1]))
|
||||||
|
if seg not in new_segs:
|
||||||
|
new_segs.append(seg)
|
||||||
|
|
||||||
|
old_ext_points = [
|
||||||
|
self._entities[eid]
|
||||||
|
for eid in sorted(self._external_entity_ids)
|
||||||
|
if eid in self._entities and self._entities[eid].entity_type == "point"
|
||||||
|
]
|
||||||
|
old_ext_lines = [
|
||||||
|
lid for lid in sorted(self._lines.keys()) if lid in self._external_entity_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
same_topology = len(new_pts) == len(old_ext_points) and len(new_segs) == len(old_ext_lines)
|
||||||
|
|
||||||
|
if same_topology and old_ext_points:
|
||||||
|
# Greedy one-to-one nearest matching old point -> new position.
|
||||||
|
pairs: List[Tuple[float, int, int]] = [] # (dist, old_idx, new_idx)
|
||||||
|
for oi, ent in enumerate(old_ext_points):
|
||||||
|
ox, oy = ent.geometry
|
||||||
|
for ni, (nx, ny) in enumerate(new_pts):
|
||||||
|
pairs.append((math.hypot(ox - nx, oy - ny), oi, ni))
|
||||||
|
pairs.sort()
|
||||||
|
match: Dict[int, int] = {}
|
||||||
|
used_new: set = set()
|
||||||
|
for d, oi, ni in pairs:
|
||||||
|
if oi in match or ni in used_new:
|
||||||
|
continue
|
||||||
|
match[oi] = ni
|
||||||
|
used_new.add(ni)
|
||||||
|
if len(match) == len(old_ext_points):
|
||||||
|
# Verify segment connectivity is preserved under the matching
|
||||||
|
# (same corners, but edges rewired -> rebuild instead).
|
||||||
|
mapped_segs = set()
|
||||||
|
for a, b in new_segs:
|
||||||
|
mapped_segs.add((a, b))
|
||||||
|
connectivity_ok = True
|
||||||
|
for lid in old_ext_lines:
|
||||||
|
sid, eid2 = self._lines[lid]
|
||||||
|
oi_s = next((i for i, e in enumerate(old_ext_points) if e.id == sid), None)
|
||||||
|
oi_e = next((i for i, e in enumerate(old_ext_points) if e.id == eid2), None)
|
||||||
|
if oi_s is None or oi_e is None:
|
||||||
|
connectivity_ok = False
|
||||||
|
break
|
||||||
|
seg = (min(match[oi_s], match[oi_e]), max(match[oi_s], match[oi_e]))
|
||||||
|
if seg not in mapped_segs:
|
||||||
|
connectivity_ok = False
|
||||||
|
break
|
||||||
|
if connectivity_ok:
|
||||||
|
for oi, ent in enumerate(old_ext_points):
|
||||||
|
nx, ny = new_pts[match[oi]]
|
||||||
|
self.set_entity_position(ent, nx, ny)
|
||||||
|
return self.solve()
|
||||||
|
|
||||||
|
# ── Rebuild + rebind path (topology changed, or no externals yet) ──
|
||||||
|
# Capture constraints that tie USER entities to external entities so
|
||||||
|
# they can be re-created against the nearest new external entity.
|
||||||
|
saved: List[Dict[str, Any]] = []
|
||||||
|
for entry in self._constraint_log:
|
||||||
|
ext_ids = [i for i in entry["ids"] if i in self._external_entity_ids]
|
||||||
|
user_ids = [i for i in entry["ids"] if i not in self._external_entity_ids]
|
||||||
|
if not ext_ids or not user_ids:
|
||||||
|
continue
|
||||||
|
for ext_id in ext_ids:
|
||||||
|
ent = self._entities.get(ext_id)
|
||||||
|
if ent is None:
|
||||||
|
continue
|
||||||
|
if ent.entity_type == "point" and ent.geometry is not None:
|
||||||
|
anchor: Any = ("point", ext_id, tuple(ent.geometry))
|
||||||
|
elif ent.entity_type == "line" and ext_id in self._lines:
|
||||||
|
sid, eid2 = self._lines[ext_id]
|
||||||
|
s_ent = self._entities.get(sid)
|
||||||
|
e_ent = self._entities.get(eid2)
|
||||||
|
if s_ent is None or e_ent is None:
|
||||||
|
continue
|
||||||
|
mx = (s_ent.geometry[0] + e_ent.geometry[0]) / 2.0
|
||||||
|
my = (s_ent.geometry[1] + e_ent.geometry[1]) / 2.0
|
||||||
|
anchor = ("line", ext_id, (mx, my))
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
saved.append({"type": entry["type"], "user_ids": list(user_ids), "anchor": anchor})
|
||||||
|
|
||||||
|
# Drop old externals, import the new projection, then rebuild the
|
||||||
|
# solver exactly once. The import MUST happen before the rebuild:
|
||||||
|
# with external ids present the rebuild re-fixes the new underlay
|
||||||
|
# points and (per the add_point guard) does not auto-anchor a user
|
||||||
|
# point — which would conflict with the re-bound coincidents below.
|
||||||
|
self._drop_external_entities()
|
||||||
|
self.add_external_polylines(polylines)
|
||||||
|
self._rebuild_solver()
|
||||||
|
self._rebuild_labels()
|
||||||
|
|
||||||
|
# Rebind saved constraints to the nearest new external entity.
|
||||||
|
rebound = 0
|
||||||
|
for item in saved:
|
||||||
|
kind, _old_id, pos = item["anchor"]
|
||||||
|
target: Optional[OCCSketchEntity] = None
|
||||||
|
best_d = float("inf")
|
||||||
|
if kind == "point":
|
||||||
|
for eid in self._external_entity_ids:
|
||||||
|
ent = self._entities.get(eid)
|
||||||
|
if ent is None or ent.entity_type != "point" or ent.geometry is None:
|
||||||
|
continue
|
||||||
|
d = math.hypot(ent.geometry[0] - pos[0], ent.geometry[1] - pos[1])
|
||||||
|
if d < best_d:
|
||||||
|
best_d = d
|
||||||
|
target = ent
|
||||||
|
else: # line: nearest segment midpoint
|
||||||
|
for lid, (sid, eid2) in self._lines.items():
|
||||||
|
if lid not in self._external_entity_ids:
|
||||||
|
continue
|
||||||
|
s_ent = self._entities.get(sid)
|
||||||
|
e_ent = self._entities.get(eid2)
|
||||||
|
if s_ent is None or e_ent is None:
|
||||||
|
continue
|
||||||
|
mx = (s_ent.geometry[0] + e_ent.geometry[0]) / 2.0
|
||||||
|
my = (s_ent.geometry[1] + e_ent.geometry[1]) / 2.0
|
||||||
|
d = math.hypot(mx - pos[0], my - pos[1])
|
||||||
|
if d < best_d:
|
||||||
|
best_d = d
|
||||||
|
target = self._entities.get(lid)
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
for uid in item["user_ids"]:
|
||||||
|
user_ent = self._entities.get(uid)
|
||||||
|
if user_ent is None:
|
||||||
|
continue
|
||||||
|
if item["type"] == "coincident":
|
||||||
|
if self.constrain_coincident(user_ent, target):
|
||||||
|
rebound += 1
|
||||||
|
if rebound:
|
||||||
|
logger.info("Rebound %d constraint(s) to re-projected underlay", rebound)
|
||||||
|
return self.solve()
|
||||||
|
|
||||||
# ── Centerlines (X and Y reference axes) ────────────────────────────
|
# ── Centerlines (X and Y reference axes) ────────────────────────────
|
||||||
|
|
||||||
_CENTERLINE_EXTENT: float = 10000.0 # large enough to span any sketch
|
_CENTERLINE_EXTENT: float = 10000.0 # large enough to span any sketch
|
||||||
@@ -514,8 +758,13 @@ class OCCSketch(SketchInterface):
|
|||||||
self.constrain_fixed(yb)
|
self.constrain_fixed(yb)
|
||||||
|
|
||||||
self._centerline_ids = {
|
self._centerline_ids = {
|
||||||
origin.id, xl.id, xr.id, xline.id,
|
origin.id,
|
||||||
yb.id, yt.id, yline.id,
|
xl.id,
|
||||||
|
xr.id,
|
||||||
|
xline.id,
|
||||||
|
yb.id,
|
||||||
|
yt.id,
|
||||||
|
yline.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.solve()
|
self.solve()
|
||||||
@@ -557,7 +806,12 @@ class OCCSketch(SketchInterface):
|
|||||||
"""Count and log a constraint so the solver can be rebuilt after deletions."""
|
"""Count and log a constraint so the solver can be rebuilt after deletions."""
|
||||||
self._constraint_count += 1
|
self._constraint_count += 1
|
||||||
self._constraint_log.append(
|
self._constraint_log.append(
|
||||||
{"type": ctype, "ids": tuple(int(i) for i in ids), "params": tuple(params), "labels": set(labels)}
|
{
|
||||||
|
"type": ctype,
|
||||||
|
"ids": tuple(int(i) for i in ids),
|
||||||
|
"params": tuple(params),
|
||||||
|
"labels": set(labels),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_constraint_log(self, entry: Dict[str, Any]) -> bool:
|
def _apply_constraint_log(self, entry: Dict[str, Any]) -> bool:
|
||||||
@@ -666,7 +920,16 @@ class OCCSketch(SketchInterface):
|
|||||||
x, y = saved_pos.get(pid, (0.0, 0.0))
|
x, y = saved_pos.get(pid, (0.0, 0.0))
|
||||||
new_handle = self._solver.add_point_2d(x, y, self._wp)
|
new_handle = self._solver.add_point_2d(x, y, self._wp)
|
||||||
ent.handle = new_handle
|
ent.handle = new_handle
|
||||||
if self._first_point_id is None:
|
if pid in self._external_entity_ids:
|
||||||
|
# External (underlay) points are ALWAYS fixed — the dragged
|
||||||
|
# applied at creation isn't in the constraint log, so it must
|
||||||
|
# be re-applied here or the underlay becomes draggable after
|
||||||
|
# any solver rebuild (e.g. deleting an unrelated user point).
|
||||||
|
self._solver.dragged(new_handle, self._wp)
|
||||||
|
elif self._first_point_id is None and not self._external_entity_ids:
|
||||||
|
# Mirror add_point's guard: when the sketch carries external
|
||||||
|
# underlay points those are the natural anchors, and fixing a
|
||||||
|
# user point too would over-constrain the system.
|
||||||
self._first_point_id = pid
|
self._first_point_id = pid
|
||||||
self._solver.dragged(new_handle, self._wp)
|
self._solver.dragged(new_handle, self._wp)
|
||||||
|
|
||||||
@@ -1008,7 +1271,12 @@ class OCCSketch(SketchInterface):
|
|||||||
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
|
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
|
||||||
|
|
||||||
for entity in self._entities.values():
|
for entity in self._entities.values():
|
||||||
if entity.entity_type == "line" and entity.geometry and not entity.is_external and not entity.is_construction:
|
if (
|
||||||
|
entity.entity_type == "line"
|
||||||
|
and entity.geometry
|
||||||
|
and not entity.is_external
|
||||||
|
and not entity.is_construction
|
||||||
|
):
|
||||||
p1, p2 = entity.geometry
|
p1, p2 = entity.geometry
|
||||||
if p1 not in adjacency:
|
if p1 not in adjacency:
|
||||||
adjacency[p1] = []
|
adjacency[p1] = []
|
||||||
@@ -1043,7 +1311,7 @@ class OCCSketch(SketchInterface):
|
|||||||
|
|
||||||
# ─── Closed-loop / face detection (for region selection + holes) ──────
|
# ─── Closed-loop / face detection (for region selection + holes) ──────
|
||||||
|
|
||||||
_SNAP_TOL: float = 1e-4 # world-unit tolerance for snapping line endpoints
|
_SNAP_TOL: float = 1e-2 # world-unit tolerance for snapping line endpoints in loop detection
|
||||||
|
|
||||||
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
|
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
|
||||||
"""Current line segments as world-coordinate tuples (uses solved positions).
|
"""Current line segments as world-coordinate tuples (uses solved positions).
|
||||||
@@ -1068,8 +1336,12 @@ class OCCSketch(SketchInterface):
|
|||||||
s_ent = self._entities.get(sid)
|
s_ent = self._entities.get(sid)
|
||||||
e_ent = self._entities.get(eid2)
|
e_ent = self._entities.get(eid2)
|
||||||
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
|
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
|
||||||
segs.append(((float(s_ent.geometry[0]), float(s_ent.geometry[1])),
|
segs.append(
|
||||||
(float(e_ent.geometry[0]), float(e_ent.geometry[1]))))
|
(
|
||||||
|
(float(s_ent.geometry[0]), float(s_ent.geometry[1])),
|
||||||
|
(float(e_ent.geometry[0]), float(e_ent.geometry[1])),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# ── Arc segments (tessellated) ──
|
# ── Arc segments (tessellated) ──
|
||||||
for arc_id, arc_data in self._arcs.items():
|
for arc_id, arc_data in self._arcs.items():
|
||||||
@@ -1086,8 +1358,9 @@ class OCCSketch(SketchInterface):
|
|||||||
c_ent = self._entities.get(center_id)
|
c_ent = self._entities.get(center_id)
|
||||||
s_ent = self._entities.get(start_id)
|
s_ent = self._entities.get(start_id)
|
||||||
e_ent = self._entities.get(end_id)
|
e_ent = self._entities.get(end_id)
|
||||||
if not (c_ent and s_ent and e_ent
|
if not (
|
||||||
and c_ent.geometry and s_ent.geometry and e_ent.geometry):
|
c_ent and s_ent and e_ent and c_ent.geometry and s_ent.geometry and e_ent.geometry
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
cx, cy = c_ent.geometry
|
cx, cy = c_ent.geometry
|
||||||
sx, sy = s_ent.geometry
|
sx, sy = s_ent.geometry
|
||||||
@@ -1099,10 +1372,8 @@ class OCCSketch(SketchInterface):
|
|||||||
t2 = (i + 1) / n
|
t2 = (i + 1) / n
|
||||||
a1 = start_angle + t1 * sweep
|
a1 = start_angle + t1 * sweep
|
||||||
a2 = start_angle + t2 * sweep
|
a2 = start_angle + t2 * sweep
|
||||||
p1 = (cx + radius * math.cos(a1),
|
p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1))
|
||||||
cy + radius * math.sin(a1))
|
p2 = (cx + radius * math.cos(a2), cy + radius * math.sin(a2))
|
||||||
p2 = (cx + radius * math.cos(a2),
|
|
||||||
cy + radius * math.sin(a2))
|
|
||||||
segs.append((p1, p2))
|
segs.append((p1, p2))
|
||||||
|
|
||||||
return segs
|
return segs
|
||||||
@@ -1180,8 +1451,13 @@ class OCCSketch(SketchInterface):
|
|||||||
for cid, (center_id, r) in self._circles.items():
|
for cid, (center_id, r) in self._circles.items():
|
||||||
c_ent = self._entities.get(center_id)
|
c_ent = self._entities.get(center_id)
|
||||||
if c_ent and c_ent.geometry and r > 0:
|
if c_ent and c_ent.geometry and r > 0:
|
||||||
loops.append({"type": "circle", "center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
|
loops.append(
|
||||||
"radius": float(r)})
|
{
|
||||||
|
"type": "circle",
|
||||||
|
"center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
|
||||||
|
"radius": float(r),
|
||||||
|
}
|
||||||
|
)
|
||||||
return loops
|
return loops
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1212,7 +1488,10 @@ class OCCSketch(SketchInterface):
|
|||||||
# Point-on-segment test — exclude strict boundary hits.
|
# Point-on-segment test — exclude strict boundary hits.
|
||||||
# First check bounding box of the segment.
|
# First check bounding box of the segment.
|
||||||
bbox_tol = max(eps, margin)
|
bbox_tol = max(eps, margin)
|
||||||
if min(xi, xj) - bbox_tol <= x <= max(xi, xj) + bbox_tol and min(yi, yj) - bbox_tol <= y <= max(yi, yj) + bbox_tol:
|
if (
|
||||||
|
min(xi, xj) - bbox_tol <= x <= max(xi, xj) + bbox_tol
|
||||||
|
and min(yi, yj) - bbox_tol <= y <= max(yi, yj) + bbox_tol
|
||||||
|
):
|
||||||
# Check collinearity
|
# Check collinearity
|
||||||
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
|
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
|
||||||
abs_cross = abs(cross)
|
abs_cross = abs(cross)
|
||||||
@@ -1269,18 +1548,14 @@ class OCCSketch(SketchInterface):
|
|||||||
if inner["type"] == "circle":
|
if inner["type"] == "circle":
|
||||||
# Circle in polygon: centre must be inside with margin
|
# Circle in polygon: centre must be inside with margin
|
||||||
cx, cy = inner["center"]
|
cx, cy = inner["center"]
|
||||||
return OCCSketch._point_in_polygon(
|
return OCCSketch._point_in_polygon((cx, cy), outer["points"], margin=1e-3)
|
||||||
(cx, cy), outer["points"], margin=1e-3
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Polygon in polygon: ALL inner vertices inside outer
|
# Polygon in polygon: ALL inner vertices inside outer
|
||||||
pts = inner["points"]
|
pts = inner["points"]
|
||||||
if len(pts) > 1 and pts[0] == pts[-1]:
|
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||||
pts = pts[:-1]
|
pts = pts[:-1]
|
||||||
for pt in pts:
|
for pt in pts:
|
||||||
if not OCCSketch._point_in_polygon(
|
if not OCCSketch._point_in_polygon(pt, outer["points"], margin=eps):
|
||||||
pt, outer["points"], margin=eps
|
|
||||||
):
|
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1293,7 +1568,11 @@ class OCCSketch(SketchInterface):
|
|||||||
the centre for circles.
|
the centre for circles.
|
||||||
"""
|
"""
|
||||||
if loop["type"] == "polygon":
|
if loop["type"] == "polygon":
|
||||||
pts = loop["points"][:-1] if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1] else loop["points"]
|
pts = (
|
||||||
|
loop["points"][:-1]
|
||||||
|
if len(loop["points"]) > 1 and loop["points"][0] == loop["points"][-1]
|
||||||
|
else loop["points"]
|
||||||
|
)
|
||||||
n = len(pts)
|
n = len(pts)
|
||||||
if n < 3:
|
if n < 3:
|
||||||
return loop.get("center", (0.0, 0.0))
|
return loop.get("center", (0.0, 0.0))
|
||||||
@@ -1374,11 +1653,13 @@ class OCCSketch(SketchInterface):
|
|||||||
for h in face["holes"]:
|
for h in face["holes"]:
|
||||||
if h["type"] == "polygon":
|
if h["type"] == "polygon":
|
||||||
if OCCSketch._point_in_polygon(pt, h["points"]):
|
if OCCSketch._point_in_polygon(pt, h["points"]):
|
||||||
in_hole = True; break
|
in_hole = True
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
hcx, hcy = h["center"]
|
hcx, hcy = h["center"]
|
||||||
if math.hypot(pt[0] - hcx, pt[1] - hcy) < h["radius"]:
|
if math.hypot(pt[0] - hcx, pt[1] - hcy) < h["radius"]:
|
||||||
in_hole = True; break
|
in_hole = True
|
||||||
|
break
|
||||||
if in_hole:
|
if in_hole:
|
||||||
continue
|
continue
|
||||||
area = OCCSketch._loop_area(outer)
|
area = OCCSketch._loop_area(outer)
|
||||||
@@ -1426,17 +1707,19 @@ class OCCSketch(SketchInterface):
|
|||||||
from top-left to bottom-right.
|
from top-left to bottom-right.
|
||||||
"""
|
"""
|
||||||
from OCP.BRepBuilderAPI import (
|
from OCP.BRepBuilderAPI import (
|
||||||
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
|
BRepBuilderAPI_MakePolygon,
|
||||||
BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeEdge,
|
BRepBuilderAPI_MakeFace,
|
||||||
|
BRepBuilderAPI_MakeWire,
|
||||||
|
BRepBuilderAPI_MakeEdge,
|
||||||
)
|
)
|
||||||
from OCP.gp import gp_Pnt, gp_Circ, gp_Ax2, gp_Dir
|
from OCP.gp import gp_Circ
|
||||||
from OCP.TopoDS import TopoDS as _TopoDS
|
from OCP.TopoDS import TopoDS as _TopoDS
|
||||||
|
|
||||||
def _wire_from_loop(loop: Dict[str, Any]):
|
def _wire_from_loop(loop: Dict[str, Any]):
|
||||||
"""Build a wire from a loop dict. No orientation adjustment."""
|
"""Build a wire from a loop dict. No orientation adjustment."""
|
||||||
if loop["type"] == "polygon":
|
if loop["type"] == "polygon":
|
||||||
mp = BRepBuilderAPI_MakePolygon()
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
for (pu, pv) in loop["points"]:
|
for pu, pv in loop["points"]:
|
||||||
mp.Add(self._uv_to_world(pu, pv))
|
mp.Add(self._uv_to_world(pu, pv))
|
||||||
mp.Close()
|
mp.Close()
|
||||||
mp.Build()
|
mp.Build()
|
||||||
@@ -1463,17 +1746,22 @@ class OCCSketch(SketchInterface):
|
|||||||
# wire (material on the other side). We reverse the hole wire
|
# wire (material on the other side). We reverse the hole wire
|
||||||
# only when its natural winding matches the outer's; if they
|
# only when its natural winding matches the outer's; if they
|
||||||
# already differ the wire is left as-is.
|
# already differ the wire is left as-is.
|
||||||
if (hole_winding >= 0 and outer_winding >= 0) or (hole_winding < 0 and outer_winding < 0):
|
if (hole_winding >= 0 and outer_winding >= 0) or (
|
||||||
|
hole_winding < 0 and outer_winding < 0
|
||||||
|
):
|
||||||
hole_wire = _TopoDS.Wire_s(hole_wire.Reversed())
|
hole_wire = _TopoDS.Wire_s(hole_wire.Reversed())
|
||||||
face_maker.Add(hole_wire)
|
face_maker.Add(hole_wire)
|
||||||
face_maker.Build()
|
face_maker.Build()
|
||||||
occ_face = face_maker.Face()
|
occ_face = face_maker.Face()
|
||||||
|
|
||||||
obj = OCCGeometryObject(occ_face, {
|
obj = OCCGeometryObject(
|
||||||
|
occ_face,
|
||||||
|
{
|
||||||
"type": "sketch_face",
|
"type": "sketch_face",
|
||||||
"normal": self._wp_normal,
|
"normal": self._wp_normal,
|
||||||
"origin": self._wp_origin,
|
"origin": self._wp_origin,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def get_solver_dof(self) -> int:
|
def get_solver_dof(self) -> int:
|
||||||
@@ -1579,7 +1867,8 @@ class OCCSketch(SketchInterface):
|
|||||||
removed_ids: set = {point.id}
|
removed_ids: set = {point.id}
|
||||||
# Remove lines that use this point as an endpoint.
|
# Remove lines that use this point as an endpoint.
|
||||||
removed_line_keys: List[int] = [
|
removed_line_keys: List[int] = [
|
||||||
lid for lid, (sid, eid2) in list(self._lines.items())
|
lid
|
||||||
|
for lid, (sid, eid2) in list(self._lines.items())
|
||||||
if sid == point.id or eid2 == point.id
|
if sid == point.id or eid2 == point.id
|
||||||
]
|
]
|
||||||
for lid in removed_line_keys:
|
for lid in removed_line_keys:
|
||||||
@@ -1593,8 +1882,7 @@ class OCCSketch(SketchInterface):
|
|||||||
del self._entities[point.id]
|
del self._entities[point.id]
|
||||||
# Circles anchored on the point are also invalid.
|
# Circles anchored on the point are also invalid.
|
||||||
removed_circle_keys: List[int] = [
|
removed_circle_keys: List[int] = [
|
||||||
cid for cid, (center_id, _r) in list(self._circles.items())
|
cid for cid, (center_id, _r) in list(self._circles.items()) if center_id == point.id
|
||||||
if center_id == point.id
|
|
||||||
]
|
]
|
||||||
for cid in removed_circle_keys:
|
for cid in removed_circle_keys:
|
||||||
removed_ids.add(cid)
|
removed_ids.add(cid)
|
||||||
@@ -1603,7 +1891,8 @@ class OCCSketch(SketchInterface):
|
|||||||
del self._entities[cid]
|
del self._entities[cid]
|
||||||
# Arcs referencing this point (as centre, start, or end) are invalid.
|
# Arcs referencing this point (as centre, start, or end) are invalid.
|
||||||
removed_arc_keys: List[int] = [
|
removed_arc_keys: List[int] = [
|
||||||
aid for aid, adata in list(self._arcs.items())
|
aid
|
||||||
|
for aid, adata in list(self._arcs.items())
|
||||||
if adata.get("center") == point.id
|
if adata.get("center") == point.id
|
||||||
or adata.get("start") == point.id
|
or adata.get("start") == point.id
|
||||||
or adata.get("end") == point.id
|
or adata.get("end") == point.id
|
||||||
@@ -1820,9 +2109,7 @@ class OCCSketch(SketchInterface):
|
|||||||
s_id = self._find_point_at(x1, y1)
|
s_id = self._find_point_at(x1, y1)
|
||||||
e_id = self._find_point_at(x2, y2)
|
e_id = self._find_point_at(x2, y2)
|
||||||
if s_id is None or e_id is None:
|
if s_id is None or e_id is None:
|
||||||
logger.warning(
|
logger.warning("Skipping line %s during load: endpoints not found", eid)
|
||||||
"Skipping line %s during load: endpoints not found", eid
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
if is_external:
|
if is_external:
|
||||||
ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id])
|
ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id])
|
||||||
@@ -1832,9 +2119,7 @@ class OCCSketch(SketchInterface):
|
|||||||
(cx, cy), radius = geom
|
(cx, cy), radius = geom
|
||||||
c_id = self._find_point_at(cx, cy)
|
c_id = self._find_point_at(cx, cy)
|
||||||
if c_id is None:
|
if c_id is None:
|
||||||
logger.warning(
|
logger.warning("Skipping circle %s during load: center not found", eid)
|
||||||
"Skipping circle %s during load: center not found", eid
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
ent = self.add_circle(entities_by_id[c_id], float(radius))
|
ent = self.add_circle(entities_by_id[c_id], float(radius))
|
||||||
elif etype == "arc":
|
elif etype == "arc":
|
||||||
@@ -1847,9 +2132,7 @@ class OCCSketch(SketchInterface):
|
|||||||
s_id = self._find_point_at(*start_pos)
|
s_id = self._find_point_at(*start_pos)
|
||||||
e_id = self._find_point_at(*end_pos)
|
e_id = self._find_point_at(*end_pos)
|
||||||
if c_id is None or s_id is None or e_id is None:
|
if c_id is None or s_id is None or e_id is None:
|
||||||
logger.warning(
|
logger.warning("Skipping arc %s during load: endpoints not found", eid)
|
||||||
"Skipping arc %s during load: endpoints not found", eid
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
ent = self.add_arc(
|
ent = self.add_arc(
|
||||||
entities_by_id[c_id],
|
entities_by_id[c_id],
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""Surface modifier for OpenCASCADE geometry.
|
||||||
|
|
||||||
|
Applies geometric patterns (pyramids, bumps, grooves) to 3D surfaces using boolean operations.
|
||||||
|
This enables grip-enhancing textures and visual surface modifications on CAD models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from typing import Any, Optional, Tuple
|
||||||
|
|
||||||
|
# OCC imports at module level for common types
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SurfaceModifier:
|
||||||
|
"""Applies geometric patterns to 3D surfaces using OCC boolean operations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._patterns_applied = []
|
||||||
|
|
||||||
|
def apply_pyramid_pattern(
|
||||||
|
self,
|
||||||
|
face_shape,
|
||||||
|
pyramid_height: float = 1.0,
|
||||||
|
base_radius: float = 2.0,
|
||||||
|
spacing: float = 5.0,
|
||||||
|
num_rings: Optional[int] = None,
|
||||||
|
direction: Tuple[float, float, float] = (0, 0, 1),
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""Apply a pyramid pattern to a face surface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
face_shape: OCC TopoDS_Shape representing the face or solid
|
||||||
|
pyramid_height: Height of each pyramid
|
||||||
|
base_radius: Radius of pyramid base
|
||||||
|
spacing: Distance between pyramids
|
||||||
|
num_rings: Number of concentric rings (auto-calculated if None)
|
||||||
|
direction: Normal direction for pyramids
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified shape on success, None on failure
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from OCP.TopAbs import TopAbs_FACE
|
||||||
|
from OCP.TopoDS import TopoDS_Face, TopoDS_Shape
|
||||||
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
|
||||||
|
from OCP.TopExp import TopExp_Explorer
|
||||||
|
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||||
|
|
||||||
|
# Validate face shape
|
||||||
|
if not isinstance(face_shape, (TopoDS_Shape, TopoDS_Face)):
|
||||||
|
logger.error("Invalid face shape type")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract the first face for surface parameterization
|
||||||
|
if isinstance(face_shape, TopoDS_Shape):
|
||||||
|
explorer = TopExp_Explorer(face_shape, TopAbs_FACE)
|
||||||
|
if not explorer.More():
|
||||||
|
logger.error("No faces found in shape")
|
||||||
|
return None
|
||||||
|
from OCP import TopoDS
|
||||||
|
face = TopoDS.TopoDS.Face_s(explorer.Current())
|
||||||
|
else:
|
||||||
|
face = face_shape
|
||||||
|
|
||||||
|
# Get face surface for UV parameterization
|
||||||
|
surf = BRepAdaptor_Surface(face)
|
||||||
|
|
||||||
|
u_min, u_max = surf.FirstUParameter(), surf.LastUParameter()
|
||||||
|
v_min, v_max = surf.FirstVParameter(), surf.LastVParameter()
|
||||||
|
|
||||||
|
# Calculate number of rings if not specified
|
||||||
|
if num_rings is None:
|
||||||
|
# Estimate based on face area and spacing
|
||||||
|
u_range = u_max - u_min
|
||||||
|
v_range = v_max - v_min
|
||||||
|
avg_dim = (u_range + v_range) / 2.0
|
||||||
|
num_rings = max(1, min(int(avg_dim / spacing), 5))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Applying pyramid pattern: {num_rings} rings, "
|
||||||
|
f"{base_radius:.2f} radius, {pyramid_height:.2f} height"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create pyramids distributed across the face UV space
|
||||||
|
result_shape = face_shape
|
||||||
|
pyramid_count = 0
|
||||||
|
|
||||||
|
for ring_idx in range(num_rings):
|
||||||
|
# Distribute rings evenly across UV parameter space
|
||||||
|
u_fraction = (ring_idx + 1) / (num_rings + 1)
|
||||||
|
v_fraction = 0.5 # Center vertically
|
||||||
|
|
||||||
|
# Map to actual UV coordinates on the face
|
||||||
|
u_pos = u_min + u_fraction * (u_max - u_min)
|
||||||
|
v_pos = v_min + v_fraction * (v_max - v_min)
|
||||||
|
|
||||||
|
# Get 3D position and tangent vectors at this UV point
|
||||||
|
from OCP.gp import gp_Pnt, gp_Vec
|
||||||
|
|
||||||
|
center_pt = gp_Pnt()
|
||||||
|
d1u = gp_Vec()
|
||||||
|
d1v = gp_Vec()
|
||||||
|
surf.D1(u_pos, v_pos, center_pt, d1u, d1v)
|
||||||
|
# Normal is cross product of tangent vectors
|
||||||
|
normal = d1u.Crossed(d1v)
|
||||||
|
normal.Normalize()
|
||||||
|
|
||||||
|
# Calculate number of pyramids in this ring based on spacing
|
||||||
|
if ring_idx == 0:
|
||||||
|
num_pyramids = 1 # Center pyramid
|
||||||
|
else:
|
||||||
|
circumference = 2.0 * math.pi * (ring_idx * spacing)
|
||||||
|
num_pyramids = max(3, int(circumference / spacing))
|
||||||
|
|
||||||
|
for i in range(num_pyramids):
|
||||||
|
if ring_idx == 0:
|
||||||
|
# Center pyramid - place at face center
|
||||||
|
place_u = u_pos
|
||||||
|
place_v = v_pos
|
||||||
|
else:
|
||||||
|
angle = (2.0 * math.pi * i) / num_pyramids
|
||||||
|
# Offset in UV space based on ring radius
|
||||||
|
offset_u = (ring_idx * spacing / (u_max - u_min)) * math.cos(angle)
|
||||||
|
offset_v = (ring_idx * spacing / (v_max - v_min)) * math.sin(angle)
|
||||||
|
place_u = max(u_min, min(u_max, u_pos + offset_u))
|
||||||
|
place_v = max(v_min, min(v_max, v_pos + offset_v))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get 3D position and normal for this pyramid
|
||||||
|
pyramid_pt = gp_Pnt()
|
||||||
|
pd1u = gp_Vec()
|
||||||
|
pd1v = gp_Vec()
|
||||||
|
surf.D1(place_u, place_v, pyramid_pt, pd1u, pd1v)
|
||||||
|
pyramid_normal = pd1u.Crossed(pd1v)
|
||||||
|
pyramid_normal.Normalize()
|
||||||
|
|
||||||
|
# Create solid pyramid at this position
|
||||||
|
pyramid_shape = self._create_solid_pyramid(
|
||||||
|
pyramid_pt,
|
||||||
|
pyramid_normal,
|
||||||
|
pyramid_height,
|
||||||
|
base_radius,
|
||||||
|
)
|
||||||
|
|
||||||
|
if pyramid_shape is not None:
|
||||||
|
# Fuse with existing geometry
|
||||||
|
fuse = BRepAlgoAPI_Fuse(result_shape, pyramid_shape)
|
||||||
|
fuse.Build()
|
||||||
|
|
||||||
|
if fuse.IsDone():
|
||||||
|
result_shape = fuse.Shape()
|
||||||
|
pyramid_count += 1
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to fuse pyramid at ({place_u:.2f}, {place_v:.2f})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
f"Error creating pyramid at ring {ring_idx}, pyramid {i}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._patterns_applied.append(
|
||||||
|
{
|
||||||
|
"type": "pyramid",
|
||||||
|
"parameters": {
|
||||||
|
"height": pyramid_height,
|
||||||
|
"base_radius": base_radius,
|
||||||
|
"spacing": spacing,
|
||||||
|
"num_rings": num_rings,
|
||||||
|
"direction": direction,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Successfully applied {pyramid_count} pyramids")
|
||||||
|
|
||||||
|
return result_shape
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error applying pyramid pattern: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _create_solid_pyramid(
|
||||||
|
self,
|
||||||
|
base_point, # gp_Pnt - position on the face
|
||||||
|
normal_vec, # gp_Dir or gp_Vec - surface normal direction
|
||||||
|
height: float,
|
||||||
|
base_radius: float,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""Create a solid pyramid at the specified position and orientation.
|
||||||
|
|
||||||
|
Uses BRepPrimAPI_MakePrism to extrude a square base into a solid pyramid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_point: 3D point where pyramid base is centered
|
||||||
|
normal_vec: Direction vector for pyramid growth (surface normal)
|
||||||
|
height: Height of the pyramid from base to apex
|
||||||
|
base_radius: Half-width of the square base
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OCC solid shape for the pyramid, or None on failure
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from OCP.gp import gp_Dir, gp_Ax2, gp_Vec
|
||||||
|
from OCP.BRepBuilderAPI import (
|
||||||
|
BRepBuilderAPI_MakeEdge,
|
||||||
|
BRepBuilderAPI_MakeWire,
|
||||||
|
)
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||||
|
|
||||||
|
half = base_radius / 2.0
|
||||||
|
|
||||||
|
# Build orthonormal basis from normal vector
|
||||||
|
if isinstance(normal_vec, gp_Vec):
|
||||||
|
n_dir = gp_Dir(normal_vec.XYZ())
|
||||||
|
else:
|
||||||
|
n_dir = normal_vec
|
||||||
|
|
||||||
|
# Create a local coordinate system at the base point
|
||||||
|
local_ax2 = gp_Ax2(base_point, n_dir)
|
||||||
|
|
||||||
|
# Get X and Y axes from the local coordinate system
|
||||||
|
x_dir = local_ax2.XDirection()
|
||||||
|
y_dir = local_ax2.YDirection()
|
||||||
|
|
||||||
|
# Create 4 corners of the square base in the local plane
|
||||||
|
corner_points = [
|
||||||
|
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(half),
|
||||||
|
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(half),
|
||||||
|
base_point + gp_Vec(x_dir).Multiplied(-half) + gp_Vec(y_dir).Multiplied(-half),
|
||||||
|
base_point + gp_Vec(x_dir).Multiplied(half) + gp_Vec(y_dir).Multiplied(-half),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create edges connecting the corners
|
||||||
|
wire_maker = BRepBuilderAPI_MakeWire()
|
||||||
|
for idx in range(4):
|
||||||
|
next_idx = (idx + 1) % 4
|
||||||
|
edge = BRepBuilderAPI_MakeEdge(
|
||||||
|
corner_points[idx], corner_points[next_idx]
|
||||||
|
).Edge()
|
||||||
|
wire_maker.Add(edge)
|
||||||
|
|
||||||
|
if not wire_maker.IsDone():
|
||||||
|
logger.warning("Failed to create pyramid base wire")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extrude the base wire in the normal direction by height to form a prism
|
||||||
|
extrusion_vec = gp_Vec(n_dir).Multiplied(height)
|
||||||
|
prism_maker = BRepPrimAPI_MakePrism(
|
||||||
|
wire_maker.Wire(), extrusion_vec, False # no check intersection
|
||||||
|
)
|
||||||
|
prism_maker.Build()
|
||||||
|
|
||||||
|
if not prism_maker.IsDone():
|
||||||
|
logger.warning("Failed to create pyramid prism")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return prism_maker.Shape()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error creating solid pyramid: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def apply_bump_pattern(
|
||||||
|
self,
|
||||||
|
face_shape,
|
||||||
|
bump_height: float = 1.0,
|
||||||
|
bump_radius: float = 2.0,
|
||||||
|
spacing: float = 5.0,
|
||||||
|
num_rings: Optional[int] = None,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""Apply a simple bump pattern to a face surface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
face_shape: OCC TopoDS_Shape representing the face
|
||||||
|
bump_height: Height of each bump
|
||||||
|
bump_radius: Radius of each bump base
|
||||||
|
spacing: Distance between bumps
|
||||||
|
num_rings: Number of concentric rings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified shape on success, None on failure
|
||||||
|
"""
|
||||||
|
return self.apply_pyramid_pattern(
|
||||||
|
face_shape,
|
||||||
|
pyramid_height=bump_height,
|
||||||
|
base_radius=bump_radius,
|
||||||
|
spacing=spacing,
|
||||||
|
num_rings=num_rings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_surface_modifier_to_body(
|
||||||
|
body_geometry, modifier_type: str = "pyramid", **parameters
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""Apply a surface modifier to a body geometry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body_geometry: OCCGeometryObject or similar geometry object
|
||||||
|
modifier_type: Type of modifier ('pyramid', 'bump')
|
||||||
|
**parameters: Modifier-specific parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified shape, or None on failure
|
||||||
|
"""
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
shape = kernel._get_shape(body_geometry)
|
||||||
|
|
||||||
|
if shape is None:
|
||||||
|
logger.error("No geometry found in body")
|
||||||
|
return None
|
||||||
|
|
||||||
|
modifier = SurfaceModifier()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if modifier_type == "pyramid":
|
||||||
|
success = modifier.apply_pyramid_pattern(shape, **parameters)
|
||||||
|
elif modifier_type == "bump":
|
||||||
|
success = modifier.apply_bump_pattern(shape, **parameters)
|
||||||
|
else:
|
||||||
|
logger.error(f"Unknown modifier type: {modifier_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.error("Surface modifier application failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return the modified shape wrapped in OCCGeometryObject
|
||||||
|
from fluency.geometry_occ.kernel import OCCGeometryObject
|
||||||
|
|
||||||
|
return OCCGeometryObject(shape)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error applying surface modifier: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Example usage and testing
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Create a simple test case
|
||||||
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
|
# Create a box to modify
|
||||||
|
box_maker = BRepPrimAPI_MakeBox(50, 50, 10)
|
||||||
|
box_maker.Build()
|
||||||
|
|
||||||
|
if box_maker.IsDone():
|
||||||
|
print("Created test box")
|
||||||
|
|
||||||
|
# Apply pyramid pattern to top face (Z direction)
|
||||||
|
modifier = SurfaceModifier()
|
||||||
|
success = modifier.apply_pyramid_pattern(
|
||||||
|
box_maker.Shape(),
|
||||||
|
pyramid_height=2.0,
|
||||||
|
base_radius=3.0,
|
||||||
|
spacing=8.0,
|
||||||
|
num_rings=2,
|
||||||
|
direction=(0, 0, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("Successfully applied pyramid pattern")
|
||||||
|
|
||||||
|
# Export modified shape
|
||||||
|
from OCP.StlAPI import StlAPI_Writer
|
||||||
|
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||||
|
|
||||||
|
tess = BRepMesh_IncrementalMesh(box_maker.Shape(), 0.1)
|
||||||
|
tess.Perform()
|
||||||
|
|
||||||
|
writer = StlAPI_Writer()
|
||||||
|
writer.SetASCIIMode(False)
|
||||||
|
writer.Write(box_maker.Shape(), "/tmp/test_pyramid_pattern.stl")
|
||||||
|
print("Exported modified shape to STL")
|
||||||
|
else:
|
||||||
|
print("Failed to apply pyramid pattern")
|
||||||
|
else:
|
||||||
|
print("Failed to create test box")
|
||||||
@@ -177,6 +177,7 @@ 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,
|
||||||
"position": _coerce_listlike(body.position),
|
"position": _coerce_listlike(body.position),
|
||||||
@@ -212,6 +213,7 @@ 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")),
|
||||||
@@ -454,7 +456,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
|
||||||
|
|
||||||
@@ -691,8 +693,13 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
|
|||||||
sk_data["occ_sketch"] = meta.get("occ_sketch")
|
sk_data["occ_sketch"] = meta.get("occ_sketch")
|
||||||
# Workplane fields on the sketch-level file override the
|
# Workplane fields on the sketch-level file override the
|
||||||
# embedded ones (source of truth lives in the sidecar).
|
# embedded ones (source of truth lives in the sidecar).
|
||||||
for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
|
for k in (
|
||||||
"is_solved", "is_fully_constrained"):
|
"workplane_origin",
|
||||||
|
"workplane_normal",
|
||||||
|
"workplane_x_dir",
|
||||||
|
"is_solved",
|
||||||
|
"is_fully_constrained",
|
||||||
|
):
|
||||||
if k in meta:
|
if k in meta:
|
||||||
sk_data[k] = meta[k]
|
sk_data[k] = meta[k]
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ class Body:
|
|||||||
extrude_cut: bool = False
|
extrude_cut: bool = False
|
||||||
extrude_union: bool = False
|
extrude_union: bool = False
|
||||||
extrude_through_all: bool = False
|
extrude_through_all: bool = False
|
||||||
|
extrude_cut_all_bodies: bool = False # cut through all bodies in component
|
||||||
extrude_face_index: Optional[int] = None # which sketch face was selected
|
extrude_face_index: Optional[int] = None # which sketch face was selected
|
||||||
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
|
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
|
||||||
needs_update: bool = False # True when source sketch changed since last extrude
|
needs_update: bool = False # True when source sketch changed since last extrude
|
||||||
|
|||||||
+33
-21
@@ -4,18 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QPoint, QPointF
|
|
||||||
from PySide6.QtGui import QColor, QFont, QKeySequence
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QButtonGroup,
|
QButtonGroup,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
QFormLayout,
|
|
||||||
QFrame,
|
QFrame,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -24,11 +19,11 @@ from PySide6.QtWidgets import (
|
|||||||
QPushButton,
|
QPushButton,
|
||||||
QRadioButton,
|
QRadioButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ExtrudeDialog(QDialog):
|
class ExtrudeDialog(QDialog):
|
||||||
"""Dialog for extrude options.
|
"""Dialog for extrude options.
|
||||||
|
|
||||||
@@ -76,6 +71,13 @@ class ExtrudeDialog(QDialog):
|
|||||||
)
|
)
|
||||||
layout.addWidget(self.through_all_checkbox)
|
layout.addWidget(self.through_all_checkbox)
|
||||||
|
|
||||||
|
self.cut_all_bodies_checkbox = QCheckBox("Cut All Bodies")
|
||||||
|
self.cut_all_bodies_checkbox.setToolTip(
|
||||||
|
"Apply the boolean cut to every body in the current component, "
|
||||||
|
"not just the one the sketch was drawn on. Requires Perform Cut."
|
||||||
|
)
|
||||||
|
layout.addWidget(self.cut_all_bodies_checkbox)
|
||||||
|
|
||||||
self.rounded_checkbox = QCheckBox("Round Edges")
|
self.rounded_checkbox = QCheckBox("Round Edges")
|
||||||
layout.addWidget(self.rounded_checkbox)
|
layout.addWidget(self.rounded_checkbox)
|
||||||
|
|
||||||
@@ -103,6 +105,7 @@ class ExtrudeDialog(QDialog):
|
|||||||
self.cut_checkbox,
|
self.cut_checkbox,
|
||||||
self.union_checkbox,
|
self.union_checkbox,
|
||||||
self.through_all_checkbox,
|
self.through_all_checkbox,
|
||||||
|
self.cut_all_bodies_checkbox,
|
||||||
self.rounded_checkbox,
|
self.rounded_checkbox,
|
||||||
):
|
):
|
||||||
# The spinbox has valueChanged; the checkboxes have stateChanged.
|
# The spinbox has valueChanged; the checkboxes have stateChanged.
|
||||||
@@ -144,7 +147,7 @@ class ExtrudeDialog(QDialog):
|
|||||||
pass
|
pass
|
||||||
super().hideEvent(event)
|
super().hideEvent(event)
|
||||||
|
|
||||||
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
|
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool, bool]:
|
||||||
return (
|
return (
|
||||||
self.length_input.value(),
|
self.length_input.value(),
|
||||||
self.symmetric_checkbox.isChecked(),
|
self.symmetric_checkbox.isChecked(),
|
||||||
@@ -152,6 +155,7 @@ class ExtrudeDialog(QDialog):
|
|||||||
self.cut_checkbox.isChecked(),
|
self.cut_checkbox.isChecked(),
|
||||||
self.union_checkbox.isChecked(),
|
self.union_checkbox.isChecked(),
|
||||||
self.through_all_checkbox.isChecked(),
|
self.through_all_checkbox.isChecked(),
|
||||||
|
self.cut_all_bodies_checkbox.isChecked(),
|
||||||
self.rounded_checkbox.isChecked(),
|
self.rounded_checkbox.isChecked(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -404,7 +408,6 @@ class WorkplaneOrientationDialog(QDialog):
|
|||||||
def _on_ok(self):
|
def _on_ok(self):
|
||||||
"""Compute the final orientation and accept."""
|
"""Compute the final orientation and accept."""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import math
|
|
||||||
|
|
||||||
if self._custom_radio.isChecked():
|
if self._custom_radio.isChecked():
|
||||||
# Custom: start from XY normal and rotate by the two angles.
|
# Custom: start from XY normal and rotate by the two angles.
|
||||||
@@ -413,18 +416,22 @@ class WorkplaneOrientationDialog(QDialog):
|
|||||||
# Start from +Z normal, rotate around X then Y
|
# Start from +Z normal, rotate around X then Y
|
||||||
n = np.array([0.0, 0.0, 1.0])
|
n = np.array([0.0, 0.0, 1.0])
|
||||||
# Rotate around X
|
# Rotate around X
|
||||||
rx = np.array([
|
rx = np.array(
|
||||||
|
[
|
||||||
[1, 0, 0],
|
[1, 0, 0],
|
||||||
[0, math.cos(ax), -math.sin(ax)],
|
[0, math.cos(ax), -math.sin(ax)],
|
||||||
[0, math.sin(ax), math.cos(ax)],
|
[0, math.sin(ax), math.cos(ax)],
|
||||||
])
|
]
|
||||||
|
)
|
||||||
n = rx @ n
|
n = rx @ n
|
||||||
# Rotate around Y
|
# Rotate around Y
|
||||||
ry = np.array([
|
ry = np.array(
|
||||||
|
[
|
||||||
[math.cos(ay), 0, math.sin(ay)],
|
[math.cos(ay), 0, math.sin(ay)],
|
||||||
[0, 1, 0],
|
[0, 1, 0],
|
||||||
[-math.sin(ay), 0, math.cos(ay)],
|
[-math.sin(ay), 0, math.cos(ay)],
|
||||||
])
|
]
|
||||||
|
)
|
||||||
n = ry @ n
|
n = ry @ n
|
||||||
n = n / np.linalg.norm(n)
|
n = n / np.linalg.norm(n)
|
||||||
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
|
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
|
||||||
@@ -454,23 +461,26 @@ class WorkplaneOrientationDialog(QDialog):
|
|||||||
whether called before or after ``_on_ok``.
|
whether called before or after ``_on_ok``.
|
||||||
"""
|
"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import math
|
|
||||||
|
|
||||||
if self._custom_radio.isChecked():
|
if self._custom_radio.isChecked():
|
||||||
ax = math.radians(self._angle_x.value())
|
ax = math.radians(self._angle_x.value())
|
||||||
ay = math.radians(self._angle_y.value())
|
ay = math.radians(self._angle_y.value())
|
||||||
n = np.array([0.0, 0.0, 1.0])
|
n = np.array([0.0, 0.0, 1.0])
|
||||||
rx = np.array([
|
rx = np.array(
|
||||||
|
[
|
||||||
[1, 0, 0],
|
[1, 0, 0],
|
||||||
[0, math.cos(ax), -math.sin(ax)],
|
[0, math.cos(ax), -math.sin(ax)],
|
||||||
[0, math.sin(ax), math.cos(ax)],
|
[0, math.sin(ax), math.cos(ax)],
|
||||||
])
|
]
|
||||||
|
)
|
||||||
n = rx @ n
|
n = rx @ n
|
||||||
ry = np.array([
|
ry = np.array(
|
||||||
|
[
|
||||||
[math.cos(ay), 0, math.sin(ay)],
|
[math.cos(ay), 0, math.sin(ay)],
|
||||||
[0, 1, 0],
|
[0, 1, 0],
|
||||||
[-math.sin(ay), 0, math.cos(ay)],
|
[-math.sin(ay), 0, math.cos(ay)],
|
||||||
])
|
]
|
||||||
|
)
|
||||||
n = ry @ n
|
n = ry @ n
|
||||||
n = n / np.linalg.norm(n)
|
n = n / np.linalg.norm(n)
|
||||||
world_y = np.array([0.0, 1.0, 0.0])
|
world_y = np.array([0.0, 1.0, 0.0])
|
||||||
@@ -492,6 +502,8 @@ class WorkplaneOrientationDialog(QDialog):
|
|||||||
if btn is not None:
|
if btn is not None:
|
||||||
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
|
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
|
||||||
# Fallback: XY default.
|
# Fallback: XY default.
|
||||||
return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
|
return (
|
||||||
|
(0.0, 0.0, 1.0),
|
||||||
|
(1.0, 0.0, 0.0),
|
||||||
|
self._name_input.text().strip() or "Workplane",
|
||||||
|
)
|
||||||
|
|||||||
+404
-50
@@ -183,6 +183,157 @@ def _make_body_thumbnail(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_component_thumbnail(
|
||||||
|
component,
|
||||||
|
kernel,
|
||||||
|
size: QSize = QSize(96, 96),
|
||||||
|
):
|
||||||
|
"""Render a small isometric thumbnail of all bodies in a component.
|
||||||
|
|
||||||
|
Combines the meshes of all visible bodies and renders them together.
|
||||||
|
Returns a QPixmap or None on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
from PySide6.QtGui import QImage, QPixmap
|
||||||
|
|
||||||
|
# Collect meshes from all visible bodies with geometry
|
||||||
|
all_verts = []
|
||||||
|
all_faces = []
|
||||||
|
vertex_offset = 0
|
||||||
|
|
||||||
|
for body in component.bodies.values():
|
||||||
|
if not body.visible or not body.geometry:
|
||||||
|
continue
|
||||||
|
verts, faces = body.get_mesh(kernel)
|
||||||
|
if verts is None or len(verts) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
verts = np.asarray(verts, dtype=np.float64)
|
||||||
|
faces = np.asarray(faces, dtype=np.int32)
|
||||||
|
|
||||||
|
# Apply body transform
|
||||||
|
if hasattr(body, "position") and body.position is not None:
|
||||||
|
if hasattr(body, "rotation") and body.rotation is not None:
|
||||||
|
verts = verts @ body.rotation.T
|
||||||
|
verts = verts + body.position
|
||||||
|
|
||||||
|
all_verts.append(verts)
|
||||||
|
all_faces.append(faces + vertex_offset)
|
||||||
|
vertex_offset += len(verts)
|
||||||
|
|
||||||
|
if not all_verts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
verts = np.concatenate(all_verts, axis=0)
|
||||||
|
faces = np.concatenate(all_faces, axis=0)
|
||||||
|
|
||||||
|
mins = verts.min(axis=0)
|
||||||
|
maxs = verts.max(axis=0)
|
||||||
|
center = (mins + maxs) / 2
|
||||||
|
extent = maxs - mins
|
||||||
|
max_dim = max(extent)
|
||||||
|
if max_dim < 1e-10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Normalize vertices to [-1, 1] range centered at origin
|
||||||
|
v = (verts - center) / (max_dim * 0.7)
|
||||||
|
|
||||||
|
# Simple isometric projection (rotation + orthographic)
|
||||||
|
angle_y = np.radians(-45)
|
||||||
|
angle_x = np.radians(25)
|
||||||
|
cos_y, sin_y = np.cos(angle_y), np.sin(angle_y)
|
||||||
|
cos_x, sin_x = np.cos(angle_x), np.sin(angle_x)
|
||||||
|
|
||||||
|
# Rotate Y
|
||||||
|
x1 = v[:, 0] * cos_y - v[:, 2] * sin_y
|
||||||
|
z1 = v[:, 0] * sin_y + v[:, 2] * cos_y
|
||||||
|
y1 = v[:, 1]
|
||||||
|
|
||||||
|
# Rotate X
|
||||||
|
y2 = y1 * cos_x - z1 * sin_x
|
||||||
|
z2 = y1 * sin_x + z1 * cos_x
|
||||||
|
x2 = x1
|
||||||
|
|
||||||
|
# Project to 2D (orthographic)
|
||||||
|
w_px, h_px = size.width(), size.height()
|
||||||
|
|
||||||
|
# Compute 2D bounding box of projected vertices for tight framing
|
||||||
|
all_px = x2
|
||||||
|
all_py = -y2
|
||||||
|
px_min, px_max = all_px.min(), all_px.max()
|
||||||
|
py_min, py_max = all_py.min(), all_py.max()
|
||||||
|
span_x = px_max - px_min
|
||||||
|
span_y = py_max - py_min
|
||||||
|
if span_x < 1e-10 or span_y < 1e-10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Scale to fill ~90% of the image
|
||||||
|
margin = 0.10
|
||||||
|
avail_w = w_px * (1.0 - margin)
|
||||||
|
avail_h = h_px * (1.0 - margin)
|
||||||
|
scale = min(avail_w / span_x, avail_h / span_y)
|
||||||
|
|
||||||
|
center_x = (px_min + px_max) / 2
|
||||||
|
center_y = (py_min + py_max) / 2
|
||||||
|
px = (all_px * scale + w_px / 2 - center_x * scale).astype(np.float64)
|
||||||
|
py = (all_py * scale + h_px / 2 - center_y * scale).astype(np.float64)
|
||||||
|
depth = z2 # for painter's algorithm
|
||||||
|
|
||||||
|
# Dark grey background
|
||||||
|
img = Image.new("RGBA", (w_px, h_px), (55, 55, 60, 255))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Compute face normals for backface culling & shading
|
||||||
|
v0 = np.stack([px[faces[:, 0]], py[faces[:, 0]], depth[faces[:, 0]]], axis=1)
|
||||||
|
v1 = np.stack([px[faces[:, 1]], py[faces[:, 1]], depth[faces[:, 1]]], axis=1)
|
||||||
|
v2 = np.stack([px[faces[:, 2]], py[faces[:, 2]], depth[faces[:, 2]]], axis=1)
|
||||||
|
|
||||||
|
# 2D cross product for winding
|
||||||
|
cross = (v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) - (v1[:, 1] - v0[:, 1]) * (
|
||||||
|
v2[:, 0] - v0[:, 0]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Average depth per face
|
||||||
|
avg_depth = (v0[:, 2] + v1[:, 2] + v2[:, 2]) / 3.0
|
||||||
|
|
||||||
|
# Sort faces by depth (painter's algorithm: draw far faces first)
|
||||||
|
order = np.argsort(-avg_depth)
|
||||||
|
|
||||||
|
# Ceramic white body with shading
|
||||||
|
base_r, base_g, base_b = 220, 218, 215
|
||||||
|
|
||||||
|
for i in order:
|
||||||
|
# Backface culling
|
||||||
|
if cross[i] <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pts = [
|
||||||
|
(float(px[faces[i, 0]]), float(py[faces[i, 0]])),
|
||||||
|
(float(px[faces[i, 1]]), float(py[faces[i, 1]])),
|
||||||
|
(float(px[faces[i, 2]]), float(py[faces[i, 2]])),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Shading: stronger contrast for depth perception
|
||||||
|
brightness = 0.5 + 0.5 * max(0.0, min(1.0, (avg_depth[i] + 1) / 2))
|
||||||
|
r = int(base_r * brightness)
|
||||||
|
g = int(base_g * brightness)
|
||||||
|
b = int(base_b * brightness)
|
||||||
|
|
||||||
|
draw.polygon(pts, fill=(r, g, b, 255))
|
||||||
|
|
||||||
|
# Convert PIL image to QPixmap
|
||||||
|
data = img.tobytes("raw", "RGBA")
|
||||||
|
qimg = QImage(data, w_px, h_px, w_px * 4, QImage.Format_RGBA8888)
|
||||||
|
pixmap = QPixmap.fromImage(qimg.copy())
|
||||||
|
return pixmap
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Component thumbnail generation failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── Button sizing & styling constants ────────────────────────────────
|
# ── Button sizing & styling constants ────────────────────────────────
|
||||||
_BTN_MIN = 40 # minimum button dimension (px)
|
_BTN_MIN = 40 # minimum button dimension (px)
|
||||||
_BTN_MAX = 160 # maximum button dimension (px)
|
_BTN_MAX = 160 # maximum button dimension (px)
|
||||||
@@ -246,7 +397,7 @@ def _scroll_to_button(btn: QPushButton, scroll_area: QScrollArea) -> None:
|
|||||||
def _create_component_button(
|
def _create_component_button(
|
||||||
num: int,
|
num: int,
|
||||||
name: str,
|
name: str,
|
||||||
body,
|
component,
|
||||||
kernel,
|
kernel,
|
||||||
group: QButtonGroup,
|
group: QButtonGroup,
|
||||||
layout: QHBoxLayout,
|
layout: QHBoxLayout,
|
||||||
@@ -261,7 +412,10 @@ def _create_component_button(
|
|||||||
btn.clicked.connect(click_handler)
|
btn.clicked.connect(click_handler)
|
||||||
_set_button_style(btn)
|
_set_button_style(btn)
|
||||||
|
|
||||||
pixmap = _make_body_thumbnail(body, kernel, QSize(96, 96))
|
# Render thumbnail from all bodies in the component
|
||||||
|
has_geometry = any(b.visible and b.geometry for b in component.bodies.values())
|
||||||
|
if has_geometry:
|
||||||
|
pixmap = _make_component_thumbnail(component, kernel, QSize(96, 96))
|
||||||
if pixmap is not None:
|
if pixmap is not None:
|
||||||
btn.setIcon(pixmap)
|
btn.setIcon(pixmap)
|
||||||
btn.setIconSize(QSize(96, 96))
|
btn.setIconSize(QSize(96, 96))
|
||||||
@@ -727,7 +881,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_del_sketch = ui.pb_del_sketch
|
self._btn_del_sketch = ui.pb_del_sketch
|
||||||
# ── Body tools ──
|
# ── Body tools ──
|
||||||
self._btn_update_body = ui.pb_update_body
|
self._btn_update_body = ui.pb_update_body
|
||||||
self._btn_edit_sketch_3 = ui.pb_edt_sktch_3
|
self._btn_body_hide = ui.pb_body_hide
|
||||||
self._btn_del_body = ui.pb_del_body
|
self._btn_del_body = ui.pb_del_body
|
||||||
# ── Component tools ──
|
# ── Component tools ──
|
||||||
self._btn_new_compo = ui.pb_new_compo
|
self._btn_new_compo = ui.pb_new_compo
|
||||||
@@ -891,7 +1045,10 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_move.clicked.connect(self._translate_body)
|
self._btn_move.clicked.connect(self._translate_body)
|
||||||
self._btn_array.clicked.connect(self._pattern_array)
|
self._btn_array.clicked.connect(self._pattern_array)
|
||||||
self._btn_offset.clicked.connect(self._offset_sketch)
|
self._btn_offset.clicked.connect(self._offset_sketch)
|
||||||
self._btn_edit_sketch_3.clicked.connect(self._edit_sketch)
|
# Per-body hide/show toggle: the user clicks pb_body_hide next
|
||||||
|
# to a body name in the right-hand list. We update the body's
|
||||||
|
# ``visible`` flag and ask the viewer to show/hide the mesh.
|
||||||
|
self._btn_body_hide.clicked.connect(self._on_body_hide_button_clicked)
|
||||||
|
|
||||||
# Snap toggle
|
# Snap toggle
|
||||||
self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c))
|
self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c))
|
||||||
@@ -998,11 +1155,12 @@ class MainWindow(QMainWindow):
|
|||||||
if component_index >= len(comp_ids):
|
if component_index >= len(comp_ids):
|
||||||
return
|
return
|
||||||
comp = self._project.components[comp_ids[component_index]]
|
comp = self._project.components[comp_ids[component_index]]
|
||||||
first_body = next(iter(comp.bodies.values()), None)
|
# Check if component has any bodies with geometry
|
||||||
if not first_body or not first_body.geometry:
|
has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
|
||||||
|
if not has_geometry:
|
||||||
return
|
return
|
||||||
btn = self._component_buttons[component_index]
|
btn = self._component_buttons[component_index]
|
||||||
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
|
pixmap = _make_component_thumbnail(comp, self._kernel, QSize(96, 96))
|
||||||
if pixmap is not None:
|
if pixmap is not None:
|
||||||
btn.setIcon(pixmap)
|
btn.setIcon(pixmap)
|
||||||
btn.setIconSize(QSize(96, 96))
|
btn.setIconSize(QSize(96, 96))
|
||||||
@@ -1076,18 +1234,13 @@ class MainWindow(QMainWindow):
|
|||||||
self._sketch_list.addItem(sketch.name)
|
self._sketch_list.addItem(sketch.name)
|
||||||
|
|
||||||
for body_id, body in self._current_component.bodies.items():
|
for body_id, body in self._current_component.bodies.items():
|
||||||
# QListWidgetItem with a checkbox so the user can toggle
|
# QListWidgetItem with a data role so the toggle handler can
|
||||||
# each body's visibility in the 3D viewer. The item's
|
|
||||||
# data role stores the body id so the toggle handler can
|
|
||||||
# look up the right body without relying on display text.
|
# look up the right body without relying on display text.
|
||||||
display_name = body.name
|
display_name = body.name
|
||||||
if body.needs_update:
|
if body.needs_update:
|
||||||
display_name = f"⚠ {body.name}"
|
display_name = f"⚠ {body.name}"
|
||||||
item = QListWidgetItem(display_name)
|
item = QListWidgetItem(display_name)
|
||||||
item.setData(Qt.UserRole, body_id)
|
item.setData(Qt.UserRole, body_id)
|
||||||
# Qt.Checked = visible, Qt.Unchecked = hidden. Default
|
|
||||||
# is whatever the body model says.
|
|
||||||
item.setCheckState(Qt.Checked if body.visible else Qt.Unchecked)
|
|
||||||
# Greying out a hidden body's name is a nice UX touch.
|
# Greying out a hidden body's name is a nice UX touch.
|
||||||
if not body.visible:
|
if not body.visible:
|
||||||
item.setForeground(QColor("#6c7086"))
|
item.setForeground(QColor("#6c7086"))
|
||||||
@@ -1103,6 +1256,8 @@ class MainWindow(QMainWindow):
|
|||||||
changes to all assembly instances, and recalculates connectors.
|
changes to all assembly instances, and recalculates connectors.
|
||||||
"""
|
"""
|
||||||
self._update_bodies_from_sketch()
|
self._update_bodies_from_sketch()
|
||||||
|
self._update_sketches_from_bodies()
|
||||||
|
self._update_bodies_from_sketch() # re-extrude bodies whose sketches just moved
|
||||||
self._redraw_bodies()
|
self._redraw_bodies()
|
||||||
self._propagate_to_assembly()
|
self._propagate_to_assembly()
|
||||||
self._recalculate_connectors()
|
self._recalculate_connectors()
|
||||||
@@ -1172,6 +1327,49 @@ class MainWindow(QMainWindow):
|
|||||||
target = b
|
target = b
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Handle cut_all_bodies: apply the cut to every body in the
|
||||||
|
# component, not just the target.
|
||||||
|
if body.extrude_cut and body.extrude_cut_all_bodies:
|
||||||
|
try:
|
||||||
|
if body.extrude_through_all and target is not None:
|
||||||
|
cut_length = self._through_all_length(target, sketch)
|
||||||
|
cut_symmetric = True
|
||||||
|
cut_invert = False
|
||||||
|
else:
|
||||||
|
cut_length = body.extrude_length or 10.0
|
||||||
|
cut_symmetric = body.extrude_symmetric
|
||||||
|
cut_invert = body.extrude_invert
|
||||||
|
tool_geom = self._kernel.extrude(
|
||||||
|
face_geom,
|
||||||
|
-cut_length if cut_invert else cut_length,
|
||||||
|
symmetric=cut_symmetric,
|
||||||
|
)
|
||||||
|
if tool_geom is None:
|
||||||
|
logger.warning(f"Body '{body.name}': cut-all tool geometry is empty")
|
||||||
|
continue
|
||||||
|
cut_count = 0
|
||||||
|
for other_id, other in list(self._current_component.bodies.items()):
|
||||||
|
if other.geometry is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
other.geometry = self._kernel.boolean_difference(
|
||||||
|
other.geometry, tool_geom
|
||||||
|
)
|
||||||
|
other.needs_update = False
|
||||||
|
other.modified_at = datetime.now()
|
||||||
|
cut_count += 1
|
||||||
|
except Exception:
|
||||||
|
pass # body doesn't intersect tool, skip
|
||||||
|
body.needs_update = False
|
||||||
|
body.modified_at = datetime.now()
|
||||||
|
updated += 1
|
||||||
|
logger.info(
|
||||||
|
f"Re-extruded cut-all body '{body.name}': cut {cut_count} body(ies)"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(f"Body '{body.name}': re-extrude cut-all failed: {exc}")
|
||||||
|
continue # skip the single-target path below
|
||||||
|
|
||||||
# Compute the new result.
|
# Compute the new result.
|
||||||
try:
|
try:
|
||||||
if body.extrude_through_all and target is not None:
|
if body.extrude_through_all and target is not None:
|
||||||
@@ -1215,6 +1413,87 @@ class MainWindow(QMainWindow):
|
|||||||
if updated > 0:
|
if updated > 0:
|
||||||
logger.info(f"Updated {updated} body(ies) from sketch")
|
logger.info(f"Updated {updated} body(ies) from sketch")
|
||||||
|
|
||||||
|
def _update_sketches_from_bodies(self) -> None:
|
||||||
|
"""Re-project underlay construction lines from updated 3D bodies.
|
||||||
|
|
||||||
|
For every sketch in the current component that carries a
|
||||||
|
``_source_face`` (a face-projected underlay) and a
|
||||||
|
``_source_body_id``, find the corresponding face on the updated
|
||||||
|
body geometry, re-project its edges to UV, and update the sketch's
|
||||||
|
external entities *in place* (preserving entity ids so existing
|
||||||
|
constraints survive). The solver is re-run so any user geometry
|
||||||
|
anchored to the underlay follows the body.
|
||||||
|
"""
|
||||||
|
if not self._current_component:
|
||||||
|
return
|
||||||
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
|
||||||
|
kernel = OCGeometryKernel()
|
||||||
|
updated = 0
|
||||||
|
for sketch in self._current_component.sketches.values():
|
||||||
|
src_body_id = getattr(sketch, "_source_body_id", None)
|
||||||
|
src_face = getattr(sketch, "_source_face", None)
|
||||||
|
if src_body_id is None or src_face is None:
|
||||||
|
continue
|
||||||
|
if sketch.occ_sketch is None:
|
||||||
|
continue
|
||||||
|
body = self._current_component.bodies.get(src_body_id)
|
||||||
|
if body is None or body.geometry is None:
|
||||||
|
continue
|
||||||
|
body_shape = kernel._get_shape(body.geometry)
|
||||||
|
if body_shape is None:
|
||||||
|
continue
|
||||||
|
# Find the face on the updated body that matches the original
|
||||||
|
# face's plane (normal parallel, origin coplanar).
|
||||||
|
wp = sketch.occ_sketch.get_workplane()
|
||||||
|
origin, normal = wp[0], wp[1]
|
||||||
|
ref_center = getattr(sketch, "_source_face_center", None)
|
||||||
|
match = OCGeometryKernel.find_coplanar_face(
|
||||||
|
body_shape,
|
||||||
|
origin,
|
||||||
|
normal,
|
||||||
|
ref_center=ref_center,
|
||||||
|
)
|
||||||
|
if match is None:
|
||||||
|
logger.debug(
|
||||||
|
"Sketch '%s': no matching face on body '%s', skipping",
|
||||||
|
sketch.name,
|
||||||
|
body.name,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
new_face, new_center = match
|
||||||
|
sketch._source_face = new_face
|
||||||
|
sketch._source_face_center = new_center
|
||||||
|
# Re-project the new face's edges into UV.
|
||||||
|
from fluency.ui.sketch_widget import _project_face_to_uv
|
||||||
|
|
||||||
|
try:
|
||||||
|
polys = _project_face_to_uv(new_face, wp)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("re-projection failed for sketch '%s': %s", sketch.name, exc)
|
||||||
|
continue
|
||||||
|
if not polys:
|
||||||
|
continue
|
||||||
|
# Update external entities in-place (preserves ids + constraints).
|
||||||
|
ok = sketch.occ_sketch.update_external_entities(polys)
|
||||||
|
if ok:
|
||||||
|
updated += 1
|
||||||
|
logger.info(
|
||||||
|
"Re-projected underlay for sketch '%s' from body '%s'",
|
||||||
|
sketch.name,
|
||||||
|
body.name,
|
||||||
|
)
|
||||||
|
# If the sketch is currently loaded in the widget, refresh
|
||||||
|
# the underlay data so the view reflects the new projection
|
||||||
|
# WITHOUT re-importing (which would break constraints).
|
||||||
|
if sketch.occ_sketch is self._sketch_widget._sketch:
|
||||||
|
self._sketch_widget._source_face = new_face
|
||||||
|
self._sketch_widget._source_underlay_uv = polys
|
||||||
|
self._sketch_widget._rebuild_from_sketch()
|
||||||
|
self._sketch_widget.update()
|
||||||
|
if updated > 0:
|
||||||
|
logger.info("Re-projected underlays for %d sketch(es)", updated)
|
||||||
|
|
||||||
def _propagate_to_assembly(self):
|
def _propagate_to_assembly(self):
|
||||||
"""Refresh all assembly instances that reference the current component.
|
"""Refresh all assembly instances that reference the current component.
|
||||||
|
|
||||||
@@ -1390,12 +1669,12 @@ class MainWindow(QMainWindow):
|
|||||||
btn.clicked.connect(self._on_assembly_component_clicked)
|
btn.clicked.connect(self._on_assembly_component_clicked)
|
||||||
_set_button_style(btn)
|
_set_button_style(btn)
|
||||||
|
|
||||||
# Thumbnail from the component's first body.
|
# Thumbnail from all bodies in the component.
|
||||||
src_comp = self._current_component
|
src_comp = self._current_component
|
||||||
if src_comp:
|
if src_comp:
|
||||||
first_body = next(iter(src_comp.bodies.values()), None)
|
has_geometry = any(b.visible and b.geometry for b in src_comp.bodies.values())
|
||||||
if first_body and first_body.geometry:
|
if has_geometry:
|
||||||
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
|
pixmap = _make_component_thumbnail(src_comp, self._kernel, QSize(96, 96))
|
||||||
if pixmap is not None:
|
if pixmap is not None:
|
||||||
btn.setIcon(pixmap)
|
btn.setIcon(pixmap)
|
||||||
btn.setIconSize(QSize(96, 96))
|
btn.setIconSize(QSize(96, 96))
|
||||||
@@ -3225,6 +3504,17 @@ class MainWindow(QMainWindow):
|
|||||||
sketch.set_workplane(origin, normal, x_dir)
|
sketch.set_workplane(origin, normal, x_dir)
|
||||||
# Keep the face reference for the projection underlay (Phase 3).
|
# Keep the face reference for the projection underlay (Phase 3).
|
||||||
sketch._source_face = face_shape
|
sketch._source_face = face_shape
|
||||||
|
# Store the face centroid for re-matching when the body updates.
|
||||||
|
try:
|
||||||
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
from OCP.GProp import GProp_GProps
|
||||||
|
|
||||||
|
props = GProp_GProps()
|
||||||
|
BRepGProp.VolumeProperties_s(face_shape, props)
|
||||||
|
c = props.CentreOfMass()
|
||||||
|
sketch._source_face_center = (float(c.X()), float(c.Y()), float(c.Z()))
|
||||||
|
except Exception:
|
||||||
|
sketch._source_face_center = tuple(float(v) for v in origin)
|
||||||
# Remember which body the sketch lives on so a later cut / combine
|
# Remember which body the sketch lives on so a later cut / combine
|
||||||
# extrude auto-targets it. ``source_body`` may be None if the
|
# extrude auto-targets it. ``source_body`` may be None if the
|
||||||
# pick landed on an untracked shape (e.g. an imported STEP that
|
# pick landed on an untracked shape (e.g. an imported STEP that
|
||||||
@@ -3452,11 +3742,9 @@ class MainWindow(QMainWindow):
|
|||||||
break
|
break
|
||||||
|
|
||||||
def _on_body_visibility_changed(self, item: QListWidgetItem) -> None:
|
def _on_body_visibility_changed(self, item: QListWidgetItem) -> None:
|
||||||
"""Toggle a body's 3D visibility when the user flips its checkbox.
|
"""Toggle a body's 3D visibility when the user clicks pb_body_hide.
|
||||||
|
|
||||||
itemChanged also fires for selection (not just check-state) changes,
|
The body is looked up via the UserRole data we set in _refresh_lists.
|
||||||
so we filter on the check state being the changed role. The body
|
|
||||||
is looked up via the UserRole data we set in _refresh_lists.
|
|
||||||
"""
|
"""
|
||||||
if self._current_component is None:
|
if self._current_component is None:
|
||||||
return
|
return
|
||||||
@@ -3466,16 +3754,11 @@ class MainWindow(QMainWindow):
|
|||||||
body = self._current_component.bodies.get(body_id)
|
body = self._current_component.bodies.get(body_id)
|
||||||
if body is None:
|
if body is None:
|
||||||
return
|
return
|
||||||
new_visible = item.checkState() == Qt.Checked
|
new_visible = not body.visible # toggle
|
||||||
if body.visible == new_visible:
|
if body.visible == new_visible:
|
||||||
return # no change
|
return # no change
|
||||||
body.visible = new_visible
|
body.visible = new_visible
|
||||||
# Greying out hidden bodies gives a quick visual hint in the list.
|
|
||||||
item.setForeground(QColor("#1e1e2e") if new_visible else QColor("#6c7086"))
|
item.setForeground(QColor("#1e1e2e") if new_visible else QColor("#6c7086"))
|
||||||
# Apply to the 3D viewer: if the body has a rendered object, hide
|
|
||||||
# or show it. Bodies without a render_object (e.g. just-created,
|
|
||||||
# not yet displayed) don't need viewer updates; they'll pick up
|
|
||||||
# the visibility at the next redraw.
|
|
||||||
if body.render_object is not None:
|
if body.render_object is not None:
|
||||||
ok = self._viewer_3d.set_visibility(body.render_object, new_visible)
|
ok = self._viewer_3d.set_visibility(body.render_object, new_visible)
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -3486,6 +3769,12 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
logger.info(f"{'Visible' if new_visible else 'Hidden'}: {body.name}")
|
logger.info(f"{'Visible' if new_visible else 'Hidden'}: {body.name}")
|
||||||
|
|
||||||
|
def _on_body_hide_button_clicked(self) -> None:
|
||||||
|
"""Handle click on pb_body_hide button - toggle visibility of selected body."""
|
||||||
|
current_item = self._body_list.currentItem()
|
||||||
|
if current_item is not None:
|
||||||
|
self._on_body_visibility_changed(current_item)
|
||||||
|
|
||||||
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
|
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
|
||||||
|
|
||||||
def _resolve_extrude_target(
|
def _resolve_extrude_target(
|
||||||
@@ -3558,6 +3847,7 @@ class MainWindow(QMainWindow):
|
|||||||
cut: bool,
|
cut: bool,
|
||||||
union: bool,
|
union: bool,
|
||||||
through_all: bool,
|
through_all: bool,
|
||||||
|
cut_all_bodies: bool = False,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""Compute the *previewable* result of an extrude/cut/union.
|
"""Compute the *previewable* result of an extrude/cut/union.
|
||||||
|
|
||||||
@@ -3566,6 +3856,7 @@ class MainWindow(QMainWindow):
|
|||||||
- "target_body": the Body being modified (None for plain extrude)
|
- "target_body": the Body being modified (None for plain extrude)
|
||||||
- "tool_geom": the extruded profile geometry (the boolean tool)
|
- "tool_geom": the extruded profile geometry (the boolean tool)
|
||||||
- "tool_shape": same, as a TopoDS_Shape (for show/remove)
|
- "tool_shape": same, as a TopoDS_Shape (for show/remove)
|
||||||
|
- "all_targets": list of all bodies affected when cut_all_bodies
|
||||||
Or *None* if the geometry can't be built (e.g. empty sketch).
|
Or *None* if the geometry can't be built (e.g. empty sketch).
|
||||||
|
|
||||||
Mutates nothing on the project — safe to call repeatedly for the
|
Mutates nothing on the project — safe to call repeatedly for the
|
||||||
@@ -3577,6 +3868,15 @@ class MainWindow(QMainWindow):
|
|||||||
return None
|
return None
|
||||||
# Resolve target (only meaningful for cut / union).
|
# Resolve target (only meaningful for cut / union).
|
||||||
target = self._resolve_extrude_target(sketch) if (cut or union) else None
|
target = self._resolve_extrude_target(sketch) if (cut or union) else None
|
||||||
|
# When cut_all_bodies, collect all bodies in the component as targets.
|
||||||
|
all_targets: list = []
|
||||||
|
if cut_all_bodies and cut and self._current_component is not None:
|
||||||
|
all_targets = [
|
||||||
|
b for b in self._current_component.bodies.values() if b.geometry is not None
|
||||||
|
]
|
||||||
|
# Use the first non-tool body as the primary target for preview.
|
||||||
|
if target is None and all_targets:
|
||||||
|
target = all_targets[0]
|
||||||
# Determine the extrude length and direction.
|
# Determine the extrude length and direction.
|
||||||
if through_all and target is not None:
|
if through_all and target is not None:
|
||||||
# Pass-through: symmetric extrude large enough to clear the body
|
# Pass-through: symmetric extrude large enough to clear the body
|
||||||
@@ -3617,6 +3917,7 @@ class MainWindow(QMainWindow):
|
|||||||
"target_body": target,
|
"target_body": target,
|
||||||
"tool_geom": tool_geom,
|
"tool_geom": tool_geom,
|
||||||
"tool_shape": tool_shape,
|
"tool_shape": tool_shape,
|
||||||
|
"all_targets": all_targets,
|
||||||
}
|
}
|
||||||
# Plain extrude: the tool IS the result.
|
# Plain extrude: the tool IS the result.
|
||||||
return {
|
return {
|
||||||
@@ -3625,6 +3926,7 @@ class MainWindow(QMainWindow):
|
|||||||
"target_body": None,
|
"target_body": None,
|
||||||
"tool_geom": tool_geom,
|
"tool_geom": tool_geom,
|
||||||
"tool_shape": tool_shape,
|
"tool_shape": tool_shape,
|
||||||
|
"all_targets": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _start_extrude_preview(self, dialog: ExtrudeDialog, sketch: Sketch, face_geom: Any) -> None:
|
def _start_extrude_preview(self, dialog: ExtrudeDialog, sketch: Sketch, face_geom: Any) -> None:
|
||||||
@@ -3639,17 +3941,24 @@ class MainWindow(QMainWindow):
|
|||||||
# which case we leave them alone).
|
# which case we leave them alone).
|
||||||
state = {"dimmed": []}
|
state = {"dimmed": []}
|
||||||
|
|
||||||
def _apply_dim(target: Optional[Body]):
|
def _apply_dim(targets):
|
||||||
# Undo any prior dim, then dim the new target.
|
"""Dim one or more bodies for preview clarity."""
|
||||||
for bid, tval in state["dimmed"]:
|
# Undo any prior dim.
|
||||||
|
for bid, _tval in state["dimmed"]:
|
||||||
body = self._current_component.bodies.get(bid) if self._current_component else None
|
body = self._current_component.bodies.get(bid) if self._current_component else None
|
||||||
if body is not None and body.render_object is not None:
|
if body is not None and body.render_object is not None:
|
||||||
self._viewer_3d.set_transparency(body.render_object, 0.0)
|
self._viewer_3d.set_transparency(body.render_object, 0.0)
|
||||||
state["dimmed"].clear()
|
state["dimmed"].clear()
|
||||||
if target is not None and target.render_object is not None:
|
# Normalize to a list.
|
||||||
ok = self._viewer_3d.set_transparency(target.render_object, 0.6)
|
if targets is None:
|
||||||
|
targets = []
|
||||||
|
elif isinstance(targets, Body):
|
||||||
|
targets = [targets]
|
||||||
|
for t in targets:
|
||||||
|
if t is not None and t.render_object is not None:
|
||||||
|
ok = self._viewer_3d.set_transparency(t.render_object, 0.6)
|
||||||
if ok:
|
if ok:
|
||||||
state["dimmed"].append((target.id, 0.6))
|
state["dimmed"].append((t.id, 0.6))
|
||||||
|
|
||||||
def _clear():
|
def _clear():
|
||||||
self._viewer_3d.clear_preview()
|
self._viewer_3d.clear_preview()
|
||||||
@@ -3663,7 +3972,7 @@ class MainWindow(QMainWindow):
|
|||||||
if values is None:
|
if values is None:
|
||||||
_clear()
|
_clear()
|
||||||
return
|
return
|
||||||
length, symmetric, invert, cut, union, through_all, _rounded = values
|
length, symmetric, invert, cut, union, through_all, cut_all_bodies, _rounded = values
|
||||||
result = self._compute_extrude_result(
|
result = self._compute_extrude_result(
|
||||||
sketch,
|
sketch,
|
||||||
face_geom,
|
face_geom,
|
||||||
@@ -3673,12 +3982,18 @@ class MainWindow(QMainWindow):
|
|||||||
bool(cut),
|
bool(cut),
|
||||||
bool(union),
|
bool(union),
|
||||||
bool(through_all),
|
bool(through_all),
|
||||||
|
cut_all_bodies=bool(cut_all_bodies),
|
||||||
)
|
)
|
||||||
if result is None or result["result_shape"] is None:
|
if result is None or result["result_shape"] is None:
|
||||||
self._viewer_3d.clear_preview()
|
self._viewer_3d.clear_preview()
|
||||||
_apply_dim(None)
|
_apply_dim(None)
|
||||||
return
|
return
|
||||||
self._viewer_3d.show_preview(result["result_shape"])
|
self._viewer_3d.show_preview(result["result_shape"])
|
||||||
|
# Dim all affected bodies when cut_all_bodies is active.
|
||||||
|
all_targets = result.get("all_targets", [])
|
||||||
|
if all_targets:
|
||||||
|
_apply_dim(all_targets)
|
||||||
|
else:
|
||||||
_apply_dim(result["target_body"])
|
_apply_dim(result["target_body"])
|
||||||
|
|
||||||
dialog.set_preview_callback(_callback)
|
dialog.set_preview_callback(_callback)
|
||||||
@@ -3731,10 +4046,13 @@ class MainWindow(QMainWindow):
|
|||||||
logger.info("Extrude dialog cancelled")
|
logger.info("Extrude dialog cancelled")
|
||||||
return
|
return
|
||||||
|
|
||||||
length, symmetric, invert, cut, union, through_all, rounded = dialog.get_values()
|
length, symmetric, invert, cut, union, through_all, cut_all_bodies, rounded = (
|
||||||
|
dialog.get_values()
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Extrude params: length={length}, symmetric={symmetric}, "
|
f"Extrude params: length={length}, symmetric={symmetric}, "
|
||||||
f"invert={invert}, cut={cut}, union={union}, through_all={through_all}"
|
f"invert={invert}, cut={cut}, union={union}, through_all={through_all}, "
|
||||||
|
f"cut_all_bodies={cut_all_bodies}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Capture the face index before the dialog closes (the selected
|
# Capture the face index before the dialog closes (the selected
|
||||||
@@ -3751,6 +4069,7 @@ class MainWindow(QMainWindow):
|
|||||||
bool(cut),
|
bool(cut),
|
||||||
bool(union),
|
bool(union),
|
||||||
bool(through_all),
|
bool(through_all),
|
||||||
|
cut_all_bodies=bool(cut_all_bodies),
|
||||||
)
|
)
|
||||||
if result is None or result["result_geom"] is None:
|
if result is None or result["result_geom"] is None:
|
||||||
logger.warning("Extrude produced no geometry")
|
logger.warning("Extrude produced no geometry")
|
||||||
@@ -3758,12 +4077,41 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
target = result["target_body"]
|
target = result["target_body"]
|
||||||
if target is not None:
|
all_targets = result.get("all_targets", [])
|
||||||
# Cut / union: commit the result onto the *target* body in
|
|
||||||
# place (don't create a separate tool body — the previous
|
if target is not None and bool(cut) and all_targets:
|
||||||
# implementation did, and that was the user-perceived
|
# Cut all bodies: apply the boolean difference to every body
|
||||||
# "added without cut" bug once the spurious body was
|
# in the component that has geometry.
|
||||||
# deleted).
|
tool_geom = result["tool_geom"]
|
||||||
|
updated_count = 0
|
||||||
|
for body in all_targets:
|
||||||
|
try:
|
||||||
|
new_geom = self._kernel.boolean_difference(body.geometry, tool_geom)
|
||||||
|
body.geometry = new_geom
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc)
|
||||||
|
continue
|
||||||
|
body.extrude_length = length
|
||||||
|
body.extrude_symmetric = symmetric
|
||||||
|
body.extrude_invert = invert
|
||||||
|
body.extrude_cut = True
|
||||||
|
body.extrude_union = False
|
||||||
|
body.extrude_through_all = bool(through_all)
|
||||||
|
body.extrude_cut_all_bodies = True
|
||||||
|
body.extrude_face_index = face_index
|
||||||
|
body.source_sketch = sketch
|
||||||
|
body.source_operation = "cut"
|
||||||
|
body.extrude_target_body_id = body.id
|
||||||
|
if body.render_object is not None:
|
||||||
|
self._viewer_3d.remove_mesh(body.render_object)
|
||||||
|
shape = self._kernel._get_shape(body.geometry)
|
||||||
|
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
||||||
|
updated_count += 1
|
||||||
|
logger.info(f"Cut-all applied to {updated_count} body(ies)")
|
||||||
|
body_name = f"{updated_count} body(ies)"
|
||||||
|
elif target is not None:
|
||||||
|
# Single-body cut / union: commit the result onto the *target*
|
||||||
|
# body in place.
|
||||||
target.geometry = result["result_geom"]
|
target.geometry = result["result_geom"]
|
||||||
# Store extrude params so the body can be rebuilt later.
|
# Store extrude params so the body can be rebuilt later.
|
||||||
target.extrude_length = length
|
target.extrude_length = length
|
||||||
@@ -3772,6 +4120,7 @@ class MainWindow(QMainWindow):
|
|||||||
target.extrude_cut = bool(cut)
|
target.extrude_cut = bool(cut)
|
||||||
target.extrude_union = bool(union)
|
target.extrude_union = bool(union)
|
||||||
target.extrude_through_all = bool(through_all)
|
target.extrude_through_all = bool(through_all)
|
||||||
|
target.extrude_cut_all_bodies = False
|
||||||
target.extrude_face_index = face_index
|
target.extrude_face_index = face_index
|
||||||
target.source_sketch = sketch
|
target.source_sketch = sketch
|
||||||
target.source_operation = "cut" if cut else "union"
|
target.source_operation = "cut" if cut else "union"
|
||||||
@@ -3797,6 +4146,7 @@ class MainWindow(QMainWindow):
|
|||||||
extrude_cut=False,
|
extrude_cut=False,
|
||||||
extrude_union=False,
|
extrude_union=False,
|
||||||
extrude_through_all=bool(through_all),
|
extrude_through_all=bool(through_all),
|
||||||
|
extrude_cut_all_bodies=False,
|
||||||
extrude_face_index=face_index,
|
extrude_face_index=face_index,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -4356,12 +4706,12 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Rebuild component buttons (one per component, with thumbnails).
|
# Rebuild component buttons (one per component, with thumbnails).
|
||||||
for idx, comp in enumerate(self._project.components.values(), start=1):
|
for idx, comp in enumerate(self._project.components.values(), start=1):
|
||||||
first_body = next(iter(comp.bodies.values()), None)
|
has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
|
||||||
if first_body and first_body.geometry:
|
if has_geometry:
|
||||||
btn = _create_component_button(
|
btn = _create_component_button(
|
||||||
idx,
|
idx,
|
||||||
comp.name,
|
comp.name,
|
||||||
first_body,
|
comp,
|
||||||
self._kernel,
|
self._kernel,
|
||||||
self._component_group,
|
self._component_group,
|
||||||
self._component_box_layout,
|
self._component_box_layout,
|
||||||
@@ -4409,12 +4759,16 @@ class MainWindow(QMainWindow):
|
|||||||
btn.clicked.connect(self._on_assembly_component_clicked)
|
btn.clicked.connect(self._on_assembly_component_clicked)
|
||||||
_set_button_style(btn)
|
_set_button_style(btn)
|
||||||
|
|
||||||
# Thumbnail from the source component's first body.
|
# Thumbnail from the source component's all bodies.
|
||||||
src_comp = self._project.components.get(ac.component_id)
|
src_comp = self._project.components.get(ac.component_id)
|
||||||
if src_comp:
|
if src_comp:
|
||||||
first_body = next(iter(src_comp.bodies.values()), None)
|
has_geometry = any(
|
||||||
if first_body and first_body.geometry:
|
b.visible and b.geometry for b in src_comp.bodies.values()
|
||||||
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
|
)
|
||||||
|
if has_geometry:
|
||||||
|
pixmap = _make_component_thumbnail(
|
||||||
|
src_comp, self._kernel, QSize(96, 96)
|
||||||
|
)
|
||||||
if pixmap is not None:
|
if pixmap is not None:
|
||||||
btn.setIcon(pixmap)
|
btn.setIcon(pixmap)
|
||||||
btn.setIconSize(QSize(96, 96))
|
btn.setIconSize(QSize(96, 96))
|
||||||
@@ -4512,7 +4866,7 @@ class MainWindow(QMainWindow):
|
|||||||
btn = _create_component_button(
|
btn = _create_component_button(
|
||||||
btn_num,
|
btn_num,
|
||||||
name,
|
name,
|
||||||
body,
|
comp,
|
||||||
self._kernel,
|
self._kernel,
|
||||||
self._component_group,
|
self._component_group,
|
||||||
self._component_box_layout,
|
self._component_box_layout,
|
||||||
|
|||||||
+371
-86
@@ -185,6 +185,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
# Rectangle first-click snap target (stored so the second click
|
# Rectangle first-click snap target (stored so the second click
|
||||||
# doesn't overwrite it and the correct corner gets constrained).
|
# doesn't overwrite it and the correct corner gets constrained).
|
||||||
self._rect_first_snap_target: Optional[OCCSketchEntity] = None
|
self._rect_first_snap_target: Optional[OCCSketchEntity] = None
|
||||||
|
self._rect_first_line_target: Optional[OCCSketchEntity] = None
|
||||||
|
|
||||||
# Offset preview state (live preview while the OffsetDialog is open).
|
# Offset preview state (live preview while the OffsetDialog is open).
|
||||||
self._offset_preview_points: List[Tuple[float, float]] = []
|
self._offset_preview_points: List[Tuple[float, float]] = []
|
||||||
@@ -375,15 +376,18 @@ class Sketch2DWidget(QWidget):
|
|||||||
# Clear any prior external entities before importing fresh ones so a
|
# Clear any prior external entities before importing fresh ones so a
|
||||||
# repeated face pick doesn't pile up duplicate construction lines.
|
# repeated face pick doesn't pile up duplicate construction lines.
|
||||||
self._sketch.remove_external_entities()
|
self._sketch.remove_external_entities()
|
||||||
imported = 0
|
# Import ALL polylines in one call so corners shared between edges
|
||||||
for poly in self._source_underlay_uv:
|
# become a single external point entity (one connection hub per
|
||||||
if len(poly) < 2:
|
# corner) instead of stacked duplicates.
|
||||||
continue
|
polys = [
|
||||||
try:
|
|
||||||
_, lines = self._sketch.add_external_polyline(
|
|
||||||
[(float(u), float(v)) for (u, v) in poly]
|
[(float(u), float(v)) for (u, v) in poly]
|
||||||
)
|
for poly in self._source_underlay_uv
|
||||||
imported += len(lines)
|
if len(poly) >= 2
|
||||||
|
]
|
||||||
|
imported = 0
|
||||||
|
try:
|
||||||
|
_, lines = self._sketch.add_external_polylines(polys)
|
||||||
|
imported = len(lines)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("underlay polyline import failed: %s", exc)
|
logger.debug("underlay polyline import failed: %s", exc)
|
||||||
logger.info("Imported %d construction-line segments from source face", imported)
|
logger.info("Imported %d construction-line segments from source face", imported)
|
||||||
@@ -560,6 +564,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._mode = None
|
self._mode = None
|
||||||
self._clear_move_state()
|
self._clear_move_state()
|
||||||
self._rect_first_snap_target = None
|
self._rect_first_snap_target = None
|
||||||
|
self._rect_first_line_target = None
|
||||||
self.clear_offset_preview()
|
self.clear_offset_preview()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -597,6 +602,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._snap_horizontal = False
|
self._snap_horizontal = False
|
||||||
self._snap_vertical = False
|
self._snap_vertical = False
|
||||||
self._rect_first_snap_target = None
|
self._rect_first_snap_target = None
|
||||||
|
self._rect_first_line_target = None
|
||||||
self._arc_accum_sweep = 0.0
|
self._arc_accum_sweep = 0.0
|
||||||
self._arc_prev_angle = None
|
self._arc_prev_angle = None
|
||||||
|
|
||||||
@@ -671,22 +677,16 @@ class Sketch2DWidget(QWidget):
|
|||||||
# ─── Snapping ─────────────────────────────────────────────────────────
|
# ─── Snapping ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
|
def _find_nearest_point(self, pos: QPoint, max_distance: int = 15) -> Optional[QPoint]:
|
||||||
if not self._snap_mode.get("point", False):
|
# Delegates to _find_nearest_point_entity so the position snap and
|
||||||
|
# the constraint target ALWAYS agree — previously this loop ignored
|
||||||
|
# underlay visibility, so a hidden underlay corner would snap the
|
||||||
|
# cursor position while the entity lookup returned None and no
|
||||||
|
# coincident constraint was created (silent disconnect).
|
||||||
|
entity = self._find_nearest_point_entity(pos, max_distance)
|
||||||
|
if entity is None or entity.geometry is None:
|
||||||
return None
|
return None
|
||||||
nearest = None
|
|
||||||
min_dist = max_distance
|
|
||||||
for entity in self._points:
|
|
||||||
if entity.geometry:
|
|
||||||
x, y = entity.geometry
|
x, y = entity.geometry
|
||||||
point = QPoint(int(round(x)), int(round(y)))
|
return QPoint(int(round(x)), int(round(y)))
|
||||||
screen_point = self._world_to_screen(point)
|
|
||||||
dist = math.sqrt(
|
|
||||||
(pos.x() - screen_point.x()) ** 2 + (pos.y() - screen_point.y()) ** 2
|
|
||||||
)
|
|
||||||
if dist < min_dist:
|
|
||||||
min_dist = dist
|
|
||||||
nearest = point
|
|
||||||
return nearest
|
|
||||||
|
|
||||||
def _find_nearest_point_entity(
|
def _find_nearest_point_entity(
|
||||||
self, pos: QPoint, max_distance: int = 15
|
self, pos: QPoint, max_distance: int = 15
|
||||||
@@ -721,6 +721,14 @@ class Sketch2DWidget(QWidget):
|
|||||||
if not self._snap_mode.get("mpoint", False):
|
if not self._snap_mode.get("mpoint", False):
|
||||||
return None
|
return None
|
||||||
for p1, p2 in self._lines:
|
for p1, p2 in self._lines:
|
||||||
|
line_ent = self._find_line_sketch_entity(p1, p2)
|
||||||
|
# Skip hidden underlay lines (consistent with point snap) and
|
||||||
|
# centerlines (their midpoint is the origin — covered by point snap).
|
||||||
|
if line_ent is not None and (
|
||||||
|
(self._is_external(line_ent) and not self._underlay_visible)
|
||||||
|
or self._is_centerline(line_ent)
|
||||||
|
):
|
||||||
|
continue
|
||||||
if p1.geometry and p2.geometry:
|
if p1.geometry and p2.geometry:
|
||||||
x1, y1 = p1.geometry
|
x1, y1 = p1.geometry
|
||||||
x2, y2 = p2.geometry
|
x2, y2 = p2.geometry
|
||||||
@@ -731,6 +739,52 @@ class Sketch2DWidget(QWidget):
|
|||||||
return mid
|
return mid
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _find_line_snap(
|
||||||
|
self, pos: QPoint, max_distance: int = 15
|
||||||
|
) -> Optional[Tuple[QPoint, OCCSketchEntity]]:
|
||||||
|
"""Snap onto the nearest line (projected point on the line).
|
||||||
|
|
||||||
|
Returns ``(world_point_on_line, line_entity)`` for the closest line
|
||||||
|
within ``max_distance`` screen pixels, or None. External (underlay)
|
||||||
|
lines participate when the underlay is visible — this is what lets a
|
||||||
|
click anywhere along a projected construction edge anchor the new
|
||||||
|
point with a point-on-line constraint. Centerlines count as
|
||||||
|
infinite reference axes (perpendicular projection, no clamping).
|
||||||
|
"""
|
||||||
|
if not self._snap_mode.get("point", False):
|
||||||
|
return None
|
||||||
|
wx, wy = self._screen_to_world_f(pos)
|
||||||
|
best: Optional[Tuple[QPoint, OCCSketchEntity]] = None
|
||||||
|
best_dist = float(max_distance)
|
||||||
|
for p1, p2 in self._lines:
|
||||||
|
line_ent = self._find_line_sketch_entity(p1, p2)
|
||||||
|
if line_ent is None:
|
||||||
|
continue
|
||||||
|
if self._is_external(line_ent) and not self._underlay_visible:
|
||||||
|
continue
|
||||||
|
if p1.geometry is None or p2.geometry is None:
|
||||||
|
continue
|
||||||
|
x1, y1 = p1.geometry
|
||||||
|
x2, y2 = p2.geometry
|
||||||
|
dx = x2 - x1
|
||||||
|
dy = y2 - y1
|
||||||
|
len_sq = dx * dx + dy * dy
|
||||||
|
if len_sq == 0:
|
||||||
|
continue
|
||||||
|
if self._is_centerline(line_ent):
|
||||||
|
# Infinite reference axis: unclamped perpendicular projection.
|
||||||
|
t = ((wx - x1) * dx + (wy - y1) * dy) / len_sq
|
||||||
|
else:
|
||||||
|
t = max(0.0, min(1.0, ((wx - x1) * dx + (wy - y1) * dy) / len_sq))
|
||||||
|
proj_x = x1 + t * dx
|
||||||
|
proj_y = y1 + t * dy
|
||||||
|
screen_proj = self._world_to_screen(QPoint(int(round(proj_x)), int(round(proj_y))))
|
||||||
|
dist = math.sqrt((pos.x() - screen_proj.x()) ** 2 + (pos.y() - screen_proj.y()) ** 2)
|
||||||
|
if dist < best_dist:
|
||||||
|
best_dist = dist
|
||||||
|
best = (QPoint(int(round(proj_x)), int(round(proj_y))), line_ent)
|
||||||
|
return best
|
||||||
|
|
||||||
def _apply_angle_snap(self, start: QPoint, end: QPoint) -> QPoint:
|
def _apply_angle_snap(self, start: QPoint, end: QPoint) -> QPoint:
|
||||||
if not self._snap_mode.get("angle", False):
|
if not self._snap_mode.get("angle", False):
|
||||||
return end
|
return end
|
||||||
@@ -771,6 +825,14 @@ class Sketch2DWidget(QWidget):
|
|||||||
mid_snap = self._find_midpoint_snap(pos)
|
mid_snap = self._find_midpoint_snap(pos)
|
||||||
if mid_snap:
|
if mid_snap:
|
||||||
return self._world_to_screen(mid_snap)
|
return self._world_to_screen(mid_snap)
|
||||||
|
# Line snap: click anywhere along a line (incl. projected construction
|
||||||
|
# edges) lands exactly on it and carries a point-on-line constraint
|
||||||
|
# target so the new endpoint is actually connected, not just close.
|
||||||
|
line_snap = self._find_line_snap(pos)
|
||||||
|
if line_snap is not None:
|
||||||
|
snap_pt, line_ent = line_snap
|
||||||
|
self._snap_line_target = line_ent
|
||||||
|
return self._world_to_screen(snap_pt)
|
||||||
if start:
|
if start:
|
||||||
if self._snap_mode.get("horiz", False):
|
if self._snap_mode.get("horiz", False):
|
||||||
horiz = self._apply_horizontal_snap(start, result)
|
horiz = self._apply_horizontal_snap(start, result)
|
||||||
@@ -860,6 +922,44 @@ class Sketch2DWidget(QWidget):
|
|||||||
if nearest is not None:
|
if nearest is not None:
|
||||||
return nearest
|
return nearest
|
||||||
|
|
||||||
|
# Line snap (excluding lines whose both endpoints are being moved)
|
||||||
|
if self._snap_mode.get("point", False):
|
||||||
|
wx, wy = self._screen_to_world_f(pos)
|
||||||
|
best_line: Optional[Tuple[QPoint, OCCSketchEntity]] = None
|
||||||
|
best_line_dist = float(self._snap_distance)
|
||||||
|
for p1, p2 in self._lines:
|
||||||
|
if p1.id in exclude_ids and p2.id in exclude_ids:
|
||||||
|
continue
|
||||||
|
line_ent = self._find_line_sketch_entity(p1, p2)
|
||||||
|
if line_ent is None:
|
||||||
|
continue
|
||||||
|
if self._is_external(line_ent) and not self._underlay_visible:
|
||||||
|
continue
|
||||||
|
if p1.geometry is None or p2.geometry is None:
|
||||||
|
continue
|
||||||
|
x1, y1 = p1.geometry
|
||||||
|
x2, y2 = p2.geometry
|
||||||
|
dx = x2 - x1
|
||||||
|
dy = y2 - y1
|
||||||
|
len_sq = dx * dx + dy * dy
|
||||||
|
if len_sq == 0:
|
||||||
|
continue
|
||||||
|
if self._is_centerline(line_ent):
|
||||||
|
t = ((wx - x1) * dx + (wy - y1) * dy) / len_sq
|
||||||
|
else:
|
||||||
|
t = max(0.0, min(1.0, ((wx - x1) * dx + (wy - y1) * dy) / len_sq))
|
||||||
|
proj_x = x1 + t * dx
|
||||||
|
proj_y = y1 + t * dy
|
||||||
|
sp = self._world_to_screen(QPoint(int(round(proj_x)), int(round(proj_y))))
|
||||||
|
d = math.sqrt((pos.x() - sp.x()) ** 2 + (pos.y() - sp.y()) ** 2)
|
||||||
|
if d < best_line_dist:
|
||||||
|
best_line_dist = d
|
||||||
|
best_line = (QPoint(int(round(proj_x)), int(round(proj_y))), line_ent)
|
||||||
|
if best_line is not None:
|
||||||
|
snap_pt, line_ent = best_line
|
||||||
|
self._snap_line_target = line_ent
|
||||||
|
return self._world_to_screen(snap_pt)
|
||||||
|
|
||||||
# Horizontal / vertical / angle snaps are relative to the original anchor
|
# Horizontal / vertical / angle snaps are relative to the original anchor
|
||||||
result = pos
|
result = pos
|
||||||
if self._snap_mode.get("horiz", False):
|
if self._snap_mode.get("horiz", False):
|
||||||
@@ -1116,6 +1216,8 @@ class Sketch2DWidget(QWidget):
|
|||||||
fm = QFontMetrics(QFont("Monospace", 9))
|
fm = QFontMetrics(QFont("Monospace", 9))
|
||||||
# Track how many tags already share an anchor so we stack them vertically.
|
# Track how many tags already share an anchor so we stack them vertically.
|
||||||
stack_count: Dict[Tuple[int, int], int] = {}
|
stack_count: Dict[Tuple[int, int], int] = {}
|
||||||
|
# Compute sketch centroid once so all pills stack inside the shape.
|
||||||
|
centroid_screen = self._sketch_centroid_screen()
|
||||||
|
|
||||||
for idx, entry in enumerate(self._sketch._constraint_log):
|
for idx, entry in enumerate(self._sketch._constraint_log):
|
||||||
# One bad log entry (e.g. a dangling id after a delete, an
|
# One bad log entry (e.g. a dangling id after a delete, an
|
||||||
@@ -1128,6 +1230,11 @@ class Sketch2DWidget(QWidget):
|
|||||||
params = entry["params"]
|
params = entry["params"]
|
||||||
anchor: Optional[QPoint] = None
|
anchor: Optional[QPoint] = None
|
||||||
label = ""
|
label = ""
|
||||||
|
# World-space endpoints for distance dimension lines.
|
||||||
|
# Initialised every iteration so stale values from a prior
|
||||||
|
# distance constraint are never accidentally reused.
|
||||||
|
tag_p1: Optional[QPoint] = None
|
||||||
|
tag_p2: Optional[QPoint] = None
|
||||||
|
|
||||||
if ctype == "horizontal":
|
if ctype == "horizontal":
|
||||||
anchor = self._line_world_mid(ids[0])
|
anchor = self._line_world_mid(ids[0])
|
||||||
@@ -1149,7 +1256,10 @@ class Sketch2DWidget(QWidget):
|
|||||||
# NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
|
# NOTE: use `is not None`, not truthiness — QPoint(0,0) is falsy in PySide6.
|
||||||
if a is not None and b is not None:
|
if a is not None and b is not None:
|
||||||
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
|
anchor = QPoint((a.x() + b.x()) // 2, (a.y() + b.y()) // 2)
|
||||||
label = f"dst {params[0]:.1f}" if params else "dst"
|
label = "dstc"
|
||||||
|
# Store world-space positions for dimension-line rendering.
|
||||||
|
tag_p1 = a
|
||||||
|
tag_p2 = b
|
||||||
elif ctype == "parallel":
|
elif ctype == "parallel":
|
||||||
anchor = self._line_world_mid(ids[0])
|
anchor = self._line_world_mid(ids[0])
|
||||||
label = "par"
|
label = "par"
|
||||||
@@ -1184,6 +1294,13 @@ class Sketch2DWidget(QWidget):
|
|||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# For distance constraints, allow rendering even without an
|
||||||
|
# anchor — we only need tag_p1/tag_p2 for the dimension line.
|
||||||
|
if anchor is None:
|
||||||
|
if ctype != "distance":
|
||||||
|
continue
|
||||||
|
# Use first valid endpoint as fallback anchor for the pill.
|
||||||
|
anchor = tag_p1 if tag_p1 is not None else tag_p2
|
||||||
if anchor is None:
|
if anchor is None:
|
||||||
continue
|
continue
|
||||||
sc = self._world_to_screen(anchor)
|
sc = self._world_to_screen(anchor)
|
||||||
@@ -1194,11 +1311,34 @@ class Sketch2DWidget(QWidget):
|
|||||||
text = f"> {label} <"
|
text = f"> {label} <"
|
||||||
w = fm.horizontalAdvance(text) + 10
|
w = fm.horizontalAdvance(text) + 10
|
||||||
h = 16
|
h = 16
|
||||||
# Stack successive tags above the anchor so they don't overlap.
|
# Push pill toward the sketch centroid (inside the closed
|
||||||
cx = sc.x()
|
# shape) so it sits inside — dimensions are outside.
|
||||||
cy = sc.y() - 14 - slot * (h + 2)
|
if centroid_screen is not None:
|
||||||
|
dir_x = centroid_screen.x() - sc.x()
|
||||||
|
dir_y = centroid_screen.y() - sc.y()
|
||||||
|
dist = math.sqrt(dir_x * dir_x + dir_y * dir_y)
|
||||||
|
if dist > 0.5:
|
||||||
|
nx, ny = dir_x / dist, dir_y / dist
|
||||||
|
else:
|
||||||
|
# Anchor is at the centroid — default to upward.
|
||||||
|
nx, ny = 0.0, -1.0
|
||||||
|
else:
|
||||||
|
# No centroid available — default to upward.
|
||||||
|
nx, ny = 0.0, -1.0
|
||||||
|
# Place the first pill 14 px inside from anchor,
|
||||||
|
# stack subsequent pills further in.
|
||||||
|
offset = 14 + slot * (h + 2)
|
||||||
|
cx = sc.x() + nx * offset
|
||||||
|
cy = sc.y() + ny * offset
|
||||||
rect = QRect(cx - w // 2, cy - h // 2, w, h)
|
rect = QRect(cx - w // 2, cy - h // 2, w, h)
|
||||||
tags.append({"idx": idx, "label": text, "rect": rect, "center": QPoint(cx, cy)})
|
tag_entry = {"idx": idx, "label": text, "rect": rect, "center": QPoint(cx, cy)}
|
||||||
|
# Attach world-space endpoints so paintEvent can draw dimension lines.
|
||||||
|
if ctype == "distance":
|
||||||
|
if tag_p1 is not None and tag_p2 is not None:
|
||||||
|
tag_entry["p1_world"] = tag_p1
|
||||||
|
tag_entry["p2_world"] = tag_p2
|
||||||
|
tag_entry["distance"] = params[0] if params else 0.0
|
||||||
|
tags.append(tag_entry)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Catch any failure while building this one tag (bad
|
# Catch any failure while building this one tag (bad
|
||||||
# geometry, missing entity, numpy round weirdness, etc.)
|
# geometry, missing entity, numpy round weirdness, etc.)
|
||||||
@@ -1817,11 +1957,20 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._solve_and_sync()
|
self._solve_and_sync()
|
||||||
self._snap_point_target = None
|
self._snap_point_target = None
|
||||||
else:
|
else:
|
||||||
# Auto-constrain: point snap during move → coincident
|
# Auto-constrain: snap → coincident / point-on-line
|
||||||
if self._snap_point_target is not None and self._move_anchor is not None:
|
target = None
|
||||||
self._sketch.constrain_coincident(
|
if self._snap_point_target is not None:
|
||||||
self._move_anchor, self._snap_point_target
|
target = self._snap_point_target
|
||||||
)
|
elif self._snap_line_target is not None and self._move_anchor is not None:
|
||||||
|
# Don't constrain a point onto a line it belongs to.
|
||||||
|
line_ent = self._snap_line_target
|
||||||
|
ep = self._sketch._lines.get(line_ent.id)
|
||||||
|
if ep is None or (
|
||||||
|
self._move_anchor.id != ep[0] and self._move_anchor.id != ep[1]
|
||||||
|
):
|
||||||
|
target = line_ent
|
||||||
|
if target is not None and self._move_anchor is not None:
|
||||||
|
self._sketch.constrain_coincident(self._move_anchor, target)
|
||||||
self._solve_and_sync()
|
self._solve_and_sync()
|
||||||
# Snap modes are honoured during the move (see _apply_move_snaps
|
# Snap modes are honoured during the move (see _apply_move_snaps
|
||||||
# in mouseMoveEvent), so the committed positions are already snapped.
|
# in mouseMoveEvent), so the committed positions are already snapped.
|
||||||
@@ -2100,6 +2249,32 @@ class Sketch2DWidget(QWidget):
|
|||||||
|
|
||||||
# ─── Drawing handlers ─────────────────────────────────────────────────
|
# ─── Drawing handlers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _auto_constrain_new_point(self, point: OCCSketchEntity, solve: bool = False) -> None:
|
||||||
|
"""Anchor a freshly created point to whatever the cursor snapped to.
|
||||||
|
|
||||||
|
Point-to-point (coincident) wins; otherwise a line snap produces a
|
||||||
|
point-on-line constraint (the solver's coincident accepts a point and
|
||||||
|
a line). Without the line fallback, a click along a projected
|
||||||
|
construction edge placed a free point that merely *looked* connected
|
||||||
|
— the endpoint never moved with the edge and loops broke on drag.
|
||||||
|
"""
|
||||||
|
if self._sketch is None:
|
||||||
|
return
|
||||||
|
target = None
|
||||||
|
if self._snap_point_target is not None:
|
||||||
|
target = self._snap_point_target
|
||||||
|
elif self._snap_line_target is not None:
|
||||||
|
# Don't constrain a point onto a line it is itself an endpoint of
|
||||||
|
# (degenerate / redundant for the solver).
|
||||||
|
line_ent = self._snap_line_target
|
||||||
|
ep = self._sketch._lines.get(line_ent.id)
|
||||||
|
if ep is None or (point.id != ep[0] and point.id != ep[1]):
|
||||||
|
target = line_ent
|
||||||
|
if target is not None:
|
||||||
|
self._sketch.constrain_coincident(point, target)
|
||||||
|
if solve:
|
||||||
|
self._solve_and_sync()
|
||||||
|
|
||||||
def _handle_line_click(self, pos: QPoint):
|
def _handle_line_click(self, pos: QPoint):
|
||||||
self._ensure_sketch_with_centerlines()
|
self._ensure_sketch_with_centerlines()
|
||||||
|
|
||||||
@@ -2112,10 +2287,8 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._points.append(point)
|
self._points.append(point)
|
||||||
self._draw_buffer.append(pos)
|
self._draw_buffer.append(pos)
|
||||||
|
|
||||||
# Auto-constrain: point snap → coincident on start point
|
# Auto-constrain: snap → coincident / point-on-line on start point
|
||||||
if self._snap_point_target is not None:
|
self._auto_constrain_new_point(point, solve=True)
|
||||||
self._sketch.constrain_coincident(point, self._snap_point_target)
|
|
||||||
self._solve_and_sync()
|
|
||||||
else:
|
else:
|
||||||
point = self._sketch.add_point(pos.x(), pos.y())
|
point = self._sketch.add_point(pos.x(), pos.y())
|
||||||
point.is_construction = self._is_construct
|
point.is_construction = self._is_construct
|
||||||
@@ -2125,9 +2298,8 @@ class Sketch2DWidget(QWidget):
|
|||||||
line = self._sketch.add_line(self._points[-2], self._points[-1])
|
line = self._sketch.add_line(self._points[-2], self._points[-1])
|
||||||
self._lines.append((self._points[-2], self._points[-1]))
|
self._lines.append((self._points[-2], self._points[-1]))
|
||||||
|
|
||||||
# Auto-constrain: point snap → coincident on end point
|
# Auto-constrain: snap → coincident / point-on-line on end point
|
||||||
if self._snap_point_target is not None:
|
self._auto_constrain_new_point(self._points[-1])
|
||||||
self._sketch.constrain_coincident(self._points[-1], self._snap_point_target)
|
|
||||||
|
|
||||||
# Auto-constrain: detect horizontal / vertical from geometry
|
# Auto-constrain: detect horizontal / vertical from geometry
|
||||||
if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
|
if self._snap_mode.get("horiz", False) or self._snap_mode.get("vert", False):
|
||||||
@@ -2157,6 +2329,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._undo_manager.save_state()
|
self._undo_manager.save_state()
|
||||||
self._draw_buffer.append(pos)
|
self._draw_buffer.append(pos)
|
||||||
self._rect_first_snap_target = self._snap_point_target
|
self._rect_first_snap_target = self._snap_point_target
|
||||||
|
self._rect_first_line_target = self._snap_line_target
|
||||||
else:
|
else:
|
||||||
p1 = self._draw_buffer[0]
|
p1 = self._draw_buffer[0]
|
||||||
p2 = pos
|
p2 = pos
|
||||||
@@ -2179,13 +2352,13 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._lines.append((pts[i], pts[(i + 1) % 4]))
|
self._lines.append((pts[i], pts[(i + 1) % 4]))
|
||||||
line_entities.append(line)
|
line_entities.append(line)
|
||||||
|
|
||||||
# Auto-constrain: point snap → coincident on the correct corners.
|
# Auto-constrain: snap → coincident / point-on-line on the
|
||||||
# pts[0] = first click snapped position
|
# correct corners. pts[0] = first click, pts[2] = second click.
|
||||||
# pts[2] = second click snapped position
|
|
||||||
if self._rect_first_snap_target is not None:
|
if self._rect_first_snap_target is not None:
|
||||||
self._sketch.constrain_coincident(pts[0], self._rect_first_snap_target)
|
self._sketch.constrain_coincident(pts[0], self._rect_first_snap_target)
|
||||||
if self._snap_point_target is not None:
|
elif self._rect_first_line_target is not None:
|
||||||
self._sketch.constrain_coincident(pts[2], self._snap_point_target)
|
self._sketch.constrain_coincident(pts[0], self._rect_first_line_target)
|
||||||
|
self._auto_constrain_new_point(pts[2])
|
||||||
|
|
||||||
# Auto-constrain: detect horizontal / vertical from geometry
|
# Auto-constrain: detect horizontal / vertical from geometry
|
||||||
if self._snap_mode.get("horiz", False):
|
if self._snap_mode.get("horiz", False):
|
||||||
@@ -2225,10 +2398,8 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._points.append(center)
|
self._points.append(center)
|
||||||
self._draw_buffer.append(pos)
|
self._draw_buffer.append(pos)
|
||||||
|
|
||||||
# Auto-constrain: point snap → coincident on center
|
# Auto-constrain: snap → coincident / point-on-line on center
|
||||||
if self._snap_point_target is not None:
|
self._auto_constrain_new_point(center, solve=True)
|
||||||
self._sketch.constrain_coincident(center, self._snap_point_target)
|
|
||||||
self._solve_and_sync()
|
|
||||||
else:
|
else:
|
||||||
center = self._points[-1]
|
center = self._points[-1]
|
||||||
cx, cy = center.geometry if center.geometry else (0, 0)
|
cx, cy = center.geometry if center.geometry else (0, 0)
|
||||||
@@ -2257,10 +2428,8 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._points.append(center)
|
self._points.append(center)
|
||||||
self._draw_buffer.append(pos)
|
self._draw_buffer.append(pos)
|
||||||
|
|
||||||
# Auto-constrain: point snap → coincident on center
|
# Auto-constrain: snap → coincident / point-on-line on center
|
||||||
if self._snap_point_target is not None:
|
self._auto_constrain_new_point(center, solve=True)
|
||||||
self._sketch.constrain_coincident(center, self._snap_point_target)
|
|
||||||
self._solve_and_sync()
|
|
||||||
elif len(self._draw_buffer) == 1:
|
elif len(self._draw_buffer) == 1:
|
||||||
# Click 2: place start point (defines radius + start angle)
|
# Click 2: place start point (defines radius + start angle)
|
||||||
start = self._sketch.add_point(pos.x(), pos.y())
|
start = self._sketch.add_point(pos.x(), pos.y())
|
||||||
@@ -2274,9 +2443,8 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._arc_prev_angle = math.atan2(pos.y() - cy, pos.x() - cx)
|
self._arc_prev_angle = math.atan2(pos.y() - cy, pos.x() - cx)
|
||||||
self._arc_accum_sweep = 0.0
|
self._arc_accum_sweep = 0.0
|
||||||
|
|
||||||
# Auto-constrain: point snap → coincident on start
|
# Auto-constrain: snap → coincident / point-on-line on start
|
||||||
if self._snap_point_target is not None:
|
self._auto_constrain_new_point(start)
|
||||||
self._sketch.constrain_coincident(start, self._snap_point_target)
|
|
||||||
|
|
||||||
self._solve_and_sync()
|
self._solve_and_sync()
|
||||||
else:
|
else:
|
||||||
@@ -2321,9 +2489,8 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._sketch.add_arc(center, radius, start_point, end, sweep=sweep)
|
self._sketch.add_arc(center, radius, start_point, end, sweep=sweep)
|
||||||
self._arcs.append((center, radius, start_point, end, sweep))
|
self._arcs.append((center, radius, start_point, end, sweep))
|
||||||
|
|
||||||
# Auto-constrain: point snap → coincident on end point
|
# Auto-constrain: snap → coincident / point-on-line on end point
|
||||||
if self._snap_point_target is not None:
|
self._auto_constrain_new_point(end)
|
||||||
self._sketch.constrain_coincident(end, self._snap_point_target)
|
|
||||||
|
|
||||||
self._solve_and_sync()
|
self._solve_and_sync()
|
||||||
|
|
||||||
@@ -2352,9 +2519,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._points.append(c1)
|
self._points.append(c1)
|
||||||
self._draw_buffer.append(pos)
|
self._draw_buffer.append(pos)
|
||||||
|
|
||||||
if self._snap_point_target is not None:
|
self._auto_constrain_new_point(c1, solve=True)
|
||||||
self._sketch.constrain_coincident(c1, self._snap_point_target)
|
|
||||||
self._solve_and_sync()
|
|
||||||
|
|
||||||
self.sketch_updated.emit()
|
self.sketch_updated.emit()
|
||||||
self.update()
|
self.update()
|
||||||
@@ -2367,8 +2532,7 @@ class Sketch2DWidget(QWidget):
|
|||||||
self._points.append(c2)
|
self._points.append(c2)
|
||||||
self._draw_buffer.append(pos)
|
self._draw_buffer.append(pos)
|
||||||
|
|
||||||
if self._snap_point_target is not None:
|
self._auto_constrain_new_point(c2)
|
||||||
self._sketch.constrain_coincident(c2, self._snap_point_target)
|
|
||||||
|
|
||||||
self._solve_and_sync()
|
self._solve_and_sync()
|
||||||
self.sketch_updated.emit()
|
self.sketch_updated.emit()
|
||||||
@@ -2850,41 +3014,145 @@ class Sketch2DWidget(QWidget):
|
|||||||
def _point_distance(self, p1: QPoint, p2: QPoint) -> float:
|
def _point_distance(self, p1: QPoint, p2: QPoint) -> float:
|
||||||
return math.sqrt((p1.x() - p2.x()) ** 2 + (p1.y() - p2.y()) ** 2)
|
return math.sqrt((p1.x() - p2.x()) ** 2 + (p1.y() - p2.y()) ** 2)
|
||||||
|
|
||||||
def _draw_distance_measurement(self, painter: QPainter, p1: QPoint, p2: QPoint):
|
def _sketch_centroid_screen(self) -> Optional[QPointF]:
|
||||||
"""Draw dimension lines and distance value between two world-coord points."""
|
"""Screen-space centroid of all non-external sketch points.
|
||||||
|
|
||||||
|
``_points`` maps point_id → (x, y) tuple. We look up the entity
|
||||||
|
in ``_entities`` to check ``is_external`` and get the geometry.
|
||||||
|
"""
|
||||||
|
if not self._sketch:
|
||||||
|
return None
|
||||||
|
sx = sy = 0.0
|
||||||
|
n = 0
|
||||||
|
for pid, geom in self._sketch._points.items():
|
||||||
|
ent = self._sketch._entities.get(pid)
|
||||||
|
if ent is not None and self._is_external(ent):
|
||||||
|
continue
|
||||||
|
if isinstance(geom, tuple) and len(geom) == 2:
|
||||||
|
x, y = geom
|
||||||
|
if isinstance(x, (int, float)) and isinstance(y, (int, float)):
|
||||||
|
sp = self._world_to_screen(QPoint(int(round(x)), int(round(y))))
|
||||||
|
sx += sp.x()
|
||||||
|
sy += sp.y()
|
||||||
|
n += 1
|
||||||
|
return QPointF(sx / n, sy / n) if n > 0 else None
|
||||||
|
|
||||||
|
def _draw_technical_dimension(
|
||||||
|
self,
|
||||||
|
painter: QPainter,
|
||||||
|
p1: QPoint,
|
||||||
|
p2: QPoint,
|
||||||
|
value: float,
|
||||||
|
color: QColor = QColor("#a6e3a1"),
|
||||||
|
):
|
||||||
|
"""Draw an ISO-style dimension between two world-space points.
|
||||||
|
|
||||||
|
Renders extension lines from each endpoint, a dimension line offset
|
||||||
|
from the measured edge (on the side *away* from the sketch centroid),
|
||||||
|
arrowheads at both ends, and the value with "mm" centred on the
|
||||||
|
dimension line.
|
||||||
|
"""
|
||||||
sp1 = self._world_to_screen(p1)
|
sp1 = self._world_to_screen(p1)
|
||||||
sp2 = self._world_to_screen(p2)
|
sp2 = self._world_to_screen(p2)
|
||||||
|
|
||||||
dx = sp2.x() - sp1.x()
|
dx = sp2.x() - sp1.x()
|
||||||
dy = sp2.y() - sp1.y()
|
dy = sp2.y() - sp1.y()
|
||||||
length = math.sqrt(dx * dx + dy * dy)
|
screen_len = math.sqrt(dx * dx + dy * dy)
|
||||||
if length == 0:
|
if screen_len < 1:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Perpendicular direction for offset lines
|
# Unit vectors along and perpendicular to the dimension line.
|
||||||
perp_dx = -dy / length
|
ux = dx / screen_len
|
||||||
perp_dy = dx / length
|
uy = dy / screen_len
|
||||||
offset = 25.0
|
px = -uy
|
||||||
|
py = ux
|
||||||
|
|
||||||
p1a = QPointF(sp1.x() + perp_dx, sp1.y() + perp_dy)
|
# Flip the perpendicular so the dimension line is on the side
|
||||||
p1b = QPointF(sp1.x() + perp_dx * offset, sp1.y() + perp_dy * offset)
|
# *away* from the sketch centroid (outside the closed shape).
|
||||||
p2a = QPointF(sp2.x() + perp_dx, sp2.y() + perp_dy)
|
centroid = self._sketch_centroid_screen()
|
||||||
p2b = QPointF(sp2.x() + perp_dx * offset, sp2.y() + perp_dy * offset)
|
if centroid is not None:
|
||||||
mid = QPointF((p1b.x() + p2b.x()) / 2, (p1b.y() + p2b.y()) / 2)
|
# Cross product: positive → centroid is left of p1→p2
|
||||||
|
cross = dx * (centroid.y() - sp1.y()) - dy * (centroid.x() - sp1.x())
|
||||||
|
if cross > 0:
|
||||||
|
px, py = -px, -py
|
||||||
|
else:
|
||||||
|
# Fallback: pick the side with the smaller screen coord.
|
||||||
|
if py > 0 or (py == 0 and px < 0):
|
||||||
|
px, py = -px, -py
|
||||||
|
|
||||||
pen_dim = QPen(QColor("#a6e3a1"), 1.5, Qt.DotLine)
|
# ── Scale factors ──
|
||||||
|
ext_gap = 4.0
|
||||||
|
ext_overrun = 5.0
|
||||||
|
dim_offset = 25.0
|
||||||
|
arrow_len = 6.0
|
||||||
|
arrow_w = 2.0
|
||||||
|
gap = 40.0
|
||||||
|
|
||||||
|
# ── Extension lines ──
|
||||||
|
e1s = QPointF(sp1.x() + px * ext_gap, sp1.y() + py * ext_gap)
|
||||||
|
e1e = QPointF(
|
||||||
|
sp1.x() + px * (dim_offset + ext_overrun), sp1.y() + py * (dim_offset + ext_overrun)
|
||||||
|
)
|
||||||
|
e2s = QPointF(sp2.x() + px * ext_gap, sp2.y() + py * ext_gap)
|
||||||
|
e2e = QPointF(
|
||||||
|
sp2.x() + px * (dim_offset + ext_overrun), sp2.y() + py * (dim_offset + ext_overrun)
|
||||||
|
)
|
||||||
|
|
||||||
|
pen_ext = QPen(color, 0.8)
|
||||||
|
painter.setPen(pen_ext)
|
||||||
|
painter.drawLine(e1s, e1e)
|
||||||
|
painter.drawLine(e2s, e2e)
|
||||||
|
|
||||||
|
# ── Dimension line (with gap for text) ──
|
||||||
|
d1 = QPointF(sp1.x() + px * dim_offset, sp1.y() + py * dim_offset)
|
||||||
|
d2 = QPointF(sp2.x() + px * dim_offset, sp2.y() + py * dim_offset)
|
||||||
|
dmid = QPointF((d1.x() + d2.x()) / 2, (d1.y() + d2.y()) / 2)
|
||||||
|
|
||||||
|
pen_dim = QPen(color, 1.2)
|
||||||
painter.setPen(pen_dim)
|
painter.setPen(pen_dim)
|
||||||
painter.drawLine(p1a.toPoint(), p1b.toPoint())
|
|
||||||
painter.drawLine(p2a.toPoint(), p2b.toPoint())
|
|
||||||
painter.drawLine(p1b.toPoint(), p2b.toPoint())
|
|
||||||
|
|
||||||
# Draw distance text
|
le = QPointF(dmid.x() - ux * (gap / 2), dmid.y() - uy * (gap / 2))
|
||||||
dist = self._point_distance(p1, p2)
|
painter.drawLine(d1, le)
|
||||||
|
rs = QPointF(dmid.x() + ux * (gap / 2), dmid.y() + uy * (gap / 2))
|
||||||
|
painter.drawLine(rs, d2)
|
||||||
|
|
||||||
|
# ── Arrowheads ──
|
||||||
|
def _arrow(tip: QPointF, idx: float, idy: float):
|
||||||
|
bx = tip.x() - idx * arrow_len
|
||||||
|
by = tip.y() - idy * arrow_len
|
||||||
|
b1 = QPointF(bx + px * arrow_w, by + py * arrow_w)
|
||||||
|
b2 = QPointF(bx - px * arrow_w, by - py * arrow_w)
|
||||||
|
path = QPainterPath()
|
||||||
|
path.moveTo(tip)
|
||||||
|
path.lineTo(b1)
|
||||||
|
path.lineTo(b2)
|
||||||
|
path.closeSubpath()
|
||||||
|
painter.setPen(Qt.NoPen)
|
||||||
|
painter.setBrush(QBrush(color))
|
||||||
|
painter.drawPath(path)
|
||||||
|
painter.setPen(pen_dim)
|
||||||
|
|
||||||
|
_arrow(d1, ux, uy)
|
||||||
|
_arrow(d2, -ux, -uy)
|
||||||
|
|
||||||
|
# ── Dimension text ──
|
||||||
painter.save()
|
painter.save()
|
||||||
painter.translate(mid)
|
try:
|
||||||
painter.scale(1, -1)
|
painter.translate(dmid)
|
||||||
painter.setPen(QPen(QColor("#a6e3a1"), 1))
|
angle = math.degrees(math.atan2(uy, ux))
|
||||||
painter.drawText(0, 0, f"{dist:.2f}")
|
if angle > 90 or angle < -90:
|
||||||
|
angle += 180
|
||||||
|
painter.rotate(angle)
|
||||||
|
painter.setPen(QPen(color, 1))
|
||||||
|
f = QFont("Helvetica", 11)
|
||||||
|
f.setBold(True)
|
||||||
|
painter.setFont(f)
|
||||||
|
text = f"{value:.2f} mm"
|
||||||
|
fm = QFontMetrics(f)
|
||||||
|
tw = fm.horizontalAdvance(text)
|
||||||
|
th = fm.height()
|
||||||
|
painter.drawText(int(-tw / 2), int(th / 3), text)
|
||||||
|
finally:
|
||||||
painter.restore()
|
painter.restore()
|
||||||
|
|
||||||
def paintEvent(self, event):
|
def paintEvent(self, event):
|
||||||
@@ -3097,6 +3365,23 @@ class Sketch2DWidget(QWidget):
|
|||||||
painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
|
painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
|
||||||
painter.drawText(rect, Qt.AlignCenter, tag["label"])
|
painter.drawText(rect, Qt.AlignCenter, tag["label"])
|
||||||
|
|
||||||
|
# ── Technical dimension lines for distance constraints ──
|
||||||
|
# Draw proper measurement lines (extension lines + dimension line
|
||||||
|
# with arrowheads + centred text) for every distance constraint.
|
||||||
|
for tag in self._constraint_tags:
|
||||||
|
if "p1_world" in tag and "p2_world" in tag:
|
||||||
|
try:
|
||||||
|
self._draw_technical_dimension(
|
||||||
|
painter,
|
||||||
|
tag["p1_world"],
|
||||||
|
tag["p2_world"],
|
||||||
|
tag["distance"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
# ── Circles ──
|
# ── Circles ──
|
||||||
for center_ent, radius in self._circles:
|
for center_ent, radius in self._circles:
|
||||||
if center_ent.geometry:
|
if center_ent.geometry:
|
||||||
|
|||||||
+130
-115
@@ -1,7 +1,6 @@
|
|||||||
"""Tests for Fluency CAD geometry kernel."""
|
"""Tests for Fluency CAD geometry kernel."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
||||||
from fluency.geometry_occ.sketch import OCCSketch
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
@@ -195,10 +194,14 @@ class TestOCCSketch:
|
|||||||
sk = OCCSketch()
|
sk = OCCSketch()
|
||||||
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
|
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
|
||||||
# 20x20 square in UV
|
# 20x20 square in UV
|
||||||
p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10)
|
p0 = sk.add_point(-10, -10)
|
||||||
p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10)
|
p1 = sk.add_point(10, -10)
|
||||||
sk.add_line(p0, p1); sk.add_line(p1, p2)
|
p2 = sk.add_point(10, 10)
|
||||||
sk.add_line(p2, p3); sk.add_line(p3, p0)
|
p3 = sk.add_point(-10, 10)
|
||||||
|
sk.add_line(p0, p1)
|
||||||
|
sk.add_line(p1, p2)
|
||||||
|
sk.add_line(p2, p3)
|
||||||
|
sk.add_line(p3, p0)
|
||||||
|
|
||||||
geom = sk.get_geometry()
|
geom = sk.get_geometry()
|
||||||
# The face must carry the plane normal for the kernel.
|
# The face must carry the plane normal for the kernel.
|
||||||
@@ -220,10 +223,14 @@ class TestOCCSketch:
|
|||||||
|
|
||||||
sk = OCCSketch()
|
sk = OCCSketch()
|
||||||
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
|
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
|
||||||
a = sk.add_point(-10, -10); b = sk.add_point(10, -10)
|
a = sk.add_point(-10, -10)
|
||||||
c = sk.add_point(10, 10); d = sk.add_point(-10, 10)
|
b = sk.add_point(10, -10)
|
||||||
sk.add_line(a, b); sk.add_line(b, c)
|
c = sk.add_point(10, 10)
|
||||||
sk.add_line(c, d); sk.add_line(d, a)
|
d = sk.add_point(-10, 10)
|
||||||
|
sk.add_line(a, b)
|
||||||
|
sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d)
|
||||||
|
sk.add_line(d, a)
|
||||||
ctr = sk.add_point(0, 0)
|
ctr = sk.add_point(0, 0)
|
||||||
sk.add_circle(ctr, 3.0)
|
sk.add_circle(ctr, 3.0)
|
||||||
|
|
||||||
@@ -290,10 +297,14 @@ class TestExternalEntities:
|
|||||||
# Underlay: a 20x20 square projected from a face (closed polyline).
|
# Underlay: a 20x20 square projected from a face (closed polyline).
|
||||||
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
|
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
|
||||||
# User profile: a 5x5 square — this is what should be extruded.
|
# User profile: a 5x5 square — this is what should be extruded.
|
||||||
a = sk.add_point(2, 2); b = sk.add_point(8, 2)
|
a = sk.add_point(2, 2)
|
||||||
c = sk.add_point(8, 8); d = sk.add_point(2, 8)
|
b = sk.add_point(8, 2)
|
||||||
sk.add_line(a, b); sk.add_line(b, c)
|
c = sk.add_point(8, 8)
|
||||||
sk.add_line(c, d); sk.add_line(d, a)
|
d = sk.add_point(2, 8)
|
||||||
|
sk.add_line(a, b)
|
||||||
|
sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d)
|
||||||
|
sk.add_line(d, a)
|
||||||
faces = sk.detect_faces()
|
faces = sk.detect_faces()
|
||||||
# Only the user-drawn face (5x5 square) should be detected.
|
# Only the user-drawn face (5x5 square) should be detected.
|
||||||
assert len(faces) == 1
|
assert len(faces) == 1
|
||||||
@@ -310,10 +321,14 @@ class TestExternalEntities:
|
|||||||
def test_external_entities_excluded_from_get_polygon_points(self):
|
def test_external_entities_excluded_from_get_polygon_points(self):
|
||||||
sk = OCCSketch()
|
sk = OCCSketch()
|
||||||
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
|
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
|
||||||
a = sk.add_point(1, 1); b = sk.add_point(2, 1)
|
a = sk.add_point(1, 1)
|
||||||
c = sk.add_point(2, 2); d = sk.add_point(1, 2)
|
b = sk.add_point(2, 1)
|
||||||
sk.add_line(a, b); sk.add_line(b, c)
|
c = sk.add_point(2, 2)
|
||||||
sk.add_line(c, d); sk.add_line(d, a)
|
d = sk.add_point(1, 2)
|
||||||
|
sk.add_line(a, b)
|
||||||
|
sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d)
|
||||||
|
sk.add_line(d, a)
|
||||||
poly = sk.get_polygon_points()
|
poly = sk.get_polygon_points()
|
||||||
# The user square (1..2 range) should appear, not the 0..100 underlay.
|
# The user square (1..2 range) should appear, not the 0..100 underlay.
|
||||||
assert all(1.0 <= p.x <= 2.0 for p in poly)
|
assert all(1.0 <= p.x <= 2.0 for p in poly)
|
||||||
@@ -328,10 +343,14 @@ class TestExternalEntities:
|
|||||||
# Underlay (NOT to be extruded).
|
# Underlay (NOT to be extruded).
|
||||||
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
|
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
|
||||||
# User profile: a 2x2 square inside the underlay.
|
# User profile: a 2x2 square inside the underlay.
|
||||||
a = sk.add_point(1, 1); b = sk.add_point(3, 1)
|
a = sk.add_point(1, 1)
|
||||||
c = sk.add_point(3, 3); d = sk.add_point(1, 3)
|
b = sk.add_point(3, 1)
|
||||||
sk.add_line(a, b); sk.add_line(b, c)
|
c = sk.add_point(3, 3)
|
||||||
sk.add_line(c, d); sk.add_line(d, a)
|
d = sk.add_point(1, 3)
|
||||||
|
sk.add_line(a, b)
|
||||||
|
sk.add_line(b, c)
|
||||||
|
sk.add_line(c, d)
|
||||||
|
sk.add_line(d, a)
|
||||||
geom = sk.get_geometry()
|
geom = sk.get_geometry()
|
||||||
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
|
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
|
||||||
kernel = OCGeometryKernel()
|
kernel = OCGeometryKernel()
|
||||||
@@ -498,10 +517,9 @@ class TestExtrudeCutFix:
|
|||||||
and the tool is no longer needed.
|
and the tool is no longer needed.
|
||||||
"""
|
"""
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
from OCP.GProp import GProp_GProps
|
from OCP.GProp import GProp_GProps
|
||||||
from OCP.BRepGProp import BRepGProp
|
from OCP.BRepGProp import BRepGProp
|
||||||
import math
|
|
||||||
|
|
||||||
k = OCGeometryKernel()
|
k = OCGeometryKernel()
|
||||||
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||||
@@ -511,17 +529,18 @@ class TestExtrudeCutFix:
|
|||||||
# expected volume easy to compute.
|
# expected volume easy to compute.
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
|
||||||
from OCP.gp import gp_Pnt, gp_Vec
|
from OCP.gp import gp_Pnt, gp_Vec
|
||||||
|
|
||||||
# 20x20 square at (0,0,0), extruded along +Z by 200.
|
# 20x20 square at (0,0,0), extruded along +Z by 200.
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
|
||||||
|
|
||||||
mp = BRepBuilderAPI_MakePolygon()
|
mp = BRepBuilderAPI_MakePolygon()
|
||||||
for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]:
|
for x, y in [(0, 0), (20, 0), (20, 20), (0, 20)]:
|
||||||
mp.Add(gp_Pnt(x, y, 0))
|
mp.Add(gp_Pnt(x, y, 0))
|
||||||
mp.Close()
|
mp.Close()
|
||||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||||
|
|
||||||
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
|
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
|
||||||
tool_shape = BRepPrimAPI_MakePrism(
|
tool_shape = BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, 200)).Shape()
|
||||||
face, gp_Vec(0, 0, 200)
|
|
||||||
).Shape()
|
|
||||||
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
|
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
|
||||||
|
|
||||||
# Before cut: target is 100^3 = 1_000_000.
|
# Before cut: target is 100^3 = 1_000_000.
|
||||||
@@ -536,9 +555,7 @@ class TestExtrudeCutFix:
|
|||||||
# After cut: target is 1_000_000 - 20*20*100 = 960_000
|
# After cut: target is 1_000_000 - 20*20*100 = 960_000
|
||||||
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
|
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
|
||||||
g1 = GProp_GProps()
|
g1 = GProp_GProps()
|
||||||
BRepGProp.VolumeProperties_s(
|
BRepGProp.VolumeProperties_s(k._get_shape(target_obj_geometry), g1)
|
||||||
k._get_shape(target_obj_geometry), g1
|
|
||||||
)
|
|
||||||
assert abs(g1.Mass() - 960_000.0) < 1.0
|
assert abs(g1.Mass() - 960_000.0) < 1.0
|
||||||
|
|
||||||
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
|
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
|
||||||
@@ -551,10 +568,9 @@ class TestExtrudeCutFix:
|
|||||||
target, so a single body remains.
|
target, so a single body remains.
|
||||||
"""
|
"""
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
|
||||||
from OCP.TopExp import TopExp_Explorer
|
from OCP.TopExp import TopExp_Explorer
|
||||||
from OCP.TopAbs import TopAbs_SOLID
|
from OCP.TopAbs import TopAbs_SOLID
|
||||||
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||||
|
|
||||||
k = OCGeometryKernel()
|
k = OCGeometryKernel()
|
||||||
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
|
||||||
@@ -562,6 +578,7 @@ class TestExtrudeCutFix:
|
|||||||
|
|
||||||
# Tool: small box at the centre, fully inside the target.
|
# Tool: small box at the centre, fully inside the target.
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
|
||||||
|
|
||||||
tool_shape = BBox(20, 20, 20).Shape()
|
tool_shape = BBox(20, 20, 20).Shape()
|
||||||
tool_obj = OCCGeometryObject(tool_shape, {})
|
tool_obj = OCCGeometryObject(tool_shape, {})
|
||||||
|
|
||||||
@@ -595,91 +612,59 @@ class TestBodyVisibilityToggle:
|
|||||||
|
|
||||||
def _make_window(self):
|
def _make_window(self):
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import MainWindow
|
from fluency.main import MainWindow
|
||||||
|
|
||||||
return MainWindow()
|
return MainWindow()
|
||||||
|
|
||||||
def test_body_list_uses_checkable_items(self):
|
def test_body_list_uses_checkable_items(self):
|
||||||
"""Each body list item must be a checkable QListWidgetItem."""
|
"""Each body list item has a data role for the toggle handler."""
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
win = self._make_window()
|
win = self._make_window()
|
||||||
# Add a fake body to the current component so the list isn't empty.
|
# Add a fake body to the current component so the list isn't empty.
|
||||||
from fluency.models.data_model import Body
|
from fluency.models.data_model import Body
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
|
||||||
box = OCCGeometryObject(
|
box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
|
||||||
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
|
||||||
)
|
|
||||||
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
win._refresh_lists()
|
win._refresh_lists()
|
||||||
items = win._body_list.findItems("A", Qt.MatchExactly)
|
items = win._body_list.findItems("A", Qt.MatchExactly)
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
# Item is checkable (so the user can toggle visibility).
|
|
||||||
assert items[0].flags() & Qt.ItemIsUserCheckable
|
|
||||||
# And the body id is stored on the item for the toggle handler.
|
# And the body id is stored on the item for the toggle handler.
|
||||||
assert items[0].data(Qt.UserRole) == "a"
|
assert items[0].data(Qt.UserRole) == "a"
|
||||||
# Default state is checked (= visible).
|
# Default state is visible.
|
||||||
assert items[0].checkState() == Qt.Checked
|
assert win._current_component.bodies["a"].visible is True
|
||||||
|
|
||||||
def test_toggling_visibility_updates_body_model(self):
|
def test_toggling_visibility_updates_body_model(self):
|
||||||
"""Flipping the checkbox should set body.visible accordingly."""
|
"""Toggling visibility via _on_body_visibility_changed updates the model."""
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
|
|
||||||
win = self._make_window()
|
win = self._make_window()
|
||||||
from fluency.models.data_model import Body
|
from fluency.models.data_model import Body
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
|
||||||
box = OCCGeometryObject(
|
box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
|
||||||
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
|
||||||
)
|
|
||||||
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
||||||
win._refresh_lists()
|
win._refresh_lists()
|
||||||
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
||||||
|
|
||||||
# Toggle off.
|
# Toggle off.
|
||||||
item.setCheckState(Qt.Unchecked)
|
|
||||||
win._on_body_visibility_changed(item)
|
win._on_body_visibility_changed(item)
|
||||||
assert win._current_component.bodies["a"].visible is False
|
assert win._current_component.bodies["a"].visible is False
|
||||||
|
|
||||||
# Toggle back on.
|
# Toggle back on.
|
||||||
item.setCheckState(Qt.Checked)
|
|
||||||
win._on_body_visibility_changed(item)
|
win._on_body_visibility_changed(item)
|
||||||
assert win._current_component.bodies["a"].visible is True
|
assert win._current_component.bodies["a"].visible is True
|
||||||
|
|
||||||
def test_visibility_no_op_when_unchanged(self):
|
|
||||||
"""Re-emitting the same state must not trigger a viewer call.
|
|
||||||
|
|
||||||
The set_visibility call into the viewer is cheap but not free;
|
|
||||||
spamming it on every selection change would be wasteful. The
|
|
||||||
handler short-circuits when the new state matches the model's.
|
|
||||||
"""
|
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
win = self._make_window()
|
|
||||||
from fluency.models.data_model import Body
|
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
|
||||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
|
||||||
box = OCCGeometryObject(
|
|
||||||
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
|
|
||||||
)
|
|
||||||
win._current_component.bodies["a"] = Body(name="A", geometry=box)
|
|
||||||
win._refresh_lists()
|
|
||||||
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
|
|
||||||
|
|
||||||
# Force the model's visibility to False to mimic a desync.
|
|
||||||
win._current_component.bodies["a"].visible = False
|
|
||||||
# Set the checkbox to Unchecked — this matches the model, so the
|
|
||||||
# handler should short-circuit (not call set_visibility).
|
|
||||||
item.setCheckState(Qt.Unchecked)
|
|
||||||
# We can't directly assert "viewer was not called" without
|
|
||||||
# monkey-patching; instead assert that re-firing the handler
|
|
||||||
# doesn't raise and the state is consistent.
|
|
||||||
win._on_body_visibility_changed(item)
|
|
||||||
assert win._current_component.bodies["a"].visible is False
|
|
||||||
|
|
||||||
|
|
||||||
def math_hypot(x, y):
|
def math_hypot(x, y):
|
||||||
import math
|
import math
|
||||||
|
|
||||||
return math.hypot(x, y)
|
return math.hypot(x, y)
|
||||||
|
|
||||||
|
|
||||||
@@ -698,10 +683,13 @@ class TestConstraintTagRendering:
|
|||||||
def _make_widget_with_sketch(self, sk):
|
def _make_widget_with_sketch(self, sk):
|
||||||
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
|
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import Sketch2DWidget
|
from fluency.main import Sketch2DWidget
|
||||||
|
|
||||||
w = Sketch2DWidget()
|
w = Sketch2DWidget()
|
||||||
w.set_sketch(sk)
|
w.set_sketch(sk)
|
||||||
return w
|
return w
|
||||||
@@ -824,6 +812,7 @@ class TestConstraintTagRendering:
|
|||||||
class _BadRound:
|
class _BadRound:
|
||||||
def __round__(self, ndigits=0):
|
def __round__(self, ndigits=0):
|
||||||
raise TypeError("cannot round")
|
raise TypeError("cannot round")
|
||||||
|
|
||||||
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
|
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
|
||||||
tags = w._compute_constraint_tags()
|
tags = w._compute_constraint_tags()
|
||||||
assert all(t["center"] is not None for t in tags)
|
assert all(t["center"] is not None for t in tags)
|
||||||
@@ -865,12 +854,13 @@ class TestExtrudeRedesign:
|
|||||||
|
|
||||||
def _make_window_with_box(self, box_side=100.0):
|
def _make_window_with_box(self, box_side=100.0):
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import MainWindow
|
from fluency.main import MainWindow
|
||||||
from fluency.models.data_model import Sketch, Body
|
from fluency.models.data_model import Sketch, Body
|
||||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
|
||||||
from fluency.geometry_occ.sketch import OCCSketch
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
@@ -896,7 +886,6 @@ class TestExtrudeRedesign:
|
|||||||
return win, sketch, sk, box_obj
|
return win, sketch, sk, box_obj
|
||||||
|
|
||||||
def _add_circle(self, sk, r=10.0):
|
def _add_circle(self, sk, r=10.0):
|
||||||
from fluency.geometry_occ.sketch import OCCSketch
|
|
||||||
c = sk.add_point(0, 0)
|
c = sk.add_point(0, 0)
|
||||||
sk.add_circle(c, r)
|
sk.add_circle(c, r)
|
||||||
sk.solve()
|
sk.solve()
|
||||||
@@ -905,6 +894,7 @@ class TestExtrudeRedesign:
|
|||||||
def _geometry_volume(self, win, geom):
|
def _geometry_volume(self, win, geom):
|
||||||
from OCP.GProp import GProp_GProps
|
from OCP.GProp import GProp_GProps
|
||||||
from OCP.BRepGProp import BRepGProp
|
from OCP.BRepGProp import BRepGProp
|
||||||
|
|
||||||
sh = win._kernel._get_shape(geom)
|
sh = win._kernel._get_shape(geom)
|
||||||
g = GProp_GProps()
|
g = GProp_GProps()
|
||||||
BRepGProp.VolumeProperties_s(sh, g)
|
BRepGProp.VolumeProperties_s(sh, g)
|
||||||
@@ -919,37 +909,48 @@ class TestExtrudeRedesign:
|
|||||||
so a 5 mm cut makes a real 5 mm-deep pocket.
|
so a 5 mm cut makes a real 5 mm-deep pocket.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
face_geom = self._add_circle(sk, r=10.0)
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
|
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
|
||||||
# removed nothing; post-redesign it must remove a 5 mm cylinder.
|
# removed nothing; post-redesign it must remove a 5 mm cylinder.
|
||||||
result = win._compute_extrude_result(
|
result = win._compute_extrude_result(
|
||||||
sketch, face_geom,
|
sketch,
|
||||||
length=5.0, symmetric=False, invert=False,
|
face_geom,
|
||||||
cut=True, union=False, through_all=False,
|
length=5.0,
|
||||||
|
symmetric=False,
|
||||||
|
invert=False,
|
||||||
|
cut=True,
|
||||||
|
union=False,
|
||||||
|
through_all=False,
|
||||||
)
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["target_body"] is not None
|
assert result["target_body"] is not None
|
||||||
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, face_geom,
|
sketch,
|
||||||
|
face_geom,
|
||||||
length=5.0, # ignored when through_all
|
length=5.0, # ignored when through_all
|
||||||
symmetric=False, invert=False,
|
symmetric=False,
|
||||||
cut=True, union=False, through_all=True,
|
invert=False,
|
||||||
|
cut=True,
|
||||||
|
union=False,
|
||||||
|
through_all=True,
|
||||||
)
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
vol = self._geometry_volume(win, result["result_geom"])
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
# 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):
|
||||||
@@ -960,30 +961,23 @@ class TestExtrudeRedesign:
|
|||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import MainWindow
|
from fluency.main import MainWindow
|
||||||
from fluency.models.data_model import Sketch, Body
|
from fluency.models.data_model import Sketch, Body
|
||||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
|
||||||
from fluency.geometry_occ.sketch import OCCSketch
|
from fluency.geometry_occ.sketch import OCCSketch
|
||||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||||
|
|
||||||
win = MainWindow()
|
win = MainWindow()
|
||||||
# First body in the dict: a 50-millimetre box ALSO.
|
# First body in the dict: a 50-millimetre box ALSO.
|
||||||
first = OCCGeometryObject(
|
first = OCCGeometryObject(BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {})
|
||||||
BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
|
win._current_component.bodies["first"] = Body(name="First", geometry=first)
|
||||||
)
|
|
||||||
win._current_component.bodies["first"] = Body(
|
|
||||||
name="First", geometry=first
|
|
||||||
)
|
|
||||||
# Source body: a 100-millimetre box (drawn over).
|
# Source body: a 100-millimetre box (drawn over).
|
||||||
src = OCCGeometryObject(
|
src = OCCGeometryObject(BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {})
|
||||||
BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}
|
win._current_component.bodies["src"] = Body(name="Src", geometry=src)
|
||||||
)
|
|
||||||
win._current_component.bodies["src"] = Body(
|
|
||||||
name="Src", geometry=src
|
|
||||||
)
|
|
||||||
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
|
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
|
||||||
sk = OCCSketch()
|
sk = OCCSketch()
|
||||||
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
@@ -999,16 +993,21 @@ class TestExtrudeRedesign:
|
|||||||
|
|
||||||
face_geom = sk.get_geometry()
|
face_geom = sk.get_geometry()
|
||||||
result = win._compute_extrude_result(
|
result = win._compute_extrude_result(
|
||||||
sketch, face_geom,
|
sketch,
|
||||||
length=5.0, symmetric=False, invert=False,
|
face_geom,
|
||||||
cut=True, union=False, through_all=True,
|
length=5.0,
|
||||||
|
symmetric=False,
|
||||||
|
invert=False,
|
||||||
|
cut=True,
|
||||||
|
union=False,
|
||||||
|
through_all=True,
|
||||||
)
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
# Target is the source box, NOT the dict's first body.
|
# Target is the source box, NOT the dict's first body.
|
||||||
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):
|
||||||
@@ -1019,17 +1018,23 @@ class TestExtrudeRedesign:
|
|||||||
rather than "subtracting" from the existing box.
|
rather than "subtracting" from the existing box.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
face_geom = self._add_circle(sk, r=10.0)
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
result = win._compute_extrude_result(
|
result = win._compute_extrude_result(
|
||||||
sketch, face_geom,
|
sketch,
|
||||||
length=10.0, symmetric=False, invert=False,
|
face_geom,
|
||||||
cut=False, union=True, through_all=False,
|
length=10.0,
|
||||||
|
symmetric=False,
|
||||||
|
invert=False,
|
||||||
|
cut=False,
|
||||||
|
union=True,
|
||||||
|
through_all=False,
|
||||||
)
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
vol = self._geometry_volume(win, result["result_geom"])
|
vol = self._geometry_volume(win, result["result_geom"])
|
||||||
# 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):
|
||||||
@@ -1037,9 +1042,14 @@ class TestExtrudeRedesign:
|
|||||||
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
face_geom = self._add_circle(sk, r=10.0)
|
face_geom = self._add_circle(sk, r=10.0)
|
||||||
result = win._compute_extrude_result(
|
result = win._compute_extrude_result(
|
||||||
sketch, face_geom,
|
sketch,
|
||||||
length=10.0, symmetric=False, invert=False,
|
face_geom,
|
||||||
cut=False, union=False, through_all=False,
|
length=10.0,
|
||||||
|
symmetric=False,
|
||||||
|
invert=False,
|
||||||
|
cut=False,
|
||||||
|
union=False,
|
||||||
|
through_all=False,
|
||||||
)
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
# No boolean target; result is the standalone tool extrusion.
|
# No boolean target; result is the standalone tool extrusion.
|
||||||
@@ -1047,7 +1057,8 @@ 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.
|
||||||
@@ -1055,7 +1066,6 @@ class TestExtrudeRedesign:
|
|||||||
The user should be able to click Extrude/Cut immediately without
|
The user should be able to click Extrude/Cut immediately without
|
||||||
first hunting for the new sketch in the left list.
|
first hunting for the new sketch in the left list.
|
||||||
"""
|
"""
|
||||||
from fluency.geometry_occ.kernel import OCCGeometryObject
|
|
||||||
win, _, sk, box_obj = self._make_window_with_box(100.0)
|
win, _, sk, box_obj = self._make_window_with_box(100.0)
|
||||||
# Simulate _on_face_picked by calling it through a fake face
|
# Simulate _on_face_picked by calling it through a fake face
|
||||||
# shape — but the simplest behavioural check is to call the
|
# shape — but the simplest behavioural check is to call the
|
||||||
@@ -1063,6 +1073,7 @@ class TestExtrudeRedesign:
|
|||||||
# set as _current_sketch, and it appears (and is selected) in
|
# set as _current_sketch, and it appears (and is selected) in
|
||||||
# the list after _refresh_lists + setCurrentRow.
|
# the list after _refresh_lists + setCurrentRow.
|
||||||
from fluency.models.data_model import Sketch
|
from fluency.models.data_model import Sketch
|
||||||
|
|
||||||
sketch = Sketch(name="Sketch on face 99")
|
sketch = Sketch(name="Sketch on face 99")
|
||||||
sketch._source_body_id = "b1"
|
sketch._source_body_id = "b1"
|
||||||
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
sketch.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
|
||||||
@@ -1084,8 +1095,10 @@ class TestExtrudeRedesign:
|
|||||||
def test_preview_callback_invoked_on_value_change(self):
|
def test_preview_callback_invoked_on_value_change(self):
|
||||||
"""The live preview callback fires on spinbox/checkbox changes."""
|
"""The live preview callback fires on spinbox/checkbox changes."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import ExtrudeDialog
|
from fluency.main import ExtrudeDialog
|
||||||
|
|
||||||
@@ -1110,8 +1123,10 @@ class TestExtrudeRedesign:
|
|||||||
def test_preview_hidden_event_sends_none(self):
|
def test_preview_hidden_event_sends_none(self):
|
||||||
"""hideEvent should deliver None to the callback so the host clears."""
|
"""hideEvent should deliver None to the callback so the host clears."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication.instance() or QApplication([])
|
app = QApplication.instance() or QApplication([])
|
||||||
from fluency.main import ExtrudeDialog
|
from fluency.main import ExtrudeDialog
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user