1 Commits

Author SHA1 Message Date
bklronin 0daa6152ee - added "measurement lines" 2026-07-26 21:39:47 +02:00
12 changed files with 3100 additions and 1905 deletions
+15 -33
View File
@@ -9,8 +9,13 @@
<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/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/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" />
@@ -27,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$" />
@@ -54,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",
@@ -62,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",
@@ -69,7 +76,6 @@
"Python.side_fluency.executor": "Run",
"Python.simple_mesh.executor": "Run",
"Python.sketch.executor": "Run",
"Python.surface_modifier.executor": "Run",
"Python.vtk_widget.executor": "Run",
"Python.vulkan.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
@@ -78,8 +84,8 @@
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"codeWithMe.voiceChat.enabledByDefault": "false",
"git-widget-placeholder": "feature/surface-modifier-pyramid-patterns",
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
"git-widget-placeholder": "feature/occ-migration",
"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)",
@@ -94,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>
@@ -440,31 +446,7 @@
<option name="project" value="LOCAL" />
<updated>1783976498520</updated>
</task>
<task id="LOCAL-00041" summary="Improved render previews">
<option name="closed" value="true" />
<created>1784408804224</created>
<option name="number" value="00041" />
<option name="presentableId" value="LOCAL-00041" />
<option name="project" value="LOCAL" />
<updated>1784408804224</updated>
</task>
<task id="LOCAL-00042" summary="- added contrain context menu&#10;- improved line pickability.">
<option name="closed" value="true" />
<created>1784452692776</created>
<option name="number" value="00042" />
<option name="presentableId" value="LOCAL-00042" />
<option name="project" value="LOCAL" />
<updated>1784452692776</updated>
</task>
<task id="LOCAL-00043" summary="- added contrain context menu&#10;- improved line pickability.">
<option name="closed" value="true" />
<created>1784470032428</created>
<option name="number" value="00043" />
<option name="presentableId" value="LOCAL-00043" />
<option name="project" value="LOCAL" />
<updated>1784470032428</updated>
</task>
<option name="localTasksCounter" value="44" />
<option name="localTasksCounter" value="41" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -485,6 +467,8 @@
<ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots>
<MESSAGE value="- Sketch projection partly works again :)" />
<MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" />
<MESSAGE value="- Drawing bodys depending on the selected compo&#10;- Cut working&#10;- Edit sketch working" />
@@ -508,8 +492,6 @@
<MESSAGE value="- added renderer" />
<MESSAGE value="- added renderer&#10;- Added undo" />
<MESSAGE value="- Render improvements, camera plane, update" />
<MESSAGE value="Improved render previews" />
<MESSAGE value="- added contrain context menu&#10;- improved line pickability." />
<option name="LAST_COMMIT_MESSAGE" value="- added contrain context menu&#10;- improved line pickability." />
<option name="LAST_COMMIT_MESSAGE" value="- Render improvements, camera plane, update" />
</component>
</project>
+747 -757
View File
File diff suppressed because it is too large Load Diff
+512 -519
View File
File diff suppressed because it is too large Load Diff
+87 -64
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,
@@ -780,67 +867,3 @@ class OCGeometryKernel(GeometryKernel):
cg = props.CentreOfMass()
return Point3D(cg.X(), cg.Y(), cg.Z())
def apply_surface_modifier(
self,
body_geometry: GeometryObject,
modifier_type: str = 'pyramid',
height: float = 2.0,
radius: float = 3.0,
spacing: float = 8.0,
num_rings: int = 3,
) -> Optional[GeometryObject]:
"""Apply a surface modifier to a body geometry.
Args:
body_geometry: The OCCGeometryObject to modify
modifier_type: Type of modifier ('pyramid', 'bump')
height: Height/depth of the pattern features
radius: Base radius of pattern features
spacing: Distance between pattern features
num_rings: Number of concentric rings
Returns:
Modified geometry, or None on failure
"""
from fluency.geometry_occ.surface_modifier import SurfaceModifier
shape = self._get_shape(body_geometry)
if shape is None:
logger.error("No geometry found in body")
return None
modifier = SurfaceModifier()
try:
if modifier_type == 'pyramid':
result_shape = modifier.apply_pyramid_pattern(
shape,
pyramid_height=height,
base_radius=radius,
spacing=spacing,
num_rings=num_rings,
direction=(0, 0, 1),
)
elif modifier_type == 'bump':
result_shape = modifier.apply_bump_pattern(
shape,
bump_height=height,
bump_radius=radius,
spacing=spacing,
num_rings=num_rings,
)
else:
logger.error(f"Unknown modifier type: {modifier_type}")
return None
if result_shape is None:
logger.error("Surface modifier application failed")
return None
# Return the modified shape wrapped in OCCGeometryObject
return OCCGeometryObject(result_shape)
except Exception as e:
logger.error(f"Error applying surface modifier: {e}", exc_info=True)
return None
+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
+32 -102
View File
@@ -11,7 +11,6 @@ from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QDoubleSpinBox,
QFormLayout,
QFrame,
QGridLayout,
QHBoxLayout,
@@ -24,6 +23,7 @@ from PySide6.QtWidgets import (
logger = logging.getLogger(__name__)
class ExtrudeDialog(QDialog):
"""Dialog for extrude options.
@@ -71,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)
@@ -98,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.
@@ -139,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(),
@@ -147,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(),
)
@@ -260,95 +269,6 @@ class OffsetDialog(QDialog):
return (self.distance_input.value(), self.inward_checkbox.isChecked())
class SurfaceModifierDialog(QDialog):
"""Dialog for surface modifier settings."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Surface Modifier")
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
# Pattern type selection
pattern_layout = QGridLayout()
self._pattern_group = QButtonGroup(self)
patterns = [
("Pyramid Pattern", "pyramid"),
("Bump Pattern", "bump"),
]
for idx, (label, pattern_type) in enumerate(patterns):
btn = QRadioButton(label)
btn.setChecked(idx == 0)
self._pattern_group.addButton(btn, idx)
btn.pattern_type = pattern_type
pattern_layout.addWidget(btn, 0, idx)
layout.addLayout(pattern_layout)
# Parameters for pyramid/bump patterns
param_layout = QFormLayout()
self.height_input = QDoubleSpinBox()
self.height_input.setDecimals(2)
self.height_input.setRange(0.1, 50.0)
self.height_input.setValue(2.0)
self.height_input.setSuffix(" mm")
param_layout.addRow("Height:", self.height_input)
self.radius_input = QDoubleSpinBox()
self.radius_input.setDecimals(2)
self.radius_input.setRange(0.5, 20.0)
self.radius_input.setValue(3.0)
self.radius_input.setSuffix(" mm")
param_layout.addRow("Base Radius:", self.radius_input)
self.spacing_input = QDoubleSpinBox()
self.spacing_input.setDecimals(1)
self.spacing_input.setRange(1.0, 50.0)
self.spacing_input.setValue(8.0)
self.spacing_input.setSuffix(" mm")
param_layout.addRow("Spacing:", self.spacing_input)
layout.addLayout(param_layout)
# Number of rings
self.rings_input = QSpinBox()
self.rings_input.setMinimum(1)
self.rings_input.setMaximum(20)
self.rings_input.setValue(3)
param_layout.addRow("Number of Rings:", self.rings_input)
line = QFrame()
line.setFrameShape(QFrame.HLine)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("Apply")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
def get_values(self) -> dict:
"""Return the modifier settings."""
pattern_type = None
for btn in self._pattern_group.buttons():
if btn.isChecked():
pattern_type = getattr(btn, 'pattern_type', None)
break
return {
'type': pattern_type or 'pyramid',
'height': self.height_input.value(),
'radius': self.radius_input.value(),
'spacing': self.spacing_input.value(),
'num_rings': self.rings_input.value(),
}
class WorkplaneOrientationDialog(QDialog):
"""Modal dialog to choose the orientation of a new workplane.
@@ -496,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
@@ -542,17 +466,21 @@ class WorkplaneOrientationDialog(QDialog):
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])
@@ -574,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",
)
+407 -106
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))
@@ -717,8 +871,6 @@ class MainWindow(QMainWindow):
self._btn_move = ui.pb_moveop
self._btn_revolve = ui.pb_revop
self._btn_array = ui.pb_arrayop
# ── Surface Modifier ──
self._btn_surface_mod = ui.pb_surface_mod
# ── Export ──
self._btn_export_stl = ui.pushButton_2
self._btn_export_step = ui.pb_export_step
@@ -729,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
@@ -774,14 +926,14 @@ class MainWindow(QMainWindow):
if panel == "viewer":
# Viewer 2/3, sketch 1/3 — more room for 3D work, sketch stays visible.
self._grid.setColumnStretch(1, 1)
self._grid.setColumnStretch(3, 2)
self._grid.setColumnStretch(2, 2)
elif panel == "sketch":
# Sketch 2/3, viewer 1/3 — comfortable sketching, 3D stays visible.
self._grid.setColumnStretch(1, 2)
self._grid.setColumnStretch(3, 1)
self._grid.setColumnStretch(2, 1)
else: # equal
self._grid.setColumnStretch(1, 1)
self._grid.setColumnStretch(3, 1)
self._grid.setColumnStretch(2, 1)
logger.info(f"Panel focus -> {self._panel_focus}")
def keyPressEvent(self, event):
@@ -834,7 +986,6 @@ class MainWindow(QMainWindow):
self._btn_cut.clicked.connect(self._boolean_cut)
self._btn_combine.clicked.connect(self._boolean_union)
self._btn_revolve.clicked.connect(self._revolve_sketch)
self._btn_surface_mod.clicked.connect(self._open_surface_modifier_dialog)
self._btn_add_sketch.clicked.connect(self._add_sketch_to_component)
self._btn_edit_sketch.clicked.connect(self._edit_sketch)
@@ -894,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))
@@ -1001,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))
@@ -1079,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"))
@@ -1106,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()
@@ -1175,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:
@@ -1218,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.
@@ -1393,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))
@@ -3228,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
@@ -3455,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
@@ -3469,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:
@@ -3489,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(
@@ -3561,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.
@@ -3569,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
@@ -3580,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
@@ -3620,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 {
@@ -3628,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:
@@ -3642,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()
@@ -3666,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,
@@ -3676,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)
@@ -3734,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
@@ -3754,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")
@@ -3761,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
@@ -3775,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"
@@ -3800,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,
)
)
@@ -3997,56 +4344,6 @@ class MainWindow(QMainWindow):
logger.exception(f"Boolean union failed: {e}")
QMessageBox.critical(self, "Error", f"Boolean union failed: {e}")
def _open_surface_modifier_dialog(self):
"""Open the surface modifier dialog and apply to selected body."""
if not self._current_component or not self._selected_body:
QMessageBox.warning(self, "No Selection", "Please select a body first")
return
# Create and show the dialog
dialog = SurfaceModifierDialog(self)
accepted = dialog.exec()
if not accepted:
logger.info("Surface modifier dialog cancelled")
return
# Get the settings from the dialog
settings = dialog.get_values()
logger.info(f"Surface modifier settings: {settings}")
try:
# Apply the surface modifier to the selected body
result_geom = self._kernel.apply_surface_modifier(
self._selected_body.geometry,
**settings
)
if result_geom is not None:
# Update the body geometry
self._selected_body.geometry = result_geom
self._mark_dirty()
logger.debug("Adding shape to OCC viewer")
shape = self._kernel._get_shape(result_geom)
self._selected_body.render_object = self._viewer_3d.show_shape(
shape, self._selected_body.color, self._selected_body.name
)
logger.info(f"Render object: {self._selected_body.render_object}")
self._refresh_lists()
self._update_component_thumbnail(self._get_active_component_index())
self._viewer_3d.fit_camera()
logger.info("Surface modifier applied successfully")
else:
QMessageBox.warning(
self, "Error", "Failed to apply surface modifier"
)
except Exception as e:
logger.exception(f"Surface modifier failed: {e}")
QMessageBox.critical(self, "Error", f"Surface modifier failed: {e}")
def _delete_body(self):
selected = self._body_list.currentItem()
if not selected or not self._current_component:
@@ -4409,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,
@@ -4462,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))
@@ -4565,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