- added "measurement lines"

This commit is contained in:
bklronin
2026-07-26 21:39:47 +02:00
parent 9f1c29d319
commit 0daa6152ee
12 changed files with 3110 additions and 1675 deletions
+11 -7
View File
@@ -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&#10;- improved line pickability.">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" /> <change beforePath="$PROJECT_DIR$/gui.ui" 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>
+744 -744
View File
File diff suppressed because it is too large Load Diff
+525 -523
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -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,
+381 -98
View File
@@ -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.
""" """
if len(uv_points) < 2: points, lines = self.add_external_polylines([uv_points])
return [], [] return (points[0] if points else []), lines
# Deduplicate nearby points so shared corners (e.g. a rectangle's
# four vertices) are *one* point entity reused by two line segments. def add_external_polylines(
eps = 1e-6 self, polylines: List[List[Tuple[float, float]]]
points: List[OCCSketchEntity] = [] ) -> Tuple[List[List[OCCSketchEntity]], List[OCCSketchEntity]]:
coord_to_entity: Dict[Tuple[int, int], OCCSketchEntity] = {} """Bulk-import several polylines with corner dedup *across* polylines.
for (u, v) in uv_points:
key = (int(round(u / eps)), int(round(v / eps))) A face projection yields one polyline per boundary edge; edges that
ent = coord_to_entity.get(key) meet at a vertex must share a single external point entity, otherwise
if ent is None: every corner ends up as two independent fixed points and user geometry
ent = self.add_external_point(float(u), float(v)) coincident to one duplicate is *not* connected to geometry coincident
coord_to_entity[key] = ent to the other. Returns ``(points_per_polyline, all_lines)``.
points.append(ent) """
lines: List[OCCSketchEntity] = [] tol = self._EXTERNAL_MERGE_TOL
for i in range(len(points) - 1):
try: def find_or_create(u: float, v: float) -> OCCSketchEntity:
ln = self.add_external_line(points[i], points[i + 1]) # Tolerance-based nearest lookup against existing external points
lines.append(ln) # (entity counts are small — a face boundary has tens of points).
except ValueError: best: Optional[OCCSketchEntity] = None
pass best_d = tol
return points, lines 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:
continue
points = [find_or_create(float(u), float(v)) for (u, v) in uv_points]
all_points.append(points)
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:
ln = self.add_external_line(points[i], points[i + 1])
all_lines.append(ln)
except ValueError:
pass
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(
"type": "sketch_face", occ_face,
"normal": self._wp_normal, {
"origin": self._wp_origin, "type": "sketch_face",
}) "normal": self._wp_normal,
"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")
+10 -3
View File
@@ -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]
+1
View File
@@ -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
+50 -38
View File
@@ -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(),
) )
@@ -295,12 +299,12 @@ class WorkplaneOrientationDialog(QDialog):
self._preset_group = QButtonGroup(self) self._preset_group = QButtonGroup(self)
preset_layout = QGridLayout() preset_layout = QGridLayout()
presets = [ presets = [
("XY (Top)", (0, 0, 1), (1, 0, 0)), ("XY (Top)", (0, 0, 1), (1, 0, 0)),
("XZ (Front)", (0, 1, 0), (1, 0, 0)), ("XZ (Front)", (0, 1, 0), (1, 0, 0)),
("YZ (Right)", (1, 0, 0), (0, 1, 0)), ("YZ (Right)", (1, 0, 0), (0, 1, 0)),
("-XY (Bottom)", (0, 0, -1), (1, 0, 0)), ("-XY (Bottom)", (0, 0, -1), (1, 0, 0)),
("-XZ (Back)", (0, -1, 0), (1, 0, 0)), ("-XZ (Back)", (0, -1, 0), (1, 0, 0)),
("-YZ (Left)", (-1, 0, 0), (0, 1, 0)), ("-YZ (Left)", (-1, 0, 0), (0, 1, 0)),
] ]
for idx, (label, normal, x_dir) in enumerate(presets): for idx, (label, normal, x_dir) in enumerate(presets):
btn = QRadioButton(label) btn = QRadioButton(label)
@@ -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], [
[0, math.cos(ax), -math.sin(ax)], [1, 0, 0],
[0, math.sin(ax), math.cos(ax)], [0, math.cos(ax), -math.sin(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)], [
[0, 1, 0], [math.cos(ay), 0, math.sin(ay)],
[-math.sin(ay), 0, math.cos(ay)], [0, 1, 0],
]) [-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], [
[0, math.cos(ax), -math.sin(ax)], [1, 0, 0],
[0, math.sin(ax), math.cos(ax)], [0, math.cos(ax), -math.sin(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)], [
[0, 1, 0], [math.cos(ay), 0, math.sin(ay)],
[-math.sin(ay), 0, math.cos(ay)], [0, 1, 0],
]) [-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",
)
+410 -56
View File
@@ -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,11 +412,14 @@ 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
if pixmap is not None: has_geometry = any(b.visible and b.geometry for b in component.bodies.values())
btn.setIcon(pixmap) if has_geometry:
btn.setIconSize(QSize(96, 96)) pixmap = _make_component_thumbnail(component, kernel, QSize(96, 96))
btn.setText("") if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
btn.setText("")
group.addButton(btn) group.addButton(btn)
layout.addWidget(btn) layout.addWidget(btn)
@@ -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:
if ok: targets = []
state["dimmed"].append((target.id, 0.6)) 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:
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,13 +3982,19 @@ 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"])
_apply_dim(result["target_body"]) # 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"])
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,
+376 -91
View File
@@ -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,17 +376,20 @@ 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()
# Import ALL polylines in one call so corners shared between edges
# become a single external point entity (one connection hub per
# corner) instead of stacked duplicates.
polys = [
[(float(u), float(v)) for (u, v) in poly]
for poly in self._source_underlay_uv
if len(poly) >= 2
]
imported = 0 imported = 0
for poly in self._source_underlay_uv: try:
if len(poly) < 2: _, lines = self._sketch.add_external_polylines(polys)
continue imported = len(lines)
try: except Exception as exc:
_, lines = self._sketch.add_external_polyline( logger.debug("underlay polyline import failed: %s", exc)
[(float(u), float(v)) for (u, v) in poly]
)
imported += len(lines)
except Exception as 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)
# Pull the new external entities into the UI lists so they're # Pull the new external entities into the UI lists so they're
# snap/hover/paint targets. # snap/hover/paint targets.
@@ -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 x, y = entity.geometry
min_dist = max_distance return QPoint(int(round(x)), int(round(y)))
for entity in self._points:
if entity.geometry:
x, y = entity.geometry
point = 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,8 +1294,15 @@ 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 anchor is None:
continue 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:
continue
sc = self._world_to_screen(anchor) sc = self._world_to_screen(anchor)
key = (sc.x(), sc.y()) key = (sc.x(), sc.y())
slot = stack_count.get(key, 0) slot = stack_count.get(key, 0)
@@ -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,42 +3014,146 @@ 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:
painter.restore() 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()
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
@@ -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
View File
@@ -1,7 +1,6 @@
"""Tests for Fluency CAD geometry kernel.""" """Tests for Fluency CAD geometry kernel."""
import pytest import pytest
import numpy as np
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry_occ.sketch import OCCSketch
@@ -195,10 +194,14 @@ class TestOCCSketch:
sk = OCCSketch() sk = OCCSketch()
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir) sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
# 20x20 square in UV # 20x20 square in UV
p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10) p0 = sk.add_point(-10, -10)
p2 = sk.add_point(10, 10); p3 = sk.add_point(-10, 10) p1 = sk.add_point(10, -10)
sk.add_line(p0, p1); sk.add_line(p1, p2) p2 = sk.add_point(10, 10)
sk.add_line(p2, p3); sk.add_line(p3, p0) p3 = sk.add_point(-10, 10)
sk.add_line(p0, p1)
sk.add_line(p1, p2)
sk.add_line(p2, p3)
sk.add_line(p3, p0)
geom = sk.get_geometry() geom = sk.get_geometry()
# The face must carry the plane normal for the kernel. # The face must carry the plane normal for the kernel.
@@ -220,10 +223,14 @@ class TestOCCSketch:
sk = OCCSketch() sk = OCCSketch()
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0)) sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
a = sk.add_point(-10, -10); b = sk.add_point(10, -10) a = sk.add_point(-10, -10)
c = sk.add_point(10, 10); d = sk.add_point(-10, 10) b = sk.add_point(10, -10)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(10, 10)
sk.add_line(c, d); sk.add_line(d, a) d = sk.add_point(-10, 10)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
ctr = sk.add_point(0, 0) ctr = sk.add_point(0, 0)
sk.add_circle(ctr, 3.0) sk.add_circle(ctr, 3.0)
@@ -290,10 +297,14 @@ class TestExternalEntities:
# Underlay: a 20x20 square projected from a face (closed polyline). # Underlay: a 20x20 square projected from a face (closed polyline).
sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)]) sk.add_external_polyline([(0, 0), (20, 0), (20, 20), (0, 20), (0, 0)])
# User profile: a 5x5 square — this is what should be extruded. # User profile: a 5x5 square — this is what should be extruded.
a = sk.add_point(2, 2); b = sk.add_point(8, 2) a = sk.add_point(2, 2)
c = sk.add_point(8, 8); d = sk.add_point(2, 8) b = sk.add_point(8, 2)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(8, 8)
sk.add_line(c, d); sk.add_line(d, a) d = sk.add_point(2, 8)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
faces = sk.detect_faces() faces = sk.detect_faces()
# Only the user-drawn face (5x5 square) should be detected. # Only the user-drawn face (5x5 square) should be detected.
assert len(faces) == 1 assert len(faces) == 1
@@ -310,10 +321,14 @@ class TestExternalEntities:
def test_external_entities_excluded_from_get_polygon_points(self): def test_external_entities_excluded_from_get_polygon_points(self):
sk = OCCSketch() sk = OCCSketch()
sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)]) sk.add_external_polyline([(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)])
a = sk.add_point(1, 1); b = sk.add_point(2, 1) a = sk.add_point(1, 1)
c = sk.add_point(2, 2); d = sk.add_point(1, 2) b = sk.add_point(2, 1)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(2, 2)
sk.add_line(c, d); sk.add_line(d, a) d = sk.add_point(1, 2)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
poly = sk.get_polygon_points() poly = sk.get_polygon_points()
# The user square (1..2 range) should appear, not the 0..100 underlay. # The user square (1..2 range) should appear, not the 0..100 underlay.
assert all(1.0 <= p.x <= 2.0 for p in poly) assert all(1.0 <= p.x <= 2.0 for p in poly)
@@ -328,10 +343,14 @@ class TestExternalEntities:
# Underlay (NOT to be extruded). # Underlay (NOT to be extruded).
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)]) sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
# User profile: a 2x2 square inside the underlay. # User profile: a 2x2 square inside the underlay.
a = sk.add_point(1, 1); b = sk.add_point(3, 1) a = sk.add_point(1, 1)
c = sk.add_point(3, 3); d = sk.add_point(1, 3) b = sk.add_point(3, 1)
sk.add_line(a, b); sk.add_line(b, c) c = sk.add_point(3, 3)
sk.add_line(c, d); sk.add_line(d, a) d = sk.add_point(1, 3)
sk.add_line(a, b)
sk.add_line(b, c)
sk.add_line(c, d)
sk.add_line(d, a)
geom = sk.get_geometry() geom = sk.get_geometry()
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400. # Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
kernel = OCGeometryKernel() kernel = OCGeometryKernel()
@@ -498,10 +517,9 @@ class TestExtrudeCutFix:
and the tool is no longer needed. and the tool is no longer needed.
""" """
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject from fluency.geometry_occ.kernel import OCGeometryKernel
from OCP.GProp import GProp_GProps from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp from OCP.BRepGProp import BRepGProp
import math
k = OCGeometryKernel() k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape() target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -511,17 +529,18 @@ class TestExtrudeCutFix:
# expected volume easy to compute. # expected volume easy to compute.
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.gp import gp_Pnt, gp_Vec from OCP.gp import gp_Pnt, gp_Vec
# 20x20 square at (0,0,0), extruded along +Z by 200. # 20x20 square at (0,0,0), extruded along +Z by 200.
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon from OCP.BRepBuilderAPI import BRepBuilderAPI_MakePolygon
mp = BRepBuilderAPI_MakePolygon() mp = BRepBuilderAPI_MakePolygon()
for (x, y) in [(0, 0), (20, 0), (20, 20), (0, 20)]: for x, y in [(0, 0), (20, 0), (20, 20), (0, 20)]:
mp.Add(gp_Pnt(x, y, 0)) mp.Add(gp_Pnt(x, y, 0))
mp.Close() mp.Close()
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face() face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
tool_shape = BRepPrimAPI_MakePrism( tool_shape = BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, 200)).Shape()
face, gp_Vec(0, 0, 200)
).Shape()
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"}) tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
# Before cut: target is 100^3 = 1_000_000. # Before cut: target is 100^3 = 1_000_000.
@@ -536,9 +555,7 @@ class TestExtrudeCutFix:
# After cut: target is 1_000_000 - 20*20*100 = 960_000 # After cut: target is 1_000_000 - 20*20*100 = 960_000
# (the prism only intersects the box in z=[0,100], i.e. 100 deep). # (the prism only intersects the box in z=[0,100], i.e. 100 deep).
g1 = GProp_GProps() g1 = GProp_GProps()
BRepGProp.VolumeProperties_s( BRepGProp.VolumeProperties_s(k._get_shape(target_obj_geometry), g1)
k._get_shape(target_obj_geometry), g1
)
assert abs(g1.Mass() - 960_000.0) < 1.0 assert abs(g1.Mass() - 960_000.0) < 1.0
def test_boolean_difference_does_not_leave_separate_cavity_body(self): def test_boolean_difference_does_not_leave_separate_cavity_body(self):
@@ -551,10 +568,9 @@ class TestExtrudeCutFix:
target, so a single body remains. target, so a single body remains.
""" """
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCP.TopExp import TopExp_Explorer from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_SOLID from OCP.TopAbs import TopAbs_SOLID
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject from fluency.geometry_occ.kernel import OCGeometryKernel
k = OCGeometryKernel() k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape() target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -562,6 +578,7 @@ class TestExtrudeCutFix:
# Tool: small box at the centre, fully inside the target. # Tool: small box at the centre, fully inside the target.
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
tool_shape = BBox(20, 20, 20).Shape() tool_shape = BBox(20, 20, 20).Shape()
tool_obj = OCCGeometryObject(tool_shape, {}) tool_obj = OCCGeometryObject(tool_shape, {})
@@ -595,91 +612,59 @@ class TestBodyVisibilityToggle:
def _make_window(self): def _make_window(self):
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow from fluency.main import MainWindow
return MainWindow() return MainWindow()
def test_body_list_uses_checkable_items(self): def test_body_list_uses_checkable_items(self):
"""Each body list item must be a checkable QListWidgetItem.""" """Each body list item has a data role for the toggle handler."""
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
win = self._make_window() win = self._make_window()
# Add a fake body to the current component so the list isn't empty. # Add a fake body to the current component so the list isn't empty.
from fluency.models.data_model import Body from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCCGeometryObject
box = OCCGeometryObject( box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
)
win._current_component.bodies["a"] = Body(name="A", geometry=box) win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists() win._refresh_lists()
items = win._body_list.findItems("A", Qt.MatchExactly) items = win._body_list.findItems("A", Qt.MatchExactly)
assert len(items) == 1 assert len(items) == 1
# Item is checkable (so the user can toggle visibility).
assert items[0].flags() & Qt.ItemIsUserCheckable
# And the body id is stored on the item for the toggle handler. # And the body id is stored on the item for the toggle handler.
assert items[0].data(Qt.UserRole) == "a" assert items[0].data(Qt.UserRole) == "a"
# Default state is checked (= visible). # Default state is visible.
assert items[0].checkState() == Qt.Checked assert win._current_component.bodies["a"].visible is True
def test_toggling_visibility_updates_body_model(self): def test_toggling_visibility_updates_body_model(self):
"""Flipping the checkbox should set body.visible accordingly.""" """Toggling visibility via _on_body_visibility_changed updates the model."""
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
win = self._make_window() win = self._make_window()
from fluency.models.data_model import Body from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCCGeometryObject
box = OCCGeometryObject( box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
)
win._current_component.bodies["a"] = Body(name="A", geometry=box) win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists() win._refresh_lists()
item = win._body_list.findItems("A", Qt.MatchExactly)[0] item = win._body_list.findItems("A", Qt.MatchExactly)[0]
# Toggle off. # Toggle off.
item.setCheckState(Qt.Unchecked)
win._on_body_visibility_changed(item) win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is False assert win._current_component.bodies["a"].visible is False
# Toggle back on. # Toggle back on.
item.setCheckState(Qt.Checked)
win._on_body_visibility_changed(item) win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is True assert win._current_component.bodies["a"].visible is True
def test_visibility_no_op_when_unchanged(self):
"""Re-emitting the same state must not trigger a viewer call.
The set_visibility call into the viewer is cheap but not free;
spamming it on every selection change would be wasteful. The
handler short-circuits when the new state matches the model's.
"""
from PySide6.QtCore import Qt
win = self._make_window()
from fluency.models.data_model import Body
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from fluency.geometry_occ.kernel import OCCGeometryObject
box = OCCGeometryObject(
BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {}
)
win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists()
item = win._body_list.findItems("A", Qt.MatchExactly)[0]
# Force the model's visibility to False to mimic a desync.
win._current_component.bodies["a"].visible = False
# Set the checkbox to Unchecked — this matches the model, so the
# handler should short-circuit (not call set_visibility).
item.setCheckState(Qt.Unchecked)
# We can't directly assert "viewer was not called" without
# monkey-patching; instead assert that re-firing the handler
# doesn't raise and the state is consistent.
win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is False
def math_hypot(x, y): def math_hypot(x, y):
import math import math
return math.hypot(x, y) return math.hypot(x, y)
@@ -698,10 +683,13 @@ class TestConstraintTagRendering:
def _make_widget_with_sketch(self, sk): def _make_widget_with_sketch(self, sk):
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it.""" """Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import Sketch2DWidget from fluency.main import Sketch2DWidget
w = Sketch2DWidget() w = Sketch2DWidget()
w.set_sketch(sk) w.set_sketch(sk)
return w return w
@@ -824,6 +812,7 @@ class TestConstraintTagRendering:
class _BadRound: class _BadRound:
def __round__(self, ndigits=0): def __round__(self, ndigits=0):
raise TypeError("cannot round") raise TypeError("cannot round")
sk._entities[c.id].geometry = (_BadRound(), _BadRound()) sk._entities[c.id].geometry = (_BadRound(), _BadRound())
tags = w._compute_constraint_tags() tags = w._compute_constraint_tags()
assert all(t["center"] is not None for t in tags) assert all(t["center"] is not None for t in tags)
@@ -865,12 +854,13 @@ class TestExtrudeRedesign:
def _make_window_with_box(self, box_side=100.0): def _make_window_with_box(self, box_side=100.0):
import os import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([]) app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body from fluency.models.data_model import Sketch, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
@@ -896,7 +886,6 @@ class TestExtrudeRedesign:
return win, sketch, sk, box_obj return win, sketch, sk, box_obj
def _add_circle(self, sk, r=10.0): def _add_circle(self, sk, r=10.0):
from fluency.geometry_occ.sketch import OCCSketch
c = sk.add_point(0, 0) c = sk.add_point(0, 0)
sk.add_circle(c, r) sk.add_circle(c, r)
sk.solve() sk.solve()
@@ -905,6 +894,7 @@ class TestExtrudeRedesign:
def _geometry_volume(self, win, geom): def _geometry_volume(self, win, geom):
from OCP.GProp import GProp_GProps from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp from OCP.BRepGProp import BRepGProp
sh = win._kernel._get_shape(geom) sh = win._kernel._get_shape(geom)
g = GProp_GProps() g = GProp_GProps()
BRepGProp.VolumeProperties_s(sh, g) BRepGProp.VolumeProperties_s(sh, g)
@@ -919,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