- 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" />
</component>
<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$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" />
<change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/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/rendering/mitsuba_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/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>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -30,7 +32,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="single_window" />
<entry key="$PROJECT_DIR$" value="feature/surface-modifier-pyramid-patterns" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -57,6 +59,7 @@
"Python.Unnamed.executor": "Run",
"Python.base.executor": "Run",
"Python.data_model.executor": "Run",
"Python.debug_dragging.executor": "Run",
"Python.draw_widget2d.executor": "Run",
"Python.draw_widget_solve.executor": "Run",
"Python.fluency.executor": "Run",
@@ -65,6 +68,7 @@
"Python.gui_ui.executor": "Run",
"Python.kernel.executor": "Run",
"Python.main.executor": "Run",
"Python.main_window.executor": "Run",
"Python.meshtest.executor": "Run",
"Python.occ_renderer.executor": "Run",
"Python.occ_to_mesh.executor": "Run",
@@ -81,7 +85,7 @@
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"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.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
@@ -96,9 +100,9 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" />
</key>
+755 -755
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
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(
self,
sketch: GeometryObject,
+372 -89
View File
@@ -109,7 +109,6 @@ class OCCSketch(SketchInterface):
orthonormalised here. ``y_dir`` is derived as ``normal × x_dir``.
Existing UV coordinates are unchanged; only their world mapping moves.
"""
import numpy as np
n = np.asarray(normal, 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):
"""Map a UV point to a world ``gp_Pnt`` on the workplane."""
from OCP.gp import gp_Pnt
ox, oy, oz = self._wp_origin
xx, xy, xz = self._wp_x_dir
yx, yy, yz = self._wp_y_dir
@@ -150,6 +150,7 @@ class OCCSketch(SketchInterface):
def _circle_axis(self, u: float, v: float):
"""Return a ``gp_Ax2`` for a circle centred at UV on the workplane."""
from OCP.gp import gp_Ax2, gp_Dir
center = self._uv_to_world(u, v)
return gp_Ax2(
center,
@@ -275,6 +276,7 @@ class OCCSketch(SketchInterface):
When *None* the rendering will infer the shortest path between start and end.
"""
import math
entity_id = self._next_id()
center_entity = self._entities.get(center.id)
@@ -345,8 +347,10 @@ class OCCSketch(SketchInterface):
self._solver.dragged(solver_handle, self._wp)
entity = OCCSketchEntity(
entity_id=entity_id, entity_type="point",
geometry=(x, y), handle=solver_handle,
entity_id=entity_id,
entity_type="point",
geometry=(x, y),
handle=solver_handle,
)
entity.is_external = True
entity.is_construction = True # external points are reference / dashed
@@ -377,7 +381,8 @@ class OCCSketch(SketchInterface):
x1, y1 = s_ent.geometry
x2, y2 = e_ent.geometry
entity = OCCSketchEntity(
entity_id=entity_id, entity_type="line",
entity_id=entity_id,
entity_type="line",
geometry=((x1, y1), (x2, y2)),
handle=solver_handle,
)
@@ -388,6 +393,10 @@ class OCCSketch(SketchInterface):
self._external_entity_ids.add(entity_id)
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(
self, uv_points: List[Tuple[float, float]]
) -> Tuple[List[OCCSketchEntity], List[OCCSketchEntity]]:
@@ -398,33 +407,108 @@ class OCCSketch(SketchInterface):
``(points, lines)`` in the order they were created so the caller can
keep references (e.g. for rendering or for toggling).
Points very close to each other (within 1e-6 UV units) are merged
into a single shared point, so a closed rectangle becomes 4 unique
points and 4 line segments (not 4 points and 4 lines + 4 duplicates
at the corners).
Points very close to each other (within ``_EXTERNAL_MERGE_TOL`` UV
units) are merged into a single shared point, so a closed rectangle
becomes 4 unique points and 4 line segments (not 4 points and 4
lines + 4 duplicates at the corners). Merging also applies against
*previously imported* external points, so consecutive polylines that
share a corner (separate face edges meeting at a vertex) reuse one
point entity — the corner becomes a single connection hub for
coincident constraints instead of two stacked duplicates.
"""
points, lines = self.add_external_polylines([uv_points])
return (points[0] if points else []), lines
def add_external_polylines(
self, polylines: List[List[Tuple[float, float]]]
) -> Tuple[List[List[OCCSketchEntity]], List[OCCSketchEntity]]:
"""Bulk-import several polylines with corner dedup *across* polylines.
A face projection yields one polyline per boundary edge; edges that
meet at a vertex must share a single external point entity, otherwise
every corner ends up as two independent fixed points and user geometry
coincident to one duplicate is *not* connected to geometry coincident
to the other. Returns ``(points_per_polyline, all_lines)``.
"""
tol = self._EXTERNAL_MERGE_TOL
def find_or_create(u: float, v: float) -> OCCSketchEntity:
# Tolerance-based nearest lookup against existing external points
# (entity counts are small — a face boundary has tens of points).
best: Optional[OCCSketchEntity] = None
best_d = tol
for eid in self._external_entity_ids:
ent = self._entities.get(eid)
if ent is None or ent.entity_type != "point" or ent.geometry is None:
continue
d = math.hypot(ent.geometry[0] - u, ent.geometry[1] - v)
if d <= best_d:
best_d = d
best = ent
if best is None:
best = self.add_external_point(float(u), float(v))
return best
all_points: List[List[OCCSketchEntity]] = []
all_lines: List[OCCSketchEntity] = []
for uv_points in polylines:
if len(uv_points) < 2:
return [], []
# Deduplicate nearby points so shared corners (e.g. a rectangle's
# four vertices) are *one* point entity reused by two line segments.
eps = 1e-6
points: List[OCCSketchEntity] = []
coord_to_entity: Dict[Tuple[int, int], OCCSketchEntity] = {}
for (u, v) in uv_points:
key = (int(round(u / eps)), int(round(v / eps)))
ent = coord_to_entity.get(key)
if ent is None:
ent = self.add_external_point(float(u), float(v))
coord_to_entity[key] = ent
points.append(ent)
lines: List[OCCSketchEntity] = []
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])
lines.append(ln)
all_lines.append(ln)
except ValueError:
pass
return points, lines
return all_points, all_lines
def _drop_external_entities(self) -> set:
"""Remove external entities from local tracking + prune their constraints.
Does NOT rebuild the solver — the caller decides when to rebuild
(removal-only flows rebuild immediately; re-projection flows first
import the new externals so the rebuild sees them and doesn't
auto-anchor a user point instead).
"""
removed = set(self._external_entity_ids)
if not removed:
return removed
# Wipe external entities from local tracking.
for eid in list(removed):
if eid in self._entities:
del self._entities[eid]
self._points.pop(eid, None)
self._lines.pop(eid, None)
self._circles.pop(eid, None)
self._arcs.pop(eid, None)
# Also clean lines that USE an external point as an endpoint but
# somehow aren't themselves external (defensive — shouldn't happen
# via the public API, but rebuild_solver needs a clean graph).
for lid, (sid, eid2) in list(self._lines.items()):
if sid in removed or eid2 in removed:
del self._lines[lid]
if lid in self._entities:
del self._entities[lid]
self._external_entity_ids.clear()
self._prune_log_for(removed)
return removed
def remove_external_entities(self) -> None:
"""Remove every external / underlay entity and prune related constraints.
@@ -437,25 +521,7 @@ class OCCSketch(SketchInterface):
"""
if not self._external_entity_ids:
return
# Wipe external entities from local tracking.
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._drop_external_entities()
self._rebuild_solver()
self._rebuild_labels()
@@ -463,6 +529,184 @@ class OCCSketch(SketchInterface):
"""Return the set of external (underlay) entity ids currently in the sketch."""
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) ────────────────────────────
_CENTERLINE_EXTENT: float = 10000.0 # large enough to span any sketch
@@ -514,8 +758,13 @@ class OCCSketch(SketchInterface):
self.constrain_fixed(yb)
self._centerline_ids = {
origin.id, xl.id, xr.id, xline.id,
yb.id, yt.id, yline.id,
origin.id,
xl.id,
xr.id,
xline.id,
yb.id,
yt.id,
yline.id,
}
self.solve()
@@ -557,7 +806,12 @@ class OCCSketch(SketchInterface):
"""Count and log a constraint so the solver can be rebuilt after deletions."""
self._constraint_count += 1
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:
@@ -666,7 +920,16 @@ class OCCSketch(SketchInterface):
x, y = saved_pos.get(pid, (0.0, 0.0))
new_handle = self._solver.add_point_2d(x, y, self._wp)
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._solver.dragged(new_handle, self._wp)
@@ -1008,7 +1271,12 @@ class OCCSketch(SketchInterface):
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
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
if p1 not in adjacency:
adjacency[p1] = []
@@ -1043,7 +1311,7 @@ class OCCSketch(SketchInterface):
# ─── 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]]]:
"""Current line segments as world-coordinate tuples (uses solved positions).
@@ -1068,8 +1336,12 @@ class OCCSketch(SketchInterface):
s_ent = self._entities.get(sid)
e_ent = self._entities.get(eid2)
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])),
(float(e_ent.geometry[0]), float(e_ent.geometry[1]))))
segs.append(
(
(float(s_ent.geometry[0]), float(s_ent.geometry[1])),
(float(e_ent.geometry[0]), float(e_ent.geometry[1])),
)
)
# ── Arc segments (tessellated) ──
for arc_id, arc_data in self._arcs.items():
@@ -1086,8 +1358,9 @@ class OCCSketch(SketchInterface):
c_ent = self._entities.get(center_id)
s_ent = self._entities.get(start_id)
e_ent = self._entities.get(end_id)
if not (c_ent and s_ent and e_ent
and c_ent.geometry and s_ent.geometry and e_ent.geometry):
if not (
c_ent and s_ent and e_ent and c_ent.geometry and s_ent.geometry and e_ent.geometry
):
continue
cx, cy = c_ent.geometry
sx, sy = s_ent.geometry
@@ -1099,10 +1372,8 @@ class OCCSketch(SketchInterface):
t2 = (i + 1) / n
a1 = start_angle + t1 * sweep
a2 = start_angle + t2 * sweep
p1 = (cx + radius * math.cos(a1),
cy + radius * math.sin(a1))
p2 = (cx + radius * math.cos(a2),
cy + radius * math.sin(a2))
p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1))
p2 = (cx + radius * math.cos(a2), cy + radius * math.sin(a2))
segs.append((p1, p2))
return segs
@@ -1180,8 +1451,13 @@ class OCCSketch(SketchInterface):
for cid, (center_id, r) in self._circles.items():
c_ent = self._entities.get(center_id)
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])),
"radius": float(r)})
loops.append(
{
"type": "circle",
"center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
"radius": float(r),
}
)
return loops
@staticmethod
@@ -1212,7 +1488,10 @@ class OCCSketch(SketchInterface):
# Point-on-segment test — exclude strict boundary hits.
# First check bounding box of the segment.
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
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
abs_cross = abs(cross)
@@ -1269,18 +1548,14 @@ class OCCSketch(SketchInterface):
if inner["type"] == "circle":
# Circle in polygon: centre must be inside with margin
cx, cy = inner["center"]
return OCCSketch._point_in_polygon(
(cx, cy), outer["points"], margin=1e-3
)
return OCCSketch._point_in_polygon((cx, cy), outer["points"], margin=1e-3)
else:
# Polygon in polygon: ALL inner vertices inside outer
pts = inner["points"]
if len(pts) > 1 and pts[0] == pts[-1]:
pts = pts[:-1]
for pt in pts:
if not OCCSketch._point_in_polygon(
pt, outer["points"], margin=eps
):
if not OCCSketch._point_in_polygon(pt, outer["points"], margin=eps):
return False
return True
@@ -1293,7 +1568,11 @@ class OCCSketch(SketchInterface):
the centre for circles.
"""
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)
if n < 3:
return loop.get("center", (0.0, 0.0))
@@ -1374,11 +1653,13 @@ class OCCSketch(SketchInterface):
for h in face["holes"]:
if h["type"] == "polygon":
if OCCSketch._point_in_polygon(pt, h["points"]):
in_hole = True; break
in_hole = True
break
else:
hcx, hcy = h["center"]
if math.hypot(pt[0] - hcx, pt[1] - hcy) < h["radius"]:
in_hole = True; break
in_hole = True
break
if in_hole:
continue
area = OCCSketch._loop_area(outer)
@@ -1426,17 +1707,19 @@ class OCCSketch(SketchInterface):
from top-left to bottom-right.
"""
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakePolygon,
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
def _wire_from_loop(loop: Dict[str, Any]):
"""Build a wire from a loop dict. No orientation adjustment."""
if loop["type"] == "polygon":
mp = BRepBuilderAPI_MakePolygon()
for (pu, pv) in loop["points"]:
for pu, pv in loop["points"]:
mp.Add(self._uv_to_world(pu, pv))
mp.Close()
mp.Build()
@@ -1463,17 +1746,22 @@ class OCCSketch(SketchInterface):
# wire (material on the other side). We reverse the hole wire
# only when its natural winding matches the outer's; if they
# 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())
face_maker.Add(hole_wire)
face_maker.Build()
occ_face = face_maker.Face()
obj = OCCGeometryObject(occ_face, {
obj = OCCGeometryObject(
occ_face,
{
"type": "sketch_face",
"normal": self._wp_normal,
"origin": self._wp_origin,
})
},
)
return obj
def get_solver_dof(self) -> int:
@@ -1579,7 +1867,8 @@ class OCCSketch(SketchInterface):
removed_ids: set = {point.id}
# Remove lines that use this point as an endpoint.
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
]
for lid in removed_line_keys:
@@ -1593,8 +1882,7 @@ class OCCSketch(SketchInterface):
del self._entities[point.id]
# Circles anchored on the point are also invalid.
removed_circle_keys: List[int] = [
cid for cid, (center_id, _r) in list(self._circles.items())
if center_id == point.id
cid for cid, (center_id, _r) in list(self._circles.items()) if center_id == point.id
]
for cid in removed_circle_keys:
removed_ids.add(cid)
@@ -1603,7 +1891,8 @@ class OCCSketch(SketchInterface):
del self._entities[cid]
# Arcs referencing this point (as centre, start, or end) are invalid.
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
or adata.get("start") == point.id
or adata.get("end") == point.id
@@ -1820,9 +2109,7 @@ class OCCSketch(SketchInterface):
s_id = self._find_point_at(x1, y1)
e_id = self._find_point_at(x2, y2)
if s_id is None or e_id is None:
logger.warning(
"Skipping line %s during load: endpoints not found", eid
)
logger.warning("Skipping line %s during load: endpoints not found", eid)
continue
if is_external:
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
c_id = self._find_point_at(cx, cy)
if c_id is None:
logger.warning(
"Skipping circle %s during load: center not found", eid
)
logger.warning("Skipping circle %s during load: center not found", eid)
continue
ent = self.add_circle(entities_by_id[c_id], float(radius))
elif etype == "arc":
@@ -1847,9 +2132,7 @@ class OCCSketch(SketchInterface):
s_id = self._find_point_at(*start_pos)
e_id = self._find_point_at(*end_pos)
if c_id is None or s_id is None or e_id is None:
logger.warning(
"Skipping arc %s during load: endpoints not found", eid
)
logger.warning("Skipping arc %s during load: endpoints not found", eid)
continue
ent = self.add_arc(
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_union": body.extrude_union,
"extrude_through_all": body.extrude_through_all,
"extrude_cut_all_bodies": body.extrude_cut_all_bodies,
"extrude_face_index": body.extrude_face_index,
"extrude_target_body_id": body.extrude_target_body_id,
"position": _coerce_listlike(body.position),
@@ -212,6 +213,7 @@ def _body_from_dict(
extrude_cut=bool(data.get("extrude_cut", False)),
extrude_union=bool(data.get("extrude_union", 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_target_body_id=data.get("extrude_target_body_id"),
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"))
for cid, ac_data in (data.get("components") or {}).items():
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))
return asm
@@ -691,8 +693,13 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
sk_data["occ_sketch"] = meta.get("occ_sketch")
# Workplane fields on the sketch-level file override the
# embedded ones (source of truth lives in the sidecar).
for k in ("workplane_origin", "workplane_normal", "workplane_x_dir",
"is_solved", "is_fully_constrained"):
for k in (
"workplane_origin",
"workplane_normal",
"workplane_x_dir",
"is_solved",
"is_fully_constrained",
):
if k in meta:
sk_data[k] = meta[k]
+1
View File
@@ -232,6 +232,7 @@ class Body:
extrude_cut: bool = False
extrude_union: 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_target_body_id: Optional[str] = None # for cut/union: target body id
needs_update: bool = False # True when source sketch changed since last extrude
+33 -21
View File
@@ -4,18 +4,13 @@ from __future__ import annotations
import logging
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 (
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
@@ -24,11 +19,11 @@ from PySide6.QtWidgets import (
QPushButton,
QRadioButton,
QVBoxLayout,
QWidget,
)
logger = logging.getLogger(__name__)
class ExtrudeDialog(QDialog):
"""Dialog for extrude options.
@@ -76,6 +71,13 @@ class ExtrudeDialog(QDialog):
)
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")
layout.addWidget(self.rounded_checkbox)
@@ -103,6 +105,7 @@ class ExtrudeDialog(QDialog):
self.cut_checkbox,
self.union_checkbox,
self.through_all_checkbox,
self.cut_all_bodies_checkbox,
self.rounded_checkbox,
):
# The spinbox has valueChanged; the checkboxes have stateChanged.
@@ -144,7 +147,7 @@ class ExtrudeDialog(QDialog):
pass
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 (
self.length_input.value(),
self.symmetric_checkbox.isChecked(),
@@ -152,6 +155,7 @@ class ExtrudeDialog(QDialog):
self.cut_checkbox.isChecked(),
self.union_checkbox.isChecked(),
self.through_all_checkbox.isChecked(),
self.cut_all_bodies_checkbox.isChecked(),
self.rounded_checkbox.isChecked(),
)
@@ -404,7 +408,6 @@ class WorkplaneOrientationDialog(QDialog):
def _on_ok(self):
"""Compute the final orientation and accept."""
import numpy as np
import math
if self._custom_radio.isChecked():
# 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
n = np.array([0.0, 0.0, 1.0])
# Rotate around X
rx = np.array([
rx = np.array(
[
[1, 0, 0],
[0, math.cos(ax), -math.sin(ax)],
[0, math.sin(ax), math.cos(ax)],
])
]
)
n = rx @ n
# Rotate around Y
ry = np.array([
ry = np.array(
[
[math.cos(ay), 0, math.sin(ay)],
[0, 1, 0],
[-math.sin(ay), 0, math.cos(ay)],
])
]
)
n = ry @ n
n = n / np.linalg.norm(n)
# 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``.
"""
import numpy as np
import math
if self._custom_radio.isChecked():
ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value())
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)],
[0, math.sin(ax), math.cos(ax)],
])
]
)
n = rx @ n
ry = np.array([
ry = np.array(
[
[math.cos(ay), 0, math.sin(ay)],
[0, 1, 0],
[-math.sin(ay), 0, math.cos(ay)],
])
]
)
n = ry @ n
n = n / np.linalg.norm(n)
world_y = np.array([0.0, 1.0, 0.0])
@@ -492,6 +502,8 @@ class WorkplaneOrientationDialog(QDialog):
if btn is not None:
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
# Fallback: XY default.
return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
return (
(0.0, 0.0, 1.0),
(1.0, 0.0, 0.0),
self._name_input.text().strip() or "Workplane",
)
+404 -50
View File
@@ -183,6 +183,157 @@ def _make_body_thumbnail(
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 ────────────────────────────────
_BTN_MIN = 40 # minimum 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(
num: int,
name: str,
body,
component,
kernel,
group: QButtonGroup,
layout: QHBoxLayout,
@@ -261,7 +412,10 @@ def _create_component_button(
btn.clicked.connect(click_handler)
_set_button_style(btn)
pixmap = _make_body_thumbnail(body, kernel, QSize(96, 96))
# Render thumbnail from all bodies in the component
has_geometry = any(b.visible and b.geometry for b in component.bodies.values())
if has_geometry:
pixmap = _make_component_thumbnail(component, kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
@@ -727,7 +881,7 @@ class MainWindow(QMainWindow):
self._btn_del_sketch = ui.pb_del_sketch
# ── Body tools ──
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
# ── Component tools ──
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_array.clicked.connect(self._pattern_array)
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
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):
return
comp = self._project.components[comp_ids[component_index]]
first_body = next(iter(comp.bodies.values()), None)
if not first_body or not first_body.geometry:
# Check if component has any bodies with geometry
has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
if not has_geometry:
return
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:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
@@ -1076,18 +1234,13 @@ class MainWindow(QMainWindow):
self._sketch_list.addItem(sketch.name)
for body_id, body in self._current_component.bodies.items():
# QListWidgetItem with a checkbox so the user can toggle
# each body's visibility in the 3D viewer. The item's
# data role stores the body id so the toggle handler can
# QListWidgetItem with a data role so the toggle handler can
# look up the right body without relying on display text.
display_name = body.name
if body.needs_update:
display_name = f"{body.name}"
item = QListWidgetItem(display_name)
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.
if not body.visible:
item.setForeground(QColor("#6c7086"))
@@ -1103,6 +1256,8 @@ class MainWindow(QMainWindow):
changes to all assembly instances, and recalculates connectors.
"""
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._propagate_to_assembly()
self._recalculate_connectors()
@@ -1172,6 +1327,49 @@ class MainWindow(QMainWindow):
target = b
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.
try:
if body.extrude_through_all and target is not None:
@@ -1215,6 +1413,87 @@ class MainWindow(QMainWindow):
if updated > 0:
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):
"""Refresh all assembly instances that reference the current component.
@@ -1390,12 +1669,12 @@ class MainWindow(QMainWindow):
btn.clicked.connect(self._on_assembly_component_clicked)
_set_button_style(btn)
# Thumbnail from the component's first body.
# Thumbnail from all bodies in the component.
src_comp = self._current_component
if src_comp:
first_body = next(iter(src_comp.bodies.values()), None)
if first_body and first_body.geometry:
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
has_geometry = any(b.visible and b.geometry for b in src_comp.bodies.values())
if has_geometry:
pixmap = _make_component_thumbnail(src_comp, self._kernel, QSize(96, 96))
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
@@ -3225,6 +3504,17 @@ class MainWindow(QMainWindow):
sketch.set_workplane(origin, normal, x_dir)
# Keep the face reference for the projection underlay (Phase 3).
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
# extrude auto-targets it. ``source_body`` may be None if the
# pick landed on an untracked shape (e.g. an imported STEP that
@@ -3452,11 +3742,9 @@ class MainWindow(QMainWindow):
break
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,
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.
The body is looked up via the UserRole data we set in _refresh_lists.
"""
if self._current_component is None:
return
@@ -3466,16 +3754,11 @@ class MainWindow(QMainWindow):
body = self._current_component.bodies.get(body_id)
if body is None:
return
new_visible = item.checkState() == Qt.Checked
new_visible = not body.visible # toggle
if body.visible == new_visible:
return # no change
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"))
# 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:
ok = self._viewer_3d.set_visibility(body.render_object, new_visible)
if not ok:
@@ -3486,6 +3769,12 @@ class MainWindow(QMainWindow):
)
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) ────────
def _resolve_extrude_target(
@@ -3558,6 +3847,7 @@ class MainWindow(QMainWindow):
cut: bool,
union: bool,
through_all: bool,
cut_all_bodies: bool = False,
) -> Optional[Dict[str, Any]]:
"""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)
- "tool_geom": the extruded profile geometry (the boolean tool)
- "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).
Mutates nothing on the project safe to call repeatedly for the
@@ -3577,6 +3868,15 @@ class MainWindow(QMainWindow):
return None
# Resolve target (only meaningful for cut / union).
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.
if through_all and target is not None:
# Pass-through: symmetric extrude large enough to clear the body
@@ -3617,6 +3917,7 @@ class MainWindow(QMainWindow):
"target_body": target,
"tool_geom": tool_geom,
"tool_shape": tool_shape,
"all_targets": all_targets,
}
# Plain extrude: the tool IS the result.
return {
@@ -3625,6 +3926,7 @@ class MainWindow(QMainWindow):
"target_body": None,
"tool_geom": tool_geom,
"tool_shape": tool_shape,
"all_targets": [],
}
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).
state = {"dimmed": []}
def _apply_dim(target: Optional[Body]):
# Undo any prior dim, then dim the new target.
for bid, tval in state["dimmed"]:
def _apply_dim(targets):
"""Dim one or more bodies for preview clarity."""
# Undo any prior dim.
for bid, _tval in state["dimmed"]:
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:
self._viewer_3d.set_transparency(body.render_object, 0.0)
state["dimmed"].clear()
if target is not None and target.render_object is not None:
ok = self._viewer_3d.set_transparency(target.render_object, 0.6)
# Normalize to a list.
if targets is None:
targets = []
elif isinstance(targets, Body):
targets = [targets]
for t in targets:
if t is not None and t.render_object is not None:
ok = self._viewer_3d.set_transparency(t.render_object, 0.6)
if ok:
state["dimmed"].append((target.id, 0.6))
state["dimmed"].append((t.id, 0.6))
def _clear():
self._viewer_3d.clear_preview()
@@ -3663,7 +3972,7 @@ class MainWindow(QMainWindow):
if values is None:
_clear()
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(
sketch,
face_geom,
@@ -3673,12 +3982,18 @@ class MainWindow(QMainWindow):
bool(cut),
bool(union),
bool(through_all),
cut_all_bodies=bool(cut_all_bodies),
)
if result is None or result["result_shape"] is None:
self._viewer_3d.clear_preview()
_apply_dim(None)
return
self._viewer_3d.show_preview(result["result_shape"])
# Dim all affected bodies when cut_all_bodies is active.
all_targets = result.get("all_targets", [])
if all_targets:
_apply_dim(all_targets)
else:
_apply_dim(result["target_body"])
dialog.set_preview_callback(_callback)
@@ -3731,10 +4046,13 @@ class MainWindow(QMainWindow):
logger.info("Extrude dialog cancelled")
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(
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
@@ -3751,6 +4069,7 @@ class MainWindow(QMainWindow):
bool(cut),
bool(union),
bool(through_all),
cut_all_bodies=bool(cut_all_bodies),
)
if result is None or result["result_geom"] is None:
logger.warning("Extrude produced no geometry")
@@ -3758,12 +4077,41 @@ class MainWindow(QMainWindow):
return
target = result["target_body"]
if target is not None:
# Cut / union: commit the result onto the *target* body in
# place (don't create a separate tool body — the previous
# implementation did, and that was the user-perceived
# "added without cut" bug once the spurious body was
# deleted).
all_targets = result.get("all_targets", [])
if target is not None and bool(cut) and all_targets:
# Cut all bodies: apply the boolean difference to every body
# in the component that has geometry.
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"]
# Store extrude params so the body can be rebuilt later.
target.extrude_length = length
@@ -3772,6 +4120,7 @@ class MainWindow(QMainWindow):
target.extrude_cut = bool(cut)
target.extrude_union = bool(union)
target.extrude_through_all = bool(through_all)
target.extrude_cut_all_bodies = False
target.extrude_face_index = face_index
target.source_sketch = sketch
target.source_operation = "cut" if cut else "union"
@@ -3797,6 +4146,7 @@ class MainWindow(QMainWindow):
extrude_cut=False,
extrude_union=False,
extrude_through_all=bool(through_all),
extrude_cut_all_bodies=False,
extrude_face_index=face_index,
)
)
@@ -4356,12 +4706,12 @@ class MainWindow(QMainWindow):
# Rebuild component buttons (one per component, with thumbnails).
for idx, comp in enumerate(self._project.components.values(), start=1):
first_body = next(iter(comp.bodies.values()), None)
if first_body and first_body.geometry:
has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
if has_geometry:
btn = _create_component_button(
idx,
comp.name,
first_body,
comp,
self._kernel,
self._component_group,
self._component_box_layout,
@@ -4409,12 +4759,16 @@ class MainWindow(QMainWindow):
btn.clicked.connect(self._on_assembly_component_clicked)
_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)
if src_comp:
first_body = next(iter(src_comp.bodies.values()), None)
if first_body and first_body.geometry:
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
has_geometry = any(
b.visible and b.geometry for b in src_comp.bodies.values()
)
if has_geometry:
pixmap = _make_component_thumbnail(
src_comp, self._kernel, QSize(96, 96)
)
if pixmap is not None:
btn.setIcon(pixmap)
btn.setIconSize(QSize(96, 96))
@@ -4512,7 +4866,7 @@ class MainWindow(QMainWindow):
btn = _create_component_button(
btn_num,
name,
body,
comp,
self._kernel,
self._component_group,
self._component_box_layout,
+371 -86
View File
@@ -185,6 +185,7 @@ class Sketch2DWidget(QWidget):
# Rectangle first-click snap target (stored so the second click
# doesn't overwrite it and the correct corner gets constrained).
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).
self._offset_preview_points: List[Tuple[float, float]] = []
@@ -375,15 +376,18 @@ class Sketch2DWidget(QWidget):
# Clear any prior external entities before importing fresh ones so a
# repeated face pick doesn't pile up duplicate construction lines.
self._sketch.remove_external_entities()
imported = 0
for poly in self._source_underlay_uv:
if len(poly) < 2:
continue
try:
_, lines = self._sketch.add_external_polyline(
# 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]
)
imported += len(lines)
for poly in self._source_underlay_uv
if len(poly) >= 2
]
imported = 0
try:
_, lines = self._sketch.add_external_polylines(polys)
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)
@@ -560,6 +564,7 @@ class Sketch2DWidget(QWidget):
self._mode = None
self._clear_move_state()
self._rect_first_snap_target = None
self._rect_first_line_target = None
self.clear_offset_preview()
self.update()
@@ -597,6 +602,7 @@ class Sketch2DWidget(QWidget):
self._snap_horizontal = False
self._snap_vertical = False
self._rect_first_snap_target = None
self._rect_first_line_target = None
self._arc_accum_sweep = 0.0
self._arc_prev_angle = None
@@ -671,22 +677,16 @@ class Sketch2DWidget(QWidget):
# ─── Snapping ─────────────────────────────────────────────────────────
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
nearest = None
min_dist = max_distance
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
return QPoint(int(round(x)), int(round(y)))
def _find_nearest_point_entity(
self, pos: QPoint, max_distance: int = 15
@@ -721,6 +721,14 @@ class Sketch2DWidget(QWidget):
if not self._snap_mode.get("mpoint", False):
return None
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:
x1, y1 = p1.geometry
x2, y2 = p2.geometry
@@ -731,6 +739,52 @@ class Sketch2DWidget(QWidget):
return mid
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:
if not self._snap_mode.get("angle", False):
return end
@@ -771,6 +825,14 @@ class Sketch2DWidget(QWidget):
mid_snap = self._find_midpoint_snap(pos)
if 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 self._snap_mode.get("horiz", False):
horiz = self._apply_horizontal_snap(start, result)
@@ -860,6 +922,44 @@ class Sketch2DWidget(QWidget):
if nearest is not None:
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
result = pos
if self._snap_mode.get("horiz", False):
@@ -1116,6 +1216,8 @@ class Sketch2DWidget(QWidget):
fm = QFontMetrics(QFont("Monospace", 9))
# Track how many tags already share an anchor so we stack them vertically.
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):
# One bad log entry (e.g. a dangling id after a delete, an
@@ -1128,6 +1230,11 @@ class Sketch2DWidget(QWidget):
params = entry["params"]
anchor: Optional[QPoint] = None
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":
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.
if a is not None and b is not None:
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":
anchor = self._line_world_mid(ids[0])
label = "par"
@@ -1184,6 +1294,13 @@ class Sketch2DWidget(QWidget):
else:
continue
# For distance constraints, allow rendering even without an
# anchor — we only need tag_p1/tag_p2 for the dimension line.
if anchor is None:
if ctype != "distance":
continue
# Use first valid endpoint as fallback anchor for the pill.
anchor = tag_p1 if tag_p1 is not None else tag_p2
if anchor is None:
continue
sc = self._world_to_screen(anchor)
@@ -1194,11 +1311,34 @@ class Sketch2DWidget(QWidget):
text = f"> {label} <"
w = fm.horizontalAdvance(text) + 10
h = 16
# Stack successive tags above the anchor so they don't overlap.
cx = sc.x()
cy = sc.y() - 14 - slot * (h + 2)
# Push pill toward the sketch centroid (inside the closed
# shape) so it sits inside — dimensions are outside.
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)
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:
# Catch any failure while building this one tag (bad
# geometry, missing entity, numpy round weirdness, etc.)
@@ -1817,11 +1957,20 @@ class Sketch2DWidget(QWidget):
self._solve_and_sync()
self._snap_point_target = None
else:
# Auto-constrain: point snap during move → coincident
if self._snap_point_target is not None and self._move_anchor is not None:
self._sketch.constrain_coincident(
self._move_anchor, self._snap_point_target
)
# Auto-constrain: snap → coincident / point-on-line
target = None
if self._snap_point_target is not None:
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()
# Snap modes are honoured during the move (see _apply_move_snaps
# in mouseMoveEvent), so the committed positions are already snapped.
@@ -2100,6 +2249,32 @@ class Sketch2DWidget(QWidget):
# ─── 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):
self._ensure_sketch_with_centerlines()
@@ -2112,10 +2287,8 @@ class Sketch2DWidget(QWidget):
self._points.append(point)
self._draw_buffer.append(pos)
# Auto-constrain: point snap → coincident on start point
if self._snap_point_target is not None:
self._sketch.constrain_coincident(point, self._snap_point_target)
self._solve_and_sync()
# Auto-constrain: snap → coincident / point-on-line on start point
self._auto_constrain_new_point(point, solve=True)
else:
point = self._sketch.add_point(pos.x(), pos.y())
point.is_construction = self._is_construct
@@ -2125,9 +2298,8 @@ class Sketch2DWidget(QWidget):
line = self._sketch.add_line(self._points[-2], self._points[-1])
self._lines.append((self._points[-2], self._points[-1]))
# Auto-constrain: point snap → coincident on end point
if self._snap_point_target is not None:
self._sketch.constrain_coincident(self._points[-1], self._snap_point_target)
# Auto-constrain: snap → coincident / point-on-line on end point
self._auto_constrain_new_point(self._points[-1])
# Auto-constrain: detect horizontal / vertical from geometry
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._draw_buffer.append(pos)
self._rect_first_snap_target = self._snap_point_target
self._rect_first_line_target = self._snap_line_target
else:
p1 = self._draw_buffer[0]
p2 = pos
@@ -2179,13 +2352,13 @@ class Sketch2DWidget(QWidget):
self._lines.append((pts[i], pts[(i + 1) % 4]))
line_entities.append(line)
# Auto-constrain: point snap → coincident on the correct corners.
# pts[0] = first click snapped position
# pts[2] = second click snapped position
# Auto-constrain: snap → coincident / point-on-line on the
# correct corners. pts[0] = first click, pts[2] = second click.
if self._rect_first_snap_target is not None:
self._sketch.constrain_coincident(pts[0], self._rect_first_snap_target)
if self._snap_point_target is not None:
self._sketch.constrain_coincident(pts[2], self._snap_point_target)
elif self._rect_first_line_target is not None:
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
if self._snap_mode.get("horiz", False):
@@ -2225,10 +2398,8 @@ class Sketch2DWidget(QWidget):
self._points.append(center)
self._draw_buffer.append(pos)
# Auto-constrain: point snap → coincident on center
if self._snap_point_target is not None:
self._sketch.constrain_coincident(center, self._snap_point_target)
self._solve_and_sync()
# Auto-constrain: snap → coincident / point-on-line on center
self._auto_constrain_new_point(center, solve=True)
else:
center = self._points[-1]
cx, cy = center.geometry if center.geometry else (0, 0)
@@ -2257,10 +2428,8 @@ class Sketch2DWidget(QWidget):
self._points.append(center)
self._draw_buffer.append(pos)
# Auto-constrain: point snap → coincident on center
if self._snap_point_target is not None:
self._sketch.constrain_coincident(center, self._snap_point_target)
self._solve_and_sync()
# Auto-constrain: snap → coincident / point-on-line on center
self._auto_constrain_new_point(center, solve=True)
elif len(self._draw_buffer) == 1:
# Click 2: place start point (defines radius + start angle)
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_accum_sweep = 0.0
# Auto-constrain: point snap → coincident on start
if self._snap_point_target is not None:
self._sketch.constrain_coincident(start, self._snap_point_target)
# Auto-constrain: snap → coincident / point-on-line on start
self._auto_constrain_new_point(start)
self._solve_and_sync()
else:
@@ -2321,9 +2489,8 @@ class Sketch2DWidget(QWidget):
self._sketch.add_arc(center, radius, start_point, end, sweep=sweep)
self._arcs.append((center, radius, start_point, end, sweep))
# Auto-constrain: point snap → coincident on end point
if self._snap_point_target is not None:
self._sketch.constrain_coincident(end, self._snap_point_target)
# Auto-constrain: snap → coincident / point-on-line on end point
self._auto_constrain_new_point(end)
self._solve_and_sync()
@@ -2352,9 +2519,7 @@ class Sketch2DWidget(QWidget):
self._points.append(c1)
self._draw_buffer.append(pos)
if self._snap_point_target is not None:
self._sketch.constrain_coincident(c1, self._snap_point_target)
self._solve_and_sync()
self._auto_constrain_new_point(c1, solve=True)
self.sketch_updated.emit()
self.update()
@@ -2367,8 +2532,7 @@ class Sketch2DWidget(QWidget):
self._points.append(c2)
self._draw_buffer.append(pos)
if self._snap_point_target is not None:
self._sketch.constrain_coincident(c2, self._snap_point_target)
self._auto_constrain_new_point(c2)
self._solve_and_sync()
self.sketch_updated.emit()
@@ -2850,41 +3014,145 @@ class Sketch2DWidget(QWidget):
def _point_distance(self, p1: QPoint, p2: QPoint) -> float:
return math.sqrt((p1.x() - p2.x()) ** 2 + (p1.y() - p2.y()) ** 2)
def _draw_distance_measurement(self, painter: QPainter, p1: QPoint, p2: QPoint):
"""Draw dimension lines and distance value between two world-coord points."""
def _sketch_centroid_screen(self) -> Optional[QPointF]:
"""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)
sp2 = self._world_to_screen(p2)
dx = sp2.x() - sp1.x()
dy = sp2.y() - sp1.y()
length = math.sqrt(dx * dx + dy * dy)
if length == 0:
screen_len = math.sqrt(dx * dx + dy * dy)
if screen_len < 1:
return
# Perpendicular direction for offset lines
perp_dx = -dy / length
perp_dy = dx / length
offset = 25.0
# Unit vectors along and perpendicular to the dimension line.
ux = dx / screen_len
uy = dy / screen_len
px = -uy
py = ux
p1a = QPointF(sp1.x() + perp_dx, sp1.y() + perp_dy)
p1b = QPointF(sp1.x() + perp_dx * offset, sp1.y() + perp_dy * offset)
p2a = QPointF(sp2.x() + perp_dx, sp2.y() + perp_dy)
p2b = QPointF(sp2.x() + perp_dx * offset, sp2.y() + perp_dy * offset)
mid = QPointF((p1b.x() + p2b.x()) / 2, (p1b.y() + p2b.y()) / 2)
# Flip the perpendicular so the dimension line is on the side
# *away* from the sketch centroid (outside the closed shape).
centroid = self._sketch_centroid_screen()
if centroid is not None:
# 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.drawLine(p1a.toPoint(), p1b.toPoint())
painter.drawLine(p2a.toPoint(), p2b.toPoint())
painter.drawLine(p1b.toPoint(), p2b.toPoint())
# Draw distance text
dist = self._point_distance(p1, p2)
le = QPointF(dmid.x() - ux * (gap / 2), dmid.y() - uy * (gap / 2))
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.translate(mid)
painter.scale(1, -1)
painter.setPen(QPen(QColor("#a6e3a1"), 1))
painter.drawText(0, 0, f"{dist:.2f}")
try:
painter.translate(dmid)
angle = math.degrees(math.atan2(uy, ux))
if angle > 90 or angle < -90:
angle += 180
painter.rotate(angle)
painter.setPen(QPen(color, 1))
f = QFont("Helvetica", 11)
f.setBold(True)
painter.setFont(f)
text = f"{value:.2f} mm"
fm = QFontMetrics(f)
tw = fm.horizontalAdvance(text)
th = fm.height()
painter.drawText(int(-tw / 2), int(th / 3), text)
finally:
painter.restore()
def paintEvent(self, event):
@@ -3097,6 +3365,23 @@ class Sketch2DWidget(QWidget):
painter.setPen(QPen(QColor("#f38ba8") if hovered else QColor("#f9e2af"), 1))
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 ──
for center_ent, radius in self._circles:
if center_ent.geometry:
+130 -115
View File
@@ -1,7 +1,6 @@
"""Tests for Fluency CAD geometry kernel."""
import pytest
import numpy as np
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
@@ -195,10 +194,14 @@ class TestOCCSketch:
sk = OCCSketch()
sk.set_workplane((10.0, 0.0, 5.0), normal, x_dir)
# 20x20 square in UV
p0 = sk.add_point(-10, -10); p1 = sk.add_point(10, -10)
p2 = sk.add_point(10, 10); 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)
p0 = sk.add_point(-10, -10)
p1 = sk.add_point(10, -10)
p2 = sk.add_point(10, 10)
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()
# The face must carry the plane normal for the kernel.
@@ -220,10 +223,14 @@ class TestOCCSketch:
sk = OCCSketch()
sk.set_workplane((0, 0, 0), (0, 0, 1), (1, 0, 0))
a = sk.add_point(-10, -10); b = sk.add_point(10, -10)
c = sk.add_point(10, 10); 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)
a = sk.add_point(-10, -10)
b = sk.add_point(10, -10)
c = sk.add_point(10, 10)
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)
sk.add_circle(ctr, 3.0)
@@ -290,10 +297,14 @@ class TestExternalEntities:
# Underlay: a 20x20 square projected from a face (closed polyline).
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.
a = sk.add_point(2, 2); b = sk.add_point(8, 2)
c = sk.add_point(8, 8); 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)
a = sk.add_point(2, 2)
b = sk.add_point(8, 2)
c = sk.add_point(8, 8)
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()
# Only the user-drawn face (5x5 square) should be detected.
assert len(faces) == 1
@@ -310,10 +321,14 @@ class TestExternalEntities:
def test_external_entities_excluded_from_get_polygon_points(self):
sk = OCCSketch()
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)
c = sk.add_point(2, 2); 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)
a = sk.add_point(1, 1)
b = sk.add_point(2, 1)
c = sk.add_point(2, 2)
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()
# 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)
@@ -328,10 +343,14 @@ class TestExternalEntities:
# Underlay (NOT to be extruded).
sk.add_external_polyline([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
# User profile: a 2x2 square inside the underlay.
a = sk.add_point(1, 1); b = sk.add_point(3, 1)
c = sk.add_point(3, 3); 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)
a = sk.add_point(1, 1)
b = sk.add_point(3, 1)
c = sk.add_point(3, 3)
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()
# Volume = 2 * 2 * 4 = 16, NOT 10 * 10 * 4 = 400.
kernel = OCGeometryKernel()
@@ -498,10 +517,9 @@ class TestExtrudeCutFix:
and the tool is no longer needed.
"""
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.BRepGProp import BRepGProp
import math
k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -511,17 +529,18 @@ class TestExtrudeCutFix:
# expected volume easy to compute.
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism
from OCP.gp import gp_Pnt, gp_Vec
# 20x20 square at (0,0,0), extruded along +Z by 200.
from OCP.BRepBuilderAPI import 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.Close()
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace
face = BRepBuilderAPI_MakeFace(mp.Wire()).Face()
tool_shape = BRepPrimAPI_MakePrism(
face, gp_Vec(0, 0, 200)
).Shape()
tool_shape = BRepPrimAPI_MakePrism(face, gp_Vec(0, 0, 200)).Shape()
tool_obj = OCCGeometryObject(tool_shape, {"type": "prism"})
# 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
# (the prism only intersects the box in z=[0,100], i.e. 100 deep).
g1 = GProp_GProps()
BRepGProp.VolumeProperties_s(
k._get_shape(target_obj_geometry), g1
)
BRepGProp.VolumeProperties_s(k._get_shape(target_obj_geometry), g1)
assert abs(g1.Mass() - 960_000.0) < 1.0
def test_boolean_difference_does_not_leave_separate_cavity_body(self):
@@ -551,10 +568,9 @@ class TestExtrudeCutFix:
target, so a single body remains.
"""
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_SOLID
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.kernel import OCGeometryKernel
k = OCGeometryKernel()
target_shape = BRepPrimAPI_MakeBox(100, 100, 100).Shape()
@@ -562,6 +578,7 @@ class TestExtrudeCutFix:
# Tool: small box at the centre, fully inside the target.
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox as BBox
tool_shape = BBox(20, 20, 20).Shape()
tool_obj = OCCGeometryObject(tool_shape, {})
@@ -595,91 +612,59 @@ class TestBodyVisibilityToggle:
def _make_window(self):
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
return MainWindow()
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
win = self._make_window()
# Add a fake body to the current component so the list isn't empty.
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(), {}
)
box = OCCGeometryObject(BRepPrimAPI_MakeBox(10, 10, 10).Shape(), {})
win._current_component.bodies["a"] = Body(name="A", geometry=box)
win._refresh_lists()
items = win._body_list.findItems("A", Qt.MatchExactly)
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.
assert items[0].data(Qt.UserRole) == "a"
# Default state is checked (= visible).
assert items[0].checkState() == Qt.Checked
# Default state is visible.
assert win._current_component.bodies["a"].visible is True
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
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(), {}
)
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]
# Toggle off.
item.setCheckState(Qt.Unchecked)
win._on_body_visibility_changed(item)
assert win._current_component.bodies["a"].visible is False
# Toggle back on.
item.setCheckState(Qt.Checked)
win._on_body_visibility_changed(item)
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):
import math
return math.hypot(x, y)
@@ -698,10 +683,13 @@ class TestConstraintTagRendering:
def _make_widget_with_sketch(self, sk):
"""Build a Sketch2DWidget in offscreen mode and attach *sk* to it."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import Sketch2DWidget
w = Sketch2DWidget()
w.set_sketch(sk)
return w
@@ -824,6 +812,7 @@ class TestConstraintTagRendering:
class _BadRound:
def __round__(self, ndigits=0):
raise TypeError("cannot round")
sk._entities[c.id].geometry = (_BadRound(), _BadRound())
tags = w._compute_constraint_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):
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
@@ -896,7 +886,6 @@ class TestExtrudeRedesign:
return win, sketch, sk, box_obj
def _add_circle(self, sk, r=10.0):
from fluency.geometry_occ.sketch import OCCSketch
c = sk.add_point(0, 0)
sk.add_circle(c, r)
sk.solve()
@@ -905,6 +894,7 @@ class TestExtrudeRedesign:
def _geometry_volume(self, win, geom):
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp
sh = win._kernel._get_shape(geom)
g = GProp_GProps()
BRepGProp.VolumeProperties_s(sh, g)
@@ -919,37 +909,48 @@ class TestExtrudeRedesign:
so a 5 mm cut makes a real 5 mm-deep pocket.
"""
import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
# Plain cut, length=5, NOT inverted. Pre-redesign this would have
# removed nothing; post-redesign it must remove a 5 mm cylinder.
result = win._compute_extrude_result(
sketch, face_geom,
length=5.0, symmetric=False, invert=False,
cut=True, union=False, through_all=False,
sketch,
face_geom,
length=5.0,
symmetric=False,
invert=False,
cut=True,
union=False,
through_all=False,
)
assert result is not None
assert result["target_body"] is not None
assert result["target_body"].name == "Box1"
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
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
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
sketch, face_geom,
sketch,
face_geom,
length=5.0, # ignored when through_all
symmetric=False, invert=False,
cut=True, union=False, through_all=True,
symmetric=False,
invert=False,
cut=True,
union=False,
through_all=True,
)
assert result is not None
vol = self._geometry_volume(win, result["result_geom"])
# 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
def test_cut_auto_targets_source_body_not_existing_zero(self):
@@ -960,30 +961,23 @@ class TestExtrudeRedesign:
"""
import math
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import MainWindow
from fluency.models.data_model import Sketch, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox
win = MainWindow()
# First body in the dict: a 50-millimetre box ALSO.
first = OCCGeometryObject(
BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {}
)
win._current_component.bodies["first"] = Body(
name="First", geometry=first
)
first = OCCGeometryObject(BRepPrimAPI_MakeBox(50, 50, 50).Shape(), {})
win._current_component.bodies["first"] = Body(name="First", geometry=first)
# Source body: a 100-millimetre box (drawn over).
src = OCCGeometryObject(
BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {}
)
win._current_component.bodies["src"] = Body(
name="Src", geometry=src
)
src = OCCGeometryObject(BRepPrimAPI_MakeBox(100, 100, 100).Shape(), {})
win._current_component.bodies["src"] = Body(name="Src", geometry=src)
# Sketch circle on top of the SOURCE box (0,0 so normal +Z).
sk = OCCSketch()
sk.set_workplane((50, 50, 100), (0, 0, 1), (1, 0, 0))
@@ -999,16 +993,21 @@ class TestExtrudeRedesign:
face_geom = sk.get_geometry()
result = win._compute_extrude_result(
sketch, face_geom,
length=5.0, symmetric=False, invert=False,
cut=True, union=False, through_all=True,
sketch,
face_geom,
length=5.0,
symmetric=False,
invert=False,
cut=True,
union=False,
through_all=True,
)
assert result is not None
# Target is the source box, NOT the dict's first body.
assert result["target_body"].name == "Src"
vol = self._geometry_volume(win, result["result_geom"])
# 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
def test_union_default_builds_outward(self):
@@ -1019,17 +1018,23 @@ class TestExtrudeRedesign:
rather than "subtracting" from the existing box.
"""
import math
win, sketch, sk, box_obj = self._make_window_with_box(100.0)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
sketch, face_geom,
length=10.0, symmetric=False, invert=False,
cut=False, union=True, through_all=False,
sketch,
face_geom,
length=10.0,
symmetric=False,
invert=False,
cut=False,
union=True,
through_all=False,
)
assert result is not None
vol = self._geometry_volume(win, result["result_geom"])
# 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
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)
face_geom = self._add_circle(sk, r=10.0)
result = win._compute_extrude_result(
sketch, face_geom,
length=10.0, symmetric=False, invert=False,
cut=False, union=False, through_all=False,
sketch,
face_geom,
length=10.0,
symmetric=False,
invert=False,
cut=False,
union=False,
through_all=False,
)
assert result is not None
# No boolean target; result is the standalone tool extrusion.
@@ -1047,7 +1057,8 @@ class TestExtrudeRedesign:
vol = self._geometry_volume(win, result["result_geom"])
# Standalone cylinder 10 mm tall.
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):
"""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
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)
# Simulate _on_face_picked by calling it through a fake face
# 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
# the list after _refresh_lists + setCurrentRow.
from fluency.models.data_model import Sketch
sketch = Sketch(name="Sketch on face 99")
sketch._source_body_id = "b1"
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):
"""The live preview callback fires on spinbox/checkbox changes."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog
@@ -1110,8 +1123,10 @@ class TestExtrudeRedesign:
def test_preview_hidden_event_sends_none(self):
"""hideEvent should deliver None to the callback so the host clears."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
from fluency.main import ExtrudeDialog