- added "measurement lines"
This commit is contained in:
Generated
+11
-7
@@ -4,16 +4,18 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- Render improvements, camera plane, update">
|
||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added contrain context menu - improved line pickability.">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/gui.ui" beforeDir="false" afterPath="$PROJECT_DIR$/gui.ui" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/gui_ui.py" beforeDir="false" afterPath="$PROJECT_DIR$/gui_ui.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/mitsuba_backend.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/tests/test_geometry.py" beforeDir="false" afterPath="$PROJECT_DIR$/tests/test_geometry.py" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -30,7 +32,7 @@
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||
<map>
|
||||
<entry key="$PROJECT_DIR$" value="single_window" />
|
||||
<entry key="$PROJECT_DIR$" value="feature/surface-modifier-pyramid-patterns" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
@@ -57,6 +59,7 @@
|
||||
"Python.Unnamed.executor": "Run",
|
||||
"Python.base.executor": "Run",
|
||||
"Python.data_model.executor": "Run",
|
||||
"Python.debug_dragging.executor": "Run",
|
||||
"Python.draw_widget2d.executor": "Run",
|
||||
"Python.draw_widget_solve.executor": "Run",
|
||||
"Python.fluency.executor": "Run",
|
||||
@@ -65,6 +68,7 @@
|
||||
"Python.gui_ui.executor": "Run",
|
||||
"Python.kernel.executor": "Run",
|
||||
"Python.main.executor": "Run",
|
||||
"Python.main_window.executor": "Run",
|
||||
"Python.meshtest.executor": "Run",
|
||||
"Python.occ_renderer.executor": "Run",
|
||||
"Python.occ_to_mesh.executor": "Run",
|
||||
@@ -81,7 +85,7 @@
|
||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||
"codeWithMe.voiceChat.enabledByDefault": "false",
|
||||
"git-widget-placeholder": "feature/occ-migration",
|
||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
|
||||
"last_opened_file_path": "/Volumes/Data_drive/Programming/fluency/src/fluency",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
@@ -96,9 +100,9 @@
|
||||
</component>
|
||||
<component name="RecentsManager">
|
||||
<key name="CopyFile.RECENT_KEYS">
|
||||
<recent name="$PROJECT_DIR$/src/fluency" />
|
||||
<recent name="$PROJECT_DIR$" />
|
||||
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
|
||||
<recent name="$PROJECT_DIR$/src/fluency" />
|
||||
<recent name="$PROJECT_DIR$/drawing_modules" />
|
||||
<recent name="$PROJECT_DIR$/modules" />
|
||||
</key>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ class Body:
|
||||
extrude_cut: bool = False
|
||||
extrude_union: bool = False
|
||||
extrude_through_all: bool = False
|
||||
extrude_cut_all_bodies: bool = False # cut through all bodies in component
|
||||
extrude_face_index: Optional[int] = None # which sketch face was selected
|
||||
extrude_target_body_id: Optional[str] = None # for cut/union: target body id
|
||||
needs_update: bool = False # True when source sketch changed since last extrude
|
||||
|
||||
+33
-21
@@ -4,18 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, QPoint, QPointF
|
||||
from PySide6.QtGui import QColor, QFont, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
@@ -24,11 +19,11 @@ from PySide6.QtWidgets import (
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExtrudeDialog(QDialog):
|
||||
"""Dialog for extrude options.
|
||||
|
||||
@@ -76,6 +71,13 @@ class ExtrudeDialog(QDialog):
|
||||
)
|
||||
layout.addWidget(self.through_all_checkbox)
|
||||
|
||||
self.cut_all_bodies_checkbox = QCheckBox("Cut All Bodies")
|
||||
self.cut_all_bodies_checkbox.setToolTip(
|
||||
"Apply the boolean cut to every body in the current component, "
|
||||
"not just the one the sketch was drawn on. Requires Perform Cut."
|
||||
)
|
||||
layout.addWidget(self.cut_all_bodies_checkbox)
|
||||
|
||||
self.rounded_checkbox = QCheckBox("Round Edges")
|
||||
layout.addWidget(self.rounded_checkbox)
|
||||
|
||||
@@ -103,6 +105,7 @@ class ExtrudeDialog(QDialog):
|
||||
self.cut_checkbox,
|
||||
self.union_checkbox,
|
||||
self.through_all_checkbox,
|
||||
self.cut_all_bodies_checkbox,
|
||||
self.rounded_checkbox,
|
||||
):
|
||||
# The spinbox has valueChanged; the checkboxes have stateChanged.
|
||||
@@ -144,7 +147,7 @@ class ExtrudeDialog(QDialog):
|
||||
pass
|
||||
super().hideEvent(event)
|
||||
|
||||
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool]:
|
||||
def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool, bool]:
|
||||
return (
|
||||
self.length_input.value(),
|
||||
self.symmetric_checkbox.isChecked(),
|
||||
@@ -152,6 +155,7 @@ class ExtrudeDialog(QDialog):
|
||||
self.cut_checkbox.isChecked(),
|
||||
self.union_checkbox.isChecked(),
|
||||
self.through_all_checkbox.isChecked(),
|
||||
self.cut_all_bodies_checkbox.isChecked(),
|
||||
self.rounded_checkbox.isChecked(),
|
||||
)
|
||||
|
||||
@@ -404,7 +408,6 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
def _on_ok(self):
|
||||
"""Compute the final orientation and accept."""
|
||||
import numpy as np
|
||||
import math
|
||||
|
||||
if self._custom_radio.isChecked():
|
||||
# Custom: start from XY normal and rotate by the two angles.
|
||||
@@ -413,18 +416,22 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
# Start from +Z normal, rotate around X then Y
|
||||
n = np.array([0.0, 0.0, 1.0])
|
||||
# Rotate around X
|
||||
rx = np.array([
|
||||
rx = np.array(
|
||||
[
|
||||
[1, 0, 0],
|
||||
[0, math.cos(ax), -math.sin(ax)],
|
||||
[0, math.sin(ax), math.cos(ax)],
|
||||
])
|
||||
]
|
||||
)
|
||||
n = rx @ n
|
||||
# Rotate around Y
|
||||
ry = np.array([
|
||||
ry = np.array(
|
||||
[
|
||||
[math.cos(ay), 0, math.sin(ay)],
|
||||
[0, 1, 0],
|
||||
[-math.sin(ay), 0, math.cos(ay)],
|
||||
])
|
||||
]
|
||||
)
|
||||
n = ry @ n
|
||||
n = n / np.linalg.norm(n)
|
||||
# x_dir: cross product of normal with world Y, or world Z if normal ~ Y
|
||||
@@ -454,23 +461,26 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
whether called before or after ``_on_ok``.
|
||||
"""
|
||||
import numpy as np
|
||||
import math
|
||||
|
||||
if self._custom_radio.isChecked():
|
||||
ax = math.radians(self._angle_x.value())
|
||||
ay = math.radians(self._angle_y.value())
|
||||
n = np.array([0.0, 0.0, 1.0])
|
||||
rx = np.array([
|
||||
rx = np.array(
|
||||
[
|
||||
[1, 0, 0],
|
||||
[0, math.cos(ax), -math.sin(ax)],
|
||||
[0, math.sin(ax), math.cos(ax)],
|
||||
])
|
||||
]
|
||||
)
|
||||
n = rx @ n
|
||||
ry = np.array([
|
||||
ry = np.array(
|
||||
[
|
||||
[math.cos(ay), 0, math.sin(ay)],
|
||||
[0, 1, 0],
|
||||
[-math.sin(ay), 0, math.cos(ay)],
|
||||
])
|
||||
]
|
||||
)
|
||||
n = ry @ n
|
||||
n = n / np.linalg.norm(n)
|
||||
world_y = np.array([0.0, 1.0, 0.0])
|
||||
@@ -492,6 +502,8 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
if btn is not None:
|
||||
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
|
||||
# Fallback: XY default.
|
||||
return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
|
||||
|
||||
|
||||
return (
|
||||
(0.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
self._name_input.text().strip() or "Workplane",
|
||||
)
|
||||
|
||||
+404
-50
@@ -183,6 +183,157 @@ def _make_body_thumbnail(
|
||||
return None
|
||||
|
||||
|
||||
def _make_component_thumbnail(
|
||||
component,
|
||||
kernel,
|
||||
size: QSize = QSize(96, 96),
|
||||
):
|
||||
"""Render a small isometric thumbnail of all bodies in a component.
|
||||
|
||||
Combines the meshes of all visible bodies and renders them together.
|
||||
Returns a QPixmap or None on failure.
|
||||
"""
|
||||
try:
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from PySide6.QtGui import QImage, QPixmap
|
||||
|
||||
# Collect meshes from all visible bodies with geometry
|
||||
all_verts = []
|
||||
all_faces = []
|
||||
vertex_offset = 0
|
||||
|
||||
for body in component.bodies.values():
|
||||
if not body.visible or not body.geometry:
|
||||
continue
|
||||
verts, faces = body.get_mesh(kernel)
|
||||
if verts is None or len(verts) == 0:
|
||||
continue
|
||||
|
||||
verts = np.asarray(verts, dtype=np.float64)
|
||||
faces = np.asarray(faces, dtype=np.int32)
|
||||
|
||||
# Apply body transform
|
||||
if hasattr(body, "position") and body.position is not None:
|
||||
if hasattr(body, "rotation") and body.rotation is not None:
|
||||
verts = verts @ body.rotation.T
|
||||
verts = verts + body.position
|
||||
|
||||
all_verts.append(verts)
|
||||
all_faces.append(faces + vertex_offset)
|
||||
vertex_offset += len(verts)
|
||||
|
||||
if not all_verts:
|
||||
return None
|
||||
|
||||
verts = np.concatenate(all_verts, axis=0)
|
||||
faces = np.concatenate(all_faces, axis=0)
|
||||
|
||||
mins = verts.min(axis=0)
|
||||
maxs = verts.max(axis=0)
|
||||
center = (mins + maxs) / 2
|
||||
extent = maxs - mins
|
||||
max_dim = max(extent)
|
||||
if max_dim < 1e-10:
|
||||
return None
|
||||
|
||||
# Normalize vertices to [-1, 1] range centered at origin
|
||||
v = (verts - center) / (max_dim * 0.7)
|
||||
|
||||
# Simple isometric projection (rotation + orthographic)
|
||||
angle_y = np.radians(-45)
|
||||
angle_x = np.radians(25)
|
||||
cos_y, sin_y = np.cos(angle_y), np.sin(angle_y)
|
||||
cos_x, sin_x = np.cos(angle_x), np.sin(angle_x)
|
||||
|
||||
# Rotate Y
|
||||
x1 = v[:, 0] * cos_y - v[:, 2] * sin_y
|
||||
z1 = v[:, 0] * sin_y + v[:, 2] * cos_y
|
||||
y1 = v[:, 1]
|
||||
|
||||
# Rotate X
|
||||
y2 = y1 * cos_x - z1 * sin_x
|
||||
z2 = y1 * sin_x + z1 * cos_x
|
||||
x2 = x1
|
||||
|
||||
# Project to 2D (orthographic)
|
||||
w_px, h_px = size.width(), size.height()
|
||||
|
||||
# Compute 2D bounding box of projected vertices for tight framing
|
||||
all_px = x2
|
||||
all_py = -y2
|
||||
px_min, px_max = all_px.min(), all_px.max()
|
||||
py_min, py_max = all_py.min(), all_py.max()
|
||||
span_x = px_max - px_min
|
||||
span_y = py_max - py_min
|
||||
if span_x < 1e-10 or span_y < 1e-10:
|
||||
return None
|
||||
|
||||
# Scale to fill ~90% of the image
|
||||
margin = 0.10
|
||||
avail_w = w_px * (1.0 - margin)
|
||||
avail_h = h_px * (1.0 - margin)
|
||||
scale = min(avail_w / span_x, avail_h / span_y)
|
||||
|
||||
center_x = (px_min + px_max) / 2
|
||||
center_y = (py_min + py_max) / 2
|
||||
px = (all_px * scale + w_px / 2 - center_x * scale).astype(np.float64)
|
||||
py = (all_py * scale + h_px / 2 - center_y * scale).astype(np.float64)
|
||||
depth = z2 # for painter's algorithm
|
||||
|
||||
# Dark grey background
|
||||
img = Image.new("RGBA", (w_px, h_px), (55, 55, 60, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Compute face normals for backface culling & shading
|
||||
v0 = np.stack([px[faces[:, 0]], py[faces[:, 0]], depth[faces[:, 0]]], axis=1)
|
||||
v1 = np.stack([px[faces[:, 1]], py[faces[:, 1]], depth[faces[:, 1]]], axis=1)
|
||||
v2 = np.stack([px[faces[:, 2]], py[faces[:, 2]], depth[faces[:, 2]]], axis=1)
|
||||
|
||||
# 2D cross product for winding
|
||||
cross = (v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) - (v1[:, 1] - v0[:, 1]) * (
|
||||
v2[:, 0] - v0[:, 0]
|
||||
)
|
||||
|
||||
# Average depth per face
|
||||
avg_depth = (v0[:, 2] + v1[:, 2] + v2[:, 2]) / 3.0
|
||||
|
||||
# Sort faces by depth (painter's algorithm: draw far faces first)
|
||||
order = np.argsort(-avg_depth)
|
||||
|
||||
# Ceramic white body with shading
|
||||
base_r, base_g, base_b = 220, 218, 215
|
||||
|
||||
for i in order:
|
||||
# Backface culling
|
||||
if cross[i] <= 0:
|
||||
continue
|
||||
|
||||
pts = [
|
||||
(float(px[faces[i, 0]]), float(py[faces[i, 0]])),
|
||||
(float(px[faces[i, 1]]), float(py[faces[i, 1]])),
|
||||
(float(px[faces[i, 2]]), float(py[faces[i, 2]])),
|
||||
]
|
||||
|
||||
# Shading: stronger contrast for depth perception
|
||||
brightness = 0.5 + 0.5 * max(0.0, min(1.0, (avg_depth[i] + 1) / 2))
|
||||
r = int(base_r * brightness)
|
||||
g = int(base_g * brightness)
|
||||
b = int(base_b * brightness)
|
||||
|
||||
draw.polygon(pts, fill=(r, g, b, 255))
|
||||
|
||||
# Convert PIL image to QPixmap
|
||||
data = img.tobytes("raw", "RGBA")
|
||||
qimg = QImage(data, w_px, h_px, w_px * 4, QImage.Format_RGBA8888)
|
||||
pixmap = QPixmap.fromImage(qimg.copy())
|
||||
return pixmap
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Component thumbnail generation failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── Button sizing & styling constants ────────────────────────────────
|
||||
_BTN_MIN = 40 # minimum button dimension (px)
|
||||
_BTN_MAX = 160 # maximum button dimension (px)
|
||||
@@ -246,7 +397,7 @@ def _scroll_to_button(btn: QPushButton, scroll_area: QScrollArea) -> None:
|
||||
def _create_component_button(
|
||||
num: int,
|
||||
name: str,
|
||||
body,
|
||||
component,
|
||||
kernel,
|
||||
group: QButtonGroup,
|
||||
layout: QHBoxLayout,
|
||||
@@ -261,7 +412,10 @@ def _create_component_button(
|
||||
btn.clicked.connect(click_handler)
|
||||
_set_button_style(btn)
|
||||
|
||||
pixmap = _make_body_thumbnail(body, kernel, QSize(96, 96))
|
||||
# Render thumbnail from all bodies in the component
|
||||
has_geometry = any(b.visible and b.geometry for b in component.bodies.values())
|
||||
if has_geometry:
|
||||
pixmap = _make_component_thumbnail(component, kernel, QSize(96, 96))
|
||||
if pixmap is not None:
|
||||
btn.setIcon(pixmap)
|
||||
btn.setIconSize(QSize(96, 96))
|
||||
@@ -727,7 +881,7 @@ class MainWindow(QMainWindow):
|
||||
self._btn_del_sketch = ui.pb_del_sketch
|
||||
# ── Body tools ──
|
||||
self._btn_update_body = ui.pb_update_body
|
||||
self._btn_edit_sketch_3 = ui.pb_edt_sktch_3
|
||||
self._btn_body_hide = ui.pb_body_hide
|
||||
self._btn_del_body = ui.pb_del_body
|
||||
# ── Component tools ──
|
||||
self._btn_new_compo = ui.pb_new_compo
|
||||
@@ -891,7 +1045,10 @@ class MainWindow(QMainWindow):
|
||||
self._btn_move.clicked.connect(self._translate_body)
|
||||
self._btn_array.clicked.connect(self._pattern_array)
|
||||
self._btn_offset.clicked.connect(self._offset_sketch)
|
||||
self._btn_edit_sketch_3.clicked.connect(self._edit_sketch)
|
||||
# Per-body hide/show toggle: the user clicks pb_body_hide next
|
||||
# to a body name in the right-hand list. We update the body's
|
||||
# ``visible`` flag and ask the viewer to show/hide the mesh.
|
||||
self._btn_body_hide.clicked.connect(self._on_body_hide_button_clicked)
|
||||
|
||||
# Snap toggle
|
||||
self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c))
|
||||
@@ -998,11 +1155,12 @@ class MainWindow(QMainWindow):
|
||||
if component_index >= len(comp_ids):
|
||||
return
|
||||
comp = self._project.components[comp_ids[component_index]]
|
||||
first_body = next(iter(comp.bodies.values()), None)
|
||||
if not first_body or not first_body.geometry:
|
||||
# Check if component has any bodies with geometry
|
||||
has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
|
||||
if not has_geometry:
|
||||
return
|
||||
btn = self._component_buttons[component_index]
|
||||
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
|
||||
pixmap = _make_component_thumbnail(comp, self._kernel, QSize(96, 96))
|
||||
if pixmap is not None:
|
||||
btn.setIcon(pixmap)
|
||||
btn.setIconSize(QSize(96, 96))
|
||||
@@ -1076,18 +1234,13 @@ class MainWindow(QMainWindow):
|
||||
self._sketch_list.addItem(sketch.name)
|
||||
|
||||
for body_id, body in self._current_component.bodies.items():
|
||||
# QListWidgetItem with a checkbox so the user can toggle
|
||||
# each body's visibility in the 3D viewer. The item's
|
||||
# data role stores the body id so the toggle handler can
|
||||
# QListWidgetItem with a data role so the toggle handler can
|
||||
# look up the right body without relying on display text.
|
||||
display_name = body.name
|
||||
if body.needs_update:
|
||||
display_name = f"⚠ {body.name}"
|
||||
item = QListWidgetItem(display_name)
|
||||
item.setData(Qt.UserRole, body_id)
|
||||
# Qt.Checked = visible, Qt.Unchecked = hidden. Default
|
||||
# is whatever the body model says.
|
||||
item.setCheckState(Qt.Checked if body.visible else Qt.Unchecked)
|
||||
# Greying out a hidden body's name is a nice UX touch.
|
||||
if not body.visible:
|
||||
item.setForeground(QColor("#6c7086"))
|
||||
@@ -1103,6 +1256,8 @@ class MainWindow(QMainWindow):
|
||||
changes to all assembly instances, and recalculates connectors.
|
||||
"""
|
||||
self._update_bodies_from_sketch()
|
||||
self._update_sketches_from_bodies()
|
||||
self._update_bodies_from_sketch() # re-extrude bodies whose sketches just moved
|
||||
self._redraw_bodies()
|
||||
self._propagate_to_assembly()
|
||||
self._recalculate_connectors()
|
||||
@@ -1172,6 +1327,49 @@ class MainWindow(QMainWindow):
|
||||
target = b
|
||||
break
|
||||
|
||||
# Handle cut_all_bodies: apply the cut to every body in the
|
||||
# component, not just the target.
|
||||
if body.extrude_cut and body.extrude_cut_all_bodies:
|
||||
try:
|
||||
if body.extrude_through_all and target is not None:
|
||||
cut_length = self._through_all_length(target, sketch)
|
||||
cut_symmetric = True
|
||||
cut_invert = False
|
||||
else:
|
||||
cut_length = body.extrude_length or 10.0
|
||||
cut_symmetric = body.extrude_symmetric
|
||||
cut_invert = body.extrude_invert
|
||||
tool_geom = self._kernel.extrude(
|
||||
face_geom,
|
||||
-cut_length if cut_invert else cut_length,
|
||||
symmetric=cut_symmetric,
|
||||
)
|
||||
if tool_geom is None:
|
||||
logger.warning(f"Body '{body.name}': cut-all tool geometry is empty")
|
||||
continue
|
||||
cut_count = 0
|
||||
for other_id, other in list(self._current_component.bodies.items()):
|
||||
if other.geometry is None:
|
||||
continue
|
||||
try:
|
||||
other.geometry = self._kernel.boolean_difference(
|
||||
other.geometry, tool_geom
|
||||
)
|
||||
other.needs_update = False
|
||||
other.modified_at = datetime.now()
|
||||
cut_count += 1
|
||||
except Exception:
|
||||
pass # body doesn't intersect tool, skip
|
||||
body.needs_update = False
|
||||
body.modified_at = datetime.now()
|
||||
updated += 1
|
||||
logger.info(
|
||||
f"Re-extruded cut-all body '{body.name}': cut {cut_count} body(ies)"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(f"Body '{body.name}': re-extrude cut-all failed: {exc}")
|
||||
continue # skip the single-target path below
|
||||
|
||||
# Compute the new result.
|
||||
try:
|
||||
if body.extrude_through_all and target is not None:
|
||||
@@ -1215,6 +1413,87 @@ class MainWindow(QMainWindow):
|
||||
if updated > 0:
|
||||
logger.info(f"Updated {updated} body(ies) from sketch")
|
||||
|
||||
def _update_sketches_from_bodies(self) -> None:
|
||||
"""Re-project underlay construction lines from updated 3D bodies.
|
||||
|
||||
For every sketch in the current component that carries a
|
||||
``_source_face`` (a face-projected underlay) and a
|
||||
``_source_body_id``, find the corresponding face on the updated
|
||||
body geometry, re-project its edges to UV, and update the sketch's
|
||||
external entities *in place* (preserving entity ids so existing
|
||||
constraints survive). The solver is re-run so any user geometry
|
||||
anchored to the underlay follows the body.
|
||||
"""
|
||||
if not self._current_component:
|
||||
return
|
||||
from fluency.geometry_occ.kernel import OCGeometryKernel
|
||||
|
||||
kernel = OCGeometryKernel()
|
||||
updated = 0
|
||||
for sketch in self._current_component.sketches.values():
|
||||
src_body_id = getattr(sketch, "_source_body_id", None)
|
||||
src_face = getattr(sketch, "_source_face", None)
|
||||
if src_body_id is None or src_face is None:
|
||||
continue
|
||||
if sketch.occ_sketch is None:
|
||||
continue
|
||||
body = self._current_component.bodies.get(src_body_id)
|
||||
if body is None or body.geometry is None:
|
||||
continue
|
||||
body_shape = kernel._get_shape(body.geometry)
|
||||
if body_shape is None:
|
||||
continue
|
||||
# Find the face on the updated body that matches the original
|
||||
# face's plane (normal parallel, origin coplanar).
|
||||
wp = sketch.occ_sketch.get_workplane()
|
||||
origin, normal = wp[0], wp[1]
|
||||
ref_center = getattr(sketch, "_source_face_center", None)
|
||||
match = OCGeometryKernel.find_coplanar_face(
|
||||
body_shape,
|
||||
origin,
|
||||
normal,
|
||||
ref_center=ref_center,
|
||||
)
|
||||
if match is None:
|
||||
logger.debug(
|
||||
"Sketch '%s': no matching face on body '%s', skipping",
|
||||
sketch.name,
|
||||
body.name,
|
||||
)
|
||||
continue
|
||||
new_face, new_center = match
|
||||
sketch._source_face = new_face
|
||||
sketch._source_face_center = new_center
|
||||
# Re-project the new face's edges into UV.
|
||||
from fluency.ui.sketch_widget import _project_face_to_uv
|
||||
|
||||
try:
|
||||
polys = _project_face_to_uv(new_face, wp)
|
||||
except Exception as exc:
|
||||
logger.debug("re-projection failed for sketch '%s': %s", sketch.name, exc)
|
||||
continue
|
||||
if not polys:
|
||||
continue
|
||||
# Update external entities in-place (preserves ids + constraints).
|
||||
ok = sketch.occ_sketch.update_external_entities(polys)
|
||||
if ok:
|
||||
updated += 1
|
||||
logger.info(
|
||||
"Re-projected underlay for sketch '%s' from body '%s'",
|
||||
sketch.name,
|
||||
body.name,
|
||||
)
|
||||
# If the sketch is currently loaded in the widget, refresh
|
||||
# the underlay data so the view reflects the new projection
|
||||
# WITHOUT re-importing (which would break constraints).
|
||||
if sketch.occ_sketch is self._sketch_widget._sketch:
|
||||
self._sketch_widget._source_face = new_face
|
||||
self._sketch_widget._source_underlay_uv = polys
|
||||
self._sketch_widget._rebuild_from_sketch()
|
||||
self._sketch_widget.update()
|
||||
if updated > 0:
|
||||
logger.info("Re-projected underlays for %d sketch(es)", updated)
|
||||
|
||||
def _propagate_to_assembly(self):
|
||||
"""Refresh all assembly instances that reference the current component.
|
||||
|
||||
@@ -1390,12 +1669,12 @@ class MainWindow(QMainWindow):
|
||||
btn.clicked.connect(self._on_assembly_component_clicked)
|
||||
_set_button_style(btn)
|
||||
|
||||
# Thumbnail from the component's first body.
|
||||
# Thumbnail from all bodies in the component.
|
||||
src_comp = self._current_component
|
||||
if src_comp:
|
||||
first_body = next(iter(src_comp.bodies.values()), None)
|
||||
if first_body and first_body.geometry:
|
||||
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
|
||||
has_geometry = any(b.visible and b.geometry for b in src_comp.bodies.values())
|
||||
if has_geometry:
|
||||
pixmap = _make_component_thumbnail(src_comp, self._kernel, QSize(96, 96))
|
||||
if pixmap is not None:
|
||||
btn.setIcon(pixmap)
|
||||
btn.setIconSize(QSize(96, 96))
|
||||
@@ -3225,6 +3504,17 @@ class MainWindow(QMainWindow):
|
||||
sketch.set_workplane(origin, normal, x_dir)
|
||||
# Keep the face reference for the projection underlay (Phase 3).
|
||||
sketch._source_face = face_shape
|
||||
# Store the face centroid for re-matching when the body updates.
|
||||
try:
|
||||
from OCP.BRepGProp import BRepGProp
|
||||
from OCP.GProp import GProp_GProps
|
||||
|
||||
props = GProp_GProps()
|
||||
BRepGProp.VolumeProperties_s(face_shape, props)
|
||||
c = props.CentreOfMass()
|
||||
sketch._source_face_center = (float(c.X()), float(c.Y()), float(c.Z()))
|
||||
except Exception:
|
||||
sketch._source_face_center = tuple(float(v) for v in origin)
|
||||
# Remember which body the sketch lives on so a later cut / combine
|
||||
# extrude auto-targets it. ``source_body`` may be None if the
|
||||
# pick landed on an untracked shape (e.g. an imported STEP that
|
||||
@@ -3452,11 +3742,9 @@ class MainWindow(QMainWindow):
|
||||
break
|
||||
|
||||
def _on_body_visibility_changed(self, item: QListWidgetItem) -> None:
|
||||
"""Toggle a body's 3D visibility when the user flips its checkbox.
|
||||
"""Toggle a body's 3D visibility when the user clicks pb_body_hide.
|
||||
|
||||
itemChanged also fires for selection (not just check-state) changes,
|
||||
so we filter on the check state being the changed role. The body
|
||||
is looked up via the UserRole data we set in _refresh_lists.
|
||||
The body is looked up via the UserRole data we set in _refresh_lists.
|
||||
"""
|
||||
if self._current_component is None:
|
||||
return
|
||||
@@ -3466,16 +3754,11 @@ class MainWindow(QMainWindow):
|
||||
body = self._current_component.bodies.get(body_id)
|
||||
if body is None:
|
||||
return
|
||||
new_visible = item.checkState() == Qt.Checked
|
||||
new_visible = not body.visible # toggle
|
||||
if body.visible == new_visible:
|
||||
return # no change
|
||||
body.visible = new_visible
|
||||
# Greying out hidden bodies gives a quick visual hint in the list.
|
||||
item.setForeground(QColor("#1e1e2e") if new_visible else QColor("#6c7086"))
|
||||
# Apply to the 3D viewer: if the body has a rendered object, hide
|
||||
# or show it. Bodies without a render_object (e.g. just-created,
|
||||
# not yet displayed) don't need viewer updates; they'll pick up
|
||||
# the visibility at the next redraw.
|
||||
if body.render_object is not None:
|
||||
ok = self._viewer_3d.set_visibility(body.render_object, new_visible)
|
||||
if not ok:
|
||||
@@ -3486,6 +3769,12 @@ class MainWindow(QMainWindow):
|
||||
)
|
||||
logger.info(f"{'Visible' if new_visible else 'Hidden'}: {body.name}")
|
||||
|
||||
def _on_body_hide_button_clicked(self) -> None:
|
||||
"""Handle click on pb_body_hide button - toggle visibility of selected body."""
|
||||
current_item = self._body_list.currentItem()
|
||||
if current_item is not None:
|
||||
self._on_body_visibility_changed(current_item)
|
||||
|
||||
# ─── Extrude / cut helpers (shared by live preview + apply) ────────
|
||||
|
||||
def _resolve_extrude_target(
|
||||
@@ -3558,6 +3847,7 @@ class MainWindow(QMainWindow):
|
||||
cut: bool,
|
||||
union: bool,
|
||||
through_all: bool,
|
||||
cut_all_bodies: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Compute the *previewable* result of an extrude/cut/union.
|
||||
|
||||
@@ -3566,6 +3856,7 @@ class MainWindow(QMainWindow):
|
||||
- "target_body": the Body being modified (None for plain extrude)
|
||||
- "tool_geom": the extruded profile geometry (the boolean tool)
|
||||
- "tool_shape": same, as a TopoDS_Shape (for show/remove)
|
||||
- "all_targets": list of all bodies affected when cut_all_bodies
|
||||
Or *None* if the geometry can't be built (e.g. empty sketch).
|
||||
|
||||
Mutates nothing on the project — safe to call repeatedly for the
|
||||
@@ -3577,6 +3868,15 @@ class MainWindow(QMainWindow):
|
||||
return None
|
||||
# Resolve target (only meaningful for cut / union).
|
||||
target = self._resolve_extrude_target(sketch) if (cut or union) else None
|
||||
# When cut_all_bodies, collect all bodies in the component as targets.
|
||||
all_targets: list = []
|
||||
if cut_all_bodies and cut and self._current_component is not None:
|
||||
all_targets = [
|
||||
b for b in self._current_component.bodies.values() if b.geometry is not None
|
||||
]
|
||||
# Use the first non-tool body as the primary target for preview.
|
||||
if target is None and all_targets:
|
||||
target = all_targets[0]
|
||||
# Determine the extrude length and direction.
|
||||
if through_all and target is not None:
|
||||
# Pass-through: symmetric extrude large enough to clear the body
|
||||
@@ -3617,6 +3917,7 @@ class MainWindow(QMainWindow):
|
||||
"target_body": target,
|
||||
"tool_geom": tool_geom,
|
||||
"tool_shape": tool_shape,
|
||||
"all_targets": all_targets,
|
||||
}
|
||||
# Plain extrude: the tool IS the result.
|
||||
return {
|
||||
@@ -3625,6 +3926,7 @@ class MainWindow(QMainWindow):
|
||||
"target_body": None,
|
||||
"tool_geom": tool_geom,
|
||||
"tool_shape": tool_shape,
|
||||
"all_targets": [],
|
||||
}
|
||||
|
||||
def _start_extrude_preview(self, dialog: ExtrudeDialog, sketch: Sketch, face_geom: Any) -> None:
|
||||
@@ -3639,17 +3941,24 @@ class MainWindow(QMainWindow):
|
||||
# which case we leave them alone).
|
||||
state = {"dimmed": []}
|
||||
|
||||
def _apply_dim(target: Optional[Body]):
|
||||
# Undo any prior dim, then dim the new target.
|
||||
for bid, tval in state["dimmed"]:
|
||||
def _apply_dim(targets):
|
||||
"""Dim one or more bodies for preview clarity."""
|
||||
# Undo any prior dim.
|
||||
for bid, _tval in state["dimmed"]:
|
||||
body = self._current_component.bodies.get(bid) if self._current_component else None
|
||||
if body is not None and body.render_object is not None:
|
||||
self._viewer_3d.set_transparency(body.render_object, 0.0)
|
||||
state["dimmed"].clear()
|
||||
if target is not None and target.render_object is not None:
|
||||
ok = self._viewer_3d.set_transparency(target.render_object, 0.6)
|
||||
# Normalize to a list.
|
||||
if targets is None:
|
||||
targets = []
|
||||
elif isinstance(targets, Body):
|
||||
targets = [targets]
|
||||
for t in targets:
|
||||
if t is not None and t.render_object is not None:
|
||||
ok = self._viewer_3d.set_transparency(t.render_object, 0.6)
|
||||
if ok:
|
||||
state["dimmed"].append((target.id, 0.6))
|
||||
state["dimmed"].append((t.id, 0.6))
|
||||
|
||||
def _clear():
|
||||
self._viewer_3d.clear_preview()
|
||||
@@ -3663,7 +3972,7 @@ class MainWindow(QMainWindow):
|
||||
if values is None:
|
||||
_clear()
|
||||
return
|
||||
length, symmetric, invert, cut, union, through_all, _rounded = values
|
||||
length, symmetric, invert, cut, union, through_all, cut_all_bodies, _rounded = values
|
||||
result = self._compute_extrude_result(
|
||||
sketch,
|
||||
face_geom,
|
||||
@@ -3673,12 +3982,18 @@ class MainWindow(QMainWindow):
|
||||
bool(cut),
|
||||
bool(union),
|
||||
bool(through_all),
|
||||
cut_all_bodies=bool(cut_all_bodies),
|
||||
)
|
||||
if result is None or result["result_shape"] is None:
|
||||
self._viewer_3d.clear_preview()
|
||||
_apply_dim(None)
|
||||
return
|
||||
self._viewer_3d.show_preview(result["result_shape"])
|
||||
# Dim all affected bodies when cut_all_bodies is active.
|
||||
all_targets = result.get("all_targets", [])
|
||||
if all_targets:
|
||||
_apply_dim(all_targets)
|
||||
else:
|
||||
_apply_dim(result["target_body"])
|
||||
|
||||
dialog.set_preview_callback(_callback)
|
||||
@@ -3731,10 +4046,13 @@ class MainWindow(QMainWindow):
|
||||
logger.info("Extrude dialog cancelled")
|
||||
return
|
||||
|
||||
length, symmetric, invert, cut, union, through_all, rounded = dialog.get_values()
|
||||
length, symmetric, invert, cut, union, through_all, cut_all_bodies, rounded = (
|
||||
dialog.get_values()
|
||||
)
|
||||
logger.info(
|
||||
f"Extrude params: length={length}, symmetric={symmetric}, "
|
||||
f"invert={invert}, cut={cut}, union={union}, through_all={through_all}"
|
||||
f"invert={invert}, cut={cut}, union={union}, through_all={through_all}, "
|
||||
f"cut_all_bodies={cut_all_bodies}"
|
||||
)
|
||||
|
||||
# Capture the face index before the dialog closes (the selected
|
||||
@@ -3751,6 +4069,7 @@ class MainWindow(QMainWindow):
|
||||
bool(cut),
|
||||
bool(union),
|
||||
bool(through_all),
|
||||
cut_all_bodies=bool(cut_all_bodies),
|
||||
)
|
||||
if result is None or result["result_geom"] is None:
|
||||
logger.warning("Extrude produced no geometry")
|
||||
@@ -3758,12 +4077,41 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
|
||||
target = result["target_body"]
|
||||
if target is not None:
|
||||
# Cut / union: commit the result onto the *target* body in
|
||||
# place (don't create a separate tool body — the previous
|
||||
# implementation did, and that was the user-perceived
|
||||
# "added without cut" bug once the spurious body was
|
||||
# deleted).
|
||||
all_targets = result.get("all_targets", [])
|
||||
|
||||
if target is not None and bool(cut) and all_targets:
|
||||
# Cut all bodies: apply the boolean difference to every body
|
||||
# in the component that has geometry.
|
||||
tool_geom = result["tool_geom"]
|
||||
updated_count = 0
|
||||
for body in all_targets:
|
||||
try:
|
||||
new_geom = self._kernel.boolean_difference(body.geometry, tool_geom)
|
||||
body.geometry = new_geom
|
||||
except Exception as exc:
|
||||
logger.debug("Cut-all: boolean failed for %s: %s", body.name, exc)
|
||||
continue
|
||||
body.extrude_length = length
|
||||
body.extrude_symmetric = symmetric
|
||||
body.extrude_invert = invert
|
||||
body.extrude_cut = True
|
||||
body.extrude_union = False
|
||||
body.extrude_through_all = bool(through_all)
|
||||
body.extrude_cut_all_bodies = True
|
||||
body.extrude_face_index = face_index
|
||||
body.source_sketch = sketch
|
||||
body.source_operation = "cut"
|
||||
body.extrude_target_body_id = body.id
|
||||
if body.render_object is not None:
|
||||
self._viewer_3d.remove_mesh(body.render_object)
|
||||
shape = self._kernel._get_shape(body.geometry)
|
||||
body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name)
|
||||
updated_count += 1
|
||||
logger.info(f"Cut-all applied to {updated_count} body(ies)")
|
||||
body_name = f"{updated_count} body(ies)"
|
||||
elif target is not None:
|
||||
# Single-body cut / union: commit the result onto the *target*
|
||||
# body in place.
|
||||
target.geometry = result["result_geom"]
|
||||
# Store extrude params so the body can be rebuilt later.
|
||||
target.extrude_length = length
|
||||
@@ -3772,6 +4120,7 @@ class MainWindow(QMainWindow):
|
||||
target.extrude_cut = bool(cut)
|
||||
target.extrude_union = bool(union)
|
||||
target.extrude_through_all = bool(through_all)
|
||||
target.extrude_cut_all_bodies = False
|
||||
target.extrude_face_index = face_index
|
||||
target.source_sketch = sketch
|
||||
target.source_operation = "cut" if cut else "union"
|
||||
@@ -3797,6 +4146,7 @@ class MainWindow(QMainWindow):
|
||||
extrude_cut=False,
|
||||
extrude_union=False,
|
||||
extrude_through_all=bool(through_all),
|
||||
extrude_cut_all_bodies=False,
|
||||
extrude_face_index=face_index,
|
||||
)
|
||||
)
|
||||
@@ -4356,12 +4706,12 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Rebuild component buttons (one per component, with thumbnails).
|
||||
for idx, comp in enumerate(self._project.components.values(), start=1):
|
||||
first_body = next(iter(comp.bodies.values()), None)
|
||||
if first_body and first_body.geometry:
|
||||
has_geometry = any(b.visible and b.geometry for b in comp.bodies.values())
|
||||
if has_geometry:
|
||||
btn = _create_component_button(
|
||||
idx,
|
||||
comp.name,
|
||||
first_body,
|
||||
comp,
|
||||
self._kernel,
|
||||
self._component_group,
|
||||
self._component_box_layout,
|
||||
@@ -4409,12 +4759,16 @@ class MainWindow(QMainWindow):
|
||||
btn.clicked.connect(self._on_assembly_component_clicked)
|
||||
_set_button_style(btn)
|
||||
|
||||
# Thumbnail from the source component's first body.
|
||||
# Thumbnail from the source component's all bodies.
|
||||
src_comp = self._project.components.get(ac.component_id)
|
||||
if src_comp:
|
||||
first_body = next(iter(src_comp.bodies.values()), None)
|
||||
if first_body and first_body.geometry:
|
||||
pixmap = _make_body_thumbnail(first_body, self._kernel, QSize(96, 96))
|
||||
has_geometry = any(
|
||||
b.visible and b.geometry for b in src_comp.bodies.values()
|
||||
)
|
||||
if has_geometry:
|
||||
pixmap = _make_component_thumbnail(
|
||||
src_comp, self._kernel, QSize(96, 96)
|
||||
)
|
||||
if pixmap is not None:
|
||||
btn.setIcon(pixmap)
|
||||
btn.setIconSize(QSize(96, 96))
|
||||
@@ -4512,7 +4866,7 @@ class MainWindow(QMainWindow):
|
||||
btn = _create_component_button(
|
||||
btn_num,
|
||||
name,
|
||||
body,
|
||||
comp,
|
||||
self._kernel,
|
||||
self._component_group,
|
||||
self._component_box_layout,
|
||||
|
||||
+371
-86
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user