Files
fluencyCAD/src/fluency/geometry_occ/sketch.py
T
2026-08-05 18:53:48 +02:00

3013 lines
133 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
OpenCASCADE-based sketch for Fluency CAD with SolveSpace constraint solver integration.
This module provides 2D sketching capabilities using the SolveSpace constraint
solver (via python_solvespace) for constraint management, and CadQuery for
geometry generation from solved positions.
"""
from typing import List, Tuple, Optional, Dict, Any
import math
import numpy as np
import logging
import re
from python_solvespace import SolverSystem, ResultFlag
from fluency.geometry.base import (
SketchInterface,
SketchEntity,
GeometryObject,
Point2D,
)
from fluency.geometry_occ.kernel import OCCGeometryObject
logger = logging.getLogger(__name__)
# World-unit tolerance used when matching a saved line/circle/arc position to
# an existing point entity during load. Old files can carry derived geometry
# that is stale relative to the point entities (saved after a drag that was
# never re-solved); the fallback must be generous enough to reach the real
# point while staying far below typical feature sizes.
_LOAD_POINT_TOL = 0.5
class OCCSketchEntity(SketchEntity):
"""Sketch entity for OpenCASCADE-based sketch with solver integration."""
def __init__(self, entity_id: int, entity_type: str, geometry: Any = None, handle: Any = None):
super().__init__(entity_id, entity_type)
self.geometry = geometry
self.handle = handle # SolveSpace solver entity handle
self.is_construction: bool = False
self.constraints: List[str] = [] # Track applied constraint names for UI
# External / underlay entities are reference geometry projected from
# a 3D face (or otherwise supplied from outside the sketch). They live
# in the solver so user constraints can reference them, but they are
# *not* user-drawn, *not* deletable, *not* moveable, and never
# contribute to the sketch profile (detect_faces / get_geometry).
self.is_external: bool = False
class OCCSketch(SketchInterface):
"""
Sketch with SolveSpace constraint solver integration.
Uses python_solvespace as the constraint engine, allowing points and lines
to be parametrically constrained. After solving, positions are read from
the solver and used to build CadQuery geometry for extrusion.
"""
def __init__(self) -> None:
self._solver: SolverSystem = SolverSystem()
self._wp: Any = self._solver.create_2d_base()
self._entities: Dict[int, OCCSketchEntity] = {}
self._entity_counter: int = 0
self._points: Dict[int, Tuple[float, float]] = {}
self._lines: Dict[int, Tuple[int, int]] = {}
self._circles: Dict[int, Tuple[int, float]] = {}
self._arcs: Dict[int, Any] = {}
self._constraint_count: int = 0
# Re-appliable log of every constraint, so we can rebuild the solver
# after deleting an entity (python_solvespace has no per-entity delete).
# Each entry: {"type": str, "ids": (int, ...), "params": tuple, "labels": set[str]}
self._constraint_log: List[Dict[str, Any]] = []
# External / underlay entity ids (face-projected reference geometry).
# Kept in their own set so we can:
# • render them with a distinct style
# • filter them out of get_closed_loops / detect_faces
# • refuse to delete / move them
# • clear them as a group when the source face is removed
self._external_entity_ids: set = set()
# Centerline entity ids (X and Y reference axes through origin).
# These are construction lines that span the sketch and are used
# as reference axes for constraining geometry. They are:
# • fixed in the solver (never move)
# • marked is_construction (excluded from profile detection)
# • non-deletable
self._centerline_ids: set = set()
# Track first point as dragged/fixed for solver stability
self._first_point_id: Optional[int] = None
# Cached 2D normal for the workplane. SolveSpace's add_arc() needs
# a normal_2d handle, and creating one per arc pollutes the solver
# with redundant entities. We create it lazily on first arc and
# reset it whenever the workplane or solver is reset.
self._wp_normal_handle: Optional[Any] = None
# Set of arc ids whose diameter is locked by a ``diameter``
# constraint — for those we MUST NOT overwrite the stored radius
# from the geometry, because the user explicitly fixed it.
self._arc_diameter_fixed: set = set()
# ── Workplane ───────────────────────────────────────────────────
# The sketch lives in a 2D UV frame on this plane. UV coordinates
# map to world via: P = origin + u*x_dir + v*y_dir
# where y_dir = normal × x_dir. Defaults to the world XY plane so
# existing XY-only behaviour is unchanged.
self._wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
self._wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
self._wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
self._wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0)
# ─── Workplane management ──────────────────────────────────────────────
def set_workplane(
self,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
) -> None:
"""Place this sketch on an arbitrary plane in 3D.
*normal* and *x_dir* need not be unit/perpendicular — they are
orthonormalised here. ``y_dir`` is derived as ``normal × x_dir``.
Existing UV coordinates are unchanged; only their world mapping moves.
"""
n = np.asarray(normal, dtype=float)
x = np.asarray(x_dir, dtype=float)
n = n / np.linalg.norm(n)
# Remove any component of x along n, then renormalise.
x = x - np.dot(x, n) * n
x_norm = np.linalg.norm(x)
if x_norm < 1e-9:
# x_dir is parallel to normal — pick any orthogonal basis vector.
fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
x = fallback - np.dot(fallback, n) * n
x_norm = np.linalg.norm(x)
x = x / x_norm
y = np.cross(n, x)
y = y / np.linalg.norm(y)
self._wp_origin = tuple(float(v) for v in origin)
self._wp_normal = tuple(float(v) for v in n)
self._wp_x_dir = tuple(float(v) for v in x)
self._wp_y_dir = tuple(float(v) for v in y)
def get_workplane(self) -> Tuple[Tuple[float, float, float], ...]:
"""Return the (origin, normal, x_dir, y_dir) of this sketch's plane."""
return (self._wp_origin, self._wp_normal, self._wp_x_dir, self._wp_y_dir)
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
return gp_Pnt(
ox + u * xx + v * yx,
oy + u * xy + v * yy,
oz + u * xz + v * yz,
)
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,
gp_Dir(*self._wp_normal),
gp_Dir(*self._wp_x_dir),
)
@property
def solver(self) -> SolverSystem:
"""Access the underlying SolveSpace solver."""
return self._solver
@property
def workplane(self) -> Any:
"""Get the SolveSpace 2D solver workplane entity.
Note: this is the solver's internal 2D base, not the 3D placement
plane — see :meth:`set_workplane` / :meth:`workplane` (no underscore)
for the 3D plane. The solver always runs in UV regardless of the
3D placement.
"""
return self._wp
def _next_id(self) -> int:
self._entity_counter += 1
return self._entity_counter
def _get_handle_nr(self, handle_str: str) -> int:
match = re.search(r"handle=(\d+)", str(handle_str))
return int(match.group(1)) if match else 0
def add_point(self, x: float, y: float) -> OCCSketchEntity:
"""Add a point to the sketch (added to solver + tracked).
The very first point added to an empty solver is auto-anchored via
``dragged`` to give the solver a stable reference frame. If the
sketch already carries external / underlay points (those are
always dragged at creation), we skip this auto-anchor — the
external point is the natural reference, and a second dragged
point would over-constrain the system and make any
user-to-external distance constraint unsolvable.
"""
entity_id = self._next_id()
# Add to solver
solver_handle = self._solver.add_point_2d(x, y, self._wp)
if self._first_point_id is None and not self._external_entity_ids:
self._first_point_id = entity_id
# Fix first point so solver has a reference
self._solver.dragged(solver_handle, self._wp)
entity = OCCSketchEntity(
entity_id=entity_id, entity_type="point", geometry=(x, y), handle=solver_handle
)
self._entities[entity_id] = entity
self._points[entity_id] = (x, y)
return entity
def add_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity:
"""Add a line between two points (added to solver + tracked)."""
entity_id = self._next_id()
start_entity = self._entities.get(start.id)
end_entity = self._entities.get(end.id)
if start_entity is None or end_entity is None:
raise ValueError("Start or end point not found in sketch")
# Get solver handles
s_handle = start_entity.handle
e_handle = end_entity.handle
# Add line to solver
solver_handle = self._solver.add_line_2d(s_handle, e_handle, self._wp)
x1, y1 = start_entity.geometry
x2, y2 = end_entity.geometry
entity = OCCSketchEntity(
entity_id=entity_id,
entity_type="line",
geometry=((x1, y1), (x2, y2)),
handle=solver_handle,
)
self._entities[entity_id] = entity
self._lines[entity_id] = (start.id, end.id)
return entity
def add_circle(self, center: SketchEntity, radius: float) -> OCCSketchEntity:
"""Add a circle (tracked only — solver has no native circle in this API)."""
entity_id = self._next_id()
center_entity = self._entities.get(center.id)
if center_entity is None:
raise ValueError("Center point not found in sketch")
cx, cy = center_entity.geometry
entity = OCCSketchEntity(
entity_id=entity_id, entity_type="circle", geometry=((cx, cy), radius)
)
self._entities[entity_id] = entity
self._circles[entity_id] = (center.id, radius)
return entity
def _make_arc_normal_3d(self) -> Any:
"""Build a SolveSpace 3D normal (quaternion) that matches this sketch's workplane orientation.
SolveSpace's ``add_arc`` requires a 3D normal (quaternion) for the
arc plane, NOT a 2D one — passing a 2D normal raises
``TypeError: ... is not a 3d normal``. The 3D normal is a
unit quaternion that rotates the canonical Z-axis onto the
workplane's stored normal.
The default XY workplane (normal = +Z) maps to the identity
quaternion ``(1, 0, 0, 0)``. For arbitrary workplanes we derive
the shortest-arc quaternion that takes +Z onto the workplane
normal; this is the standard axis-angle → quaternion conversion
via the cross product as rotation axis.
"""
import math as _math
nx, ny, nz = self._wp_normal
# Identity rotation when the workplane normal is already +Z.
if abs(nx) < 1e-12 and abs(ny) < 1e-12 and abs(nz - 1.0) < 1e-12:
return self._solver.add_normal_3d(1.0, 0.0, 0.0, 0.0)
# Antiparallel case (workplane normal = -Z) — 180° about X axis.
if abs(nx) < 1e-12 and abs(ny) < 1e-12 and abs(nz + 1.0) < 1e-12:
return self._solver.add_normal_3d(0.0, 1.0, 0.0, 0.0)
# Axis = +Z × n = (-ny, nx, 0); angle = arccos(nz).
axis_len = _math.sqrt(nx * nx + ny * ny)
ax = -ny / axis_len
ay = nx / axis_len
az = 0.0
angle = _math.acos(max(-1.0, min(1.0, nz)))
half = angle * 0.5
s = _math.sin(half)
qw = _math.cos(half)
qx = ax * s
qy = ay * s
qz = az * s
return self._solver.add_normal_3d(qw, qx, qy, qz)
def add_arc(
self,
center: SketchEntity,
radius: float,
start_point: SketchEntity,
end_point: SketchEntity,
sweep: Optional[float] = None,
) -> OCCSketchEntity:
"""Add an arc (added to solver + tracked).
The arc is registered with SolveSpace so its three reference points
are linked: start, end, and centre. SolveSpace's arc entity
implicitly enforces ``distance(start, centre) = distance(end, centre)``,
so the radius is **derived** from the current geometry rather than
stored as a fixed scalar.
Consequences:
* If the user constrains the start or end (e.g. coincident to a
rectangle corner) and then resizes the rectangle, the centre
slides on the perpendicular bisector of start↔end to keep the
arc consistent — the arc shape follows the rectangle, which is
the behaviour users expect from a fillet.
* If the centre is dragged instead, the radius adjusts so the
endpoints stay on the new circle.
* If the user wants the **diameter pinned** to a specific value
(e.g. a quarter-circle of exactly 10 mm), they can call
:meth:`constrain_arc_diameter` afterwards.
*sweep* is the signed angular span in radians (positive = CCW,
negative = CW). 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)
start_entity = self._entities.get(start_point.id)
end_entity = self._entities.get(end_point.id)
if center_entity is None or start_entity is None or end_entity is None:
raise ValueError("Arc points not found in sketch")
if center_entity.handle is None or start_entity.handle is None or end_entity.handle is None:
raise ValueError("Arc endpoints must already be in the solver")
cx, cy = center_entity.geometry
sx, sy = start_entity.geometry
ex, ey = end_entity.geometry
# Infer sweep from geometry when not provided.
if sweep is None:
sa = math.atan2(sy - cy, sx - cx)
ea = math.atan2(ey - cy, ex - cx)
sweep = ea - sa
while sweep > math.pi:
sweep -= 2 * math.pi
while sweep < -math.pi:
sweep += 2 * math.pi
# ── Add the arc to the SolveSpace solver ───────────────────────
# We need a 3D normal (quaternion) for the work plane. Cache one
# per (sketch, workplane) so we don't accumulate unused normals
# across many arc creations, and so the cache is invalidated
# whenever the workplane orientation changes. The normal is
# invalidated by ``clear`` / ``_rebuild_solver`` /
# ``set_workplane`` (the workplane reference changes).
if self._wp_normal_handle is None:
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None # _make_arc_normal_3d always returns a handle
arc_handle = self._solver.add_arc(
nm,
center_entity.handle,
start_entity.handle,
end_entity.handle,
self._wp,
)
entity = OCCSketchEntity(
entity_id=entity_id,
entity_type="arc",
geometry={
"center": (cx, cy),
"radius": radius,
"start": (sx, sy),
"end": (ex, ey),
"sweep": sweep,
},
handle=arc_handle,
)
self._entities[entity_id] = entity
self._arcs[entity_id] = {
"center": center.id,
"start": start_point.id,
"end": end_point.id,
"radius": radius,
"sweep": sweep,
# ``original_sweep`` captures the angular span the user drew
# the arc with. When the host geometry (e.g. a rectangle
# the arc is attached to) resizes, ``_sync_solved_positions``
# uses this to re-derive the centre position so the arc
# scales with the rectangle while keeping the same shape.
# The user can override it later with
# :meth:`constrain_arc_diameter` if they want a fixed-size
# arc regardless of the host geometry.
"original_sweep": sweep,
}
return entity
# ─── External / underlay entities (face-projected reference geometry) ───
def add_external_point(self, x: float, y: float) -> OCCSketchEntity:
"""Add a point that participates in the solver but is *not* user-drawn.
External points are used to anchor projected face edges (sketch-on-
surface underlay) so the user can snap to them and add constraints
like "hole center 50mm from the body's top edge". The point is
immediately marked *fixed* in the solver (via ``dragged``) so it never
moves when other entities are dragged or re-solved.
External entities are skipped by ``get_closed_loops`` /
``detect_faces`` / ``get_geometry`` so they never contribute to the
extruded profile — they're reference geometry only.
"""
entity_id = self._next_id()
solver_handle = self._solver.add_point_2d(x, y, self._wp)
# Always fix external points — they MUST NOT move when the solver
# adjusts other entities. We bypass the first-point auto-fix in
# ``add_point`` (which would also fix the very first one and leave
# the rest free), and we apply dragged() unconditionally here.
self._solver.dragged(solver_handle, self._wp)
entity = OCCSketchEntity(
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
self._entities[entity_id] = entity
self._points[entity_id] = (x, y)
self._external_entity_ids.add(entity_id)
return entity
def add_external_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity:
"""Add a line between two existing external points.
Both endpoints must already be external points (created via
:meth:`add_external_point`). External lines are tagged ``is_external``
and are excluded from the sketch's profile-detect path so they don't
pollute the extruded face. Constraints applied to external lines
(horizontal, vertical, parallel, perpendicular, midpoint) work
normally — the line handle is real — but the line itself never moves.
"""
entity_id = self._next_id()
s_ent = self._entities.get(start.id)
e_ent = self._entities.get(end.id)
if s_ent is None or e_ent is None:
raise ValueError("Start or end point not found in sketch")
if s_ent.handle is None or e_ent.handle is None:
raise ValueError("External endpoints must have solver handles")
solver_handle = self._solver.add_line_2d(s_ent.handle, e_ent.handle, self._wp)
x1, y1 = s_ent.geometry
x2, y2 = e_ent.geometry
entity = OCCSketchEntity(
entity_id=entity_id,
entity_type="line",
geometry=((x1, y1), (x2, y2)),
handle=solver_handle,
)
entity.is_external = True
entity.is_construction = True
self._entities[entity_id] = entity
self._lines[entity_id] = (start.id, end.id)
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]]:
"""Bulk-import a polyline of UV points as external (underlay) entities.
Creates one external point per unique UV position and one external
line segment between consecutive points. Returns
``(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 ``_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:
continue
points = [find_or_create(float(u), float(v)) for (u, v) in uv_points]
all_points.append(points)
for i in range(len(points) - 1):
if points[i] is points[i + 1]:
continue # degenerate zero-length segment after merging
# Skip duplicate segments (two edges projecting onto the
# same pair of corner points).
dupe = False
for lid, (sid, eid2) in self._lines.items():
if lid not in self._external_entity_ids:
continue
if (sid == points[i].id and eid2 == points[i + 1].id) or (
sid == points[i + 1].id and eid2 == points[i].id
):
dupe = True
break
if dupe:
continue
try:
ln = self.add_external_line(points[i], points[i + 1])
all_lines.append(ln)
except ValueError:
pass
return all_points, all_lines
def _drop_external_entities(self) -> set:
"""Remove external entities from local tracking + prune their constraints.
Does NOT rebuild the solver — the caller decides when to rebuild
(removal-only flows rebuild immediately; re-projection flows first
import the new externals so the rebuild sees them and doesn't
auto-anchor a user point instead).
"""
removed = set(self._external_entity_ids)
if not removed:
return removed
# Wipe external entities from local tracking.
for eid in list(removed):
if eid in self._entities:
del self._entities[eid]
self._points.pop(eid, None)
self._lines.pop(eid, None)
self._circles.pop(eid, None)
self._arcs.pop(eid, None)
# Also clean lines that USE an external point as an endpoint but
# somehow aren't themselves external (defensive — shouldn't happen
# via the public API, but rebuild_solver needs a clean graph).
for lid, (sid, eid2) in list(self._lines.items()):
if sid in removed or eid2 in removed:
del self._lines[lid]
if lid in self._entities:
del self._entities[lid]
self._external_entity_ids.clear()
self._prune_log_for(removed)
return removed
def remove_external_entities(self) -> None:
"""Remove every external / underlay entity and prune related constraints.
Used when the source face is removed (or rebinded). External
entities are *never* user-deletable; this is the only way to clear
them. Any constraint that references a removed external id is
pruned from the constraint log and the solver is rebuilt from the
surviving user geometry so the next solve is consistent.
"""
if not self._external_entity_ids:
return
self._drop_external_entities()
self._rebuild_solver()
self._rebuild_labels()
def get_external_entity_ids(self) -> set:
"""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
def add_centerlines(self) -> None:
"""Add X (horizontal) and Y (vertical) centerlines through the origin.
These construction lines serve as reference axes for constraining
sketch geometry. They are:
• Fixed in the solver (never move)
• Marked is_construction (excluded from profile / face detection)
• Non-deletable
• Available as constraint targets (snap, coincident, distance,
symmetric, horizontal, vertical, parallel, perpendicular)
The X centerline runs horizontal (left→right) through origin and
the Y centerline runs vertical (bottom→top) through origin. Both
extend to ``_CENTERLINE_EXTENT`` in both directions so they span
any realistic sketch. If centerlines have already been added this
is a no-op.
"""
if self._centerline_ids:
return
HALF = self._CENTERLINE_EXTENT
# Origin point — auto-fixed as the first point in the solver
origin = self.add_point(0.0, 0.0)
origin.is_construction = True
# X centerline (horizontal)
xl = self.add_point(-HALF, 0.0)
xl.is_construction = True
xr = self.add_point(HALF, 0.0)
xr.is_construction = True
xline = self.add_line(xl, xr)
xline.is_construction = True
self.constrain_horizontal(xline)
self.constrain_fixed(xl)
# Y centerline (vertical)
yb = self.add_point(0.0, -HALF)
yb.is_construction = True
yt = self.add_point(0.0, HALF)
yt.is_construction = True
yline = self.add_line(yb, yt)
yline.is_construction = True
self.constrain_vertical(yline)
self.constrain_fixed(yb)
self._centerline_ids = {
origin.id,
xl.id,
xr.id,
xline.id,
yb.id,
yt.id,
yline.id,
}
self.solve()
def get_centerline_ids(self) -> set:
"""Return the set of centerline entity ids currently in the sketch."""
return set(self._centerline_ids)
def add_rectangle(
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
) -> List[OCCSketchEntity]:
"""Add a rectangle, returning the created entities."""
x1, y1 = corner1
x2, y2 = corner2
entities: List[OCCSketchEntity] = []
p1 = self.add_point(x1, y1)
p2 = self.add_point(x2, y1)
p3 = self.add_point(x2, y2)
p4 = self.add_point(x1, y2)
entities.extend([p1, p2, p3, p4])
l1 = self.add_line(p1, p2)
l2 = self.add_line(p2, p3)
l3 = self.add_line(p3, p4)
l4 = self.add_line(p4, p1)
entities.extend([l1, l2, l3, l4])
return entities
# ─── Constraint methods (actual solver calls) ──────────────────────────
def _record_constraint(
self, ctype: str, ids: Tuple[int, ...], params: Tuple = (), labels: Tuple[str, ...] = ()
) -> None:
"""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),
}
)
def _apply_constraint_log(self, entry: Dict[str, Any]) -> bool:
"""Re-apply a single logged constraint to the current (rebuilt) solver.
Uses live solver handles looked up by entity id. Returns False silently if
any referenced entity is now gone (pruning should have removed it, but
this is defensive).
"""
ctype = entry["type"]
ids = entry["ids"]
params = entry["params"]
def h(i: int) -> Any:
ent = self._entities.get(i)
return ent.handle if ent is not None else None
if ctype == "coincident":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.coincident(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "horizontal":
if h(ids[0]) is None:
return False
self._solver.horizontal(h(ids[0]), self._wp)
elif ctype == "vertical":
if h(ids[0]) is None:
return False
self._solver.vertical(h(ids[0]), self._wp)
elif ctype == "distance":
if h(ids[0]) is None or h(ids[1]) is None:
return False
ent0 = self._entities.get(ids[0])
ent1 = self._entities.get(ids[1])
# Normalise (line, point) -> (point, line) like constrain_distance
# does, and drop legacy line-line entries the solver can't hold.
if ent0 is not None and ent1 is not None:
if ent0.entity_type == "line" and ent1.entity_type == "line":
return False
if ent0.entity_type == "line" and ent1.entity_type == "point":
self._solver.distance(h(ids[1]), h(ids[0]), params[0], self._wp)
else:
self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
elif ctype == "angle":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.angle(h(ids[0]), h(ids[1]), params[0], self._wp)
elif ctype == "parallel":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.parallel(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "perpendicular":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.perpendicular(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "midpoint":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.midpoint(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "tangent":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.tangent(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "equal":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.equal(h(ids[0]), h(ids[1]), self._wp)
elif ctype == "fixed":
if h(ids[0]) is None:
return False
self._solver.dragged(h(ids[0]), self._wp)
elif ctype == "symmetric":
if h(ids[0]) is None or h(ids[1]) is None or h(ids[2]) is None:
return False
self._solver.symmetric(h(ids[0]), h(ids[1]), h(ids[2]), self._wp)
elif ctype == "equal_radius":
# tracked only (no solver entity)
pass
elif ctype == "diameter":
# Update circle radius in sketch data. Legacy files (pre-ghost-
# circle-fix) sometimes recorded the diameter against the CENTER
# point id instead of the circle entity id; resolve such entries
# to the real circle via the _circles center mapping. Never
# touch the geometry of a non-circle entity — doing so used to
# turn a point into circle-shaped geometry and crashed the UI's
# ``round()`` calls.
circle_id = ids[0]
radius = params[0] / 2.0
resolved: Optional[int] = circle_id
if resolved not in self._circles:
for cid, (center_id, _r) in self._circles.items():
if center_id == circle_id:
resolved = cid
break
else:
resolved = None
if resolved is not None:
if resolved in self._circles:
center_id, _ = self._circles[resolved]
self._circles[resolved] = (center_id, radius)
ent = self._entities.get(resolved)
if ent is not None and ent.entity_type == "circle" and ent.geometry is not None:
cx, cy = (
ent.geometry[0]
if isinstance(ent.geometry[0], (tuple, list))
else ent.geometry
)
ent.geometry = ((cx, cy), radius)
elif ctype == "arc_diameter":
# Re-apply the solver-side diameter constraint and refresh the
# stored radius so the renderer matches. Marks the arc as
# diameter-pinned so subsequent solves don't overwrite the
# radius from the implicit geometry.
arc_id = ids[0]
ent = self._entities.get(arc_id)
if ent is None or ent.handle is None or arc_id not in self._arcs:
return False
try:
diameter_value = float(params[0])
except (TypeError, ValueError) as e:
logger.debug("arc_diameter log had non-numeric param: %s", e)
return False
try:
self._solver.diameter(ent.handle, diameter_value)
except Exception as e:
logger.debug("Re-applying arc_diameter failed: %s", e)
return False
radius = diameter_value / 2.0
self._arcs[arc_id]["radius"] = radius
if isinstance(ent.geometry, dict):
ent.geometry["radius"] = radius
self._arc_diameter_fixed.add(arc_id)
else:
return False
return True
def _rebuild_solver(self) -> None:
"""Recreate the SolveSpace system from current points/lines + log.
python_solvespace cannot remove individual entities/constraints, so
after deleting an entity we rebuild the whole system: re-add every
surviving point at its current position (first point re-fixed for
stability), re-add every surviving line, re-add every surviving
arc, then re-apply the pruned constraint log. Entity ids are
preserved; only solver handles change.
"""
# Snapshot current point positions before resetting the solver.
saved_pos: Dict[int, Tuple[float, float]] = {}
for eid, ent in self._entities.items():
if ent.entity_type == "point" and ent.geometry is not None:
saved_pos[eid] = (float(ent.geometry[0]), float(ent.geometry[1]))
self._solver = SolverSystem()
self._wp = self._solver.create_2d_base()
self._first_point_id = None
# New solver = new work plane = new normal entity. Drop the cache
# so ``add_arc`` recreates it on demand.
self._wp_normal_handle = None
# Re-add point entities in id order (preserves first-point-fixed).
for pid in sorted(eid for eid, e in self._entities.items() if e.entity_type == "point"):
ent = self._entities[pid]
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 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)
# Re-add line entities in id order, updating their solver handles.
for lid in sorted(self._lines.keys()):
sid, eid2 = self._lines[lid]
s_ent = self._entities.get(sid)
e_ent = self._entities.get(eid2)
if s_ent is None or e_ent is None or s_ent.handle is None or e_ent.handle is None:
continue
new_handle = self._solver.add_line_2d(s_ent.handle, e_ent.handle, self._wp)
line_ent = self._entities.get(lid)
if line_ent is not None:
line_ent.handle = new_handle
# Re-add arc entities in id order. Without this, every arc loses
# its solver-side constraint that ties start/end/centre together,
# and the renderer would happily draw the old radius over the new
# geometry — the exact "arc doesn't follow the rectangle" bug
# that motivated the add-arc-to-solver change.
if self._arcs:
if self._wp_normal_handle is None:
# Use the 3D-normal helper, not add_normal_2d — the latter
# produces a 2D entity that add_arc rejects with
# ``TypeError: ... is not a 3d normal``.
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None
for aid in sorted(self._arcs.keys()):
arc_data = self._arcs[aid]
c_id = arc_data.get("center")
s_id = arc_data.get("start")
e_id = arc_data.get("end")
c_ent = self._entities.get(c_id) if c_id is not None else None
s_ent = self._entities.get(s_id) if s_id is not None else None
e_ent = self._entities.get(e_id) if e_id is not None else None
if (
c_ent is None
or s_ent is None
or e_ent is None
or c_ent.handle is None
or s_ent.handle is None
or e_ent.handle is None
):
continue
new_handle = self._solver.add_arc(
nm, c_ent.handle, s_ent.handle, e_ent.handle, self._wp
)
arc_ent = self._entities.get(aid)
if arc_ent is not None:
arc_ent.handle = new_handle
# Re-apply every surviving logged constraint.
for entry in self._constraint_log:
self._apply_constraint_log(entry)
def constrain_coincident(self, *entities: SketchEntity) -> bool:
"""Make entities coincident via solver."""
if len(entities) < 2:
return False
e1 = self._entities.get(entities[0].id)
e2 = self._entities.get(entities[1].id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.coincident(e1.handle, e2.handle, self._wp)
self._record_constraint("coincident", (entities[0].id, entities[1].id))
return True
def constrain_horizontal(self, line: SketchEntity) -> bool:
"""Constrain a line to be horizontal."""
entity = self._entities.get(line.id)
if entity is None or entity.handle is None:
return False
self._solver.horizontal(entity.handle, self._wp)
self._record_constraint("horizontal", (line.id,), labels=("hrz",))
if "hrz" not in entity.constraints:
entity.constraints.append("hrz")
return True
def constrain_vertical(self, line: SketchEntity) -> bool:
"""Constrain a line to be vertical."""
entity = self._entities.get(line.id)
if entity is None or entity.handle is None:
return False
self._solver.vertical(entity.handle, self._wp)
self._record_constraint("vertical", (line.id,), labels=("vrt",))
if "vrt" not in entity.constraints:
entity.constraints.append("vrt")
return True
def _point_line_signed_offset(
self, point_ent: OCCSketchEntity, line_ent: OCCSketchEntity
) -> float:
"""Signed perpendicular offset of *point_ent* from *line_ent*, in the
solver's sign convention — i.e. the ``valA`` that pins the point on
its current side of the line.
Mirrors SolveSpace's ``PT_LINE_DISTANCE`` equation exactly
(a = line start, b = line end, d = a - b):
proj = dv·(ua u) du·(va v), m = |d|
offset = proj / m
Returns 0.0 when the line is degenerate or the point lies on it
(the side is then undefined — callers fall back to the unsigned
value).
"""
if line_ent.id not in self._lines:
return 0.0
sid, eid2 = self._lines[line_ent.id]
s_ent = self._entities.get(sid)
e_ent = self._entities.get(eid2)
if s_ent is None or e_ent is None or not s_ent.geometry or not e_ent.geometry:
return 0.0
ua, va = s_ent.geometry
ub, vb = e_ent.geometry
u, v = point_ent.geometry
du = ua - ub
dv = va - vb
m = math.hypot(du, dv)
if m < 1e-12:
return 0.0
return (dv * (ua - u) - du * (va - v)) / m
def constrain_distance(
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
) -> bool:
"""Constrain distance between two entities.
python-solvespace's ``distance`` accepts point-point and point-line
(in that order) only, so line-point pairs are normalised and line-line
pairs are rejected with a warning instead of raising TypeError.
A point constrained to itself with a non-zero value is always
inconsistent — reject it up front so the UI never creates a constraint
the solver cannot satisfy.
"""
e1 = self._entities.get(entity1.id)
e2 = self._entities.get(entity2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
if e1 is e2 and e1.entity_type == "point" and distance != 0.0:
logger.warning("distance: refusing point-to-itself constraint")
return False
t1, t2 = e1.entity_type, e2.entity_type
if t1 == "line" and t2 == "line":
logger.warning(
"distance: line-to-line distance is not supported by the solver "
"(select a point and a line instead)"
)
return False
# python-solvespace only accepts (point, line) ordering.
if t1 == "line" and t2 == "point":
e1, e2 = e2, e1
entity1, entity2 = entity2, entity1
# Point-to-line distance is SIGNED in SolveSpace: the constraint
# equation is (signed perpendicular offset) = valA, so a positive
# valA always drives the point onto one fixed side of the line
# (for a vertical line, always +x). Pass a value whose sign
# matches the side the point is currently on so the constraint
# pins it there instead of flipping it across the line. The sign
# is recorded with the value so a solver rebuild reproduces the
# same side.
solver_value = distance
if e2.entity_type == "line" and e1.entity_type == "point" and distance != 0.0:
signed = self._point_line_signed_offset(e1, e2)
if abs(signed) > 1e-9:
solver_value = math.copysign(distance, signed)
self._solver.distance(e1.handle, e2.handle, solver_value, self._wp)
# Record in the normalised (point-first) order so replayed / legacy
# constraint logs are consistent.
self._record_constraint("distance", (entity1.id, entity2.id), (solver_value,))
return True
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
"""Constrain angle between two lines."""
e1 = self._entities.get(line1.id)
e2 = self._entities.get(line2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.angle(e1.handle, e2.handle, angle, self._wp)
self._record_constraint("angle", (line1.id, line2.id), (angle,))
return True
def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be parallel."""
e1 = self._entities.get(line1.id)
e2 = self._entities.get(line2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.parallel(e1.handle, e2.handle, self._wp)
self._record_constraint("parallel", (line1.id, line2.id))
return True
def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be perpendicular."""
e1 = self._entities.get(line1.id)
e2 = self._entities.get(line2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.perpendicular(e1.handle, e2.handle, self._wp)
self._record_constraint("perpendicular", (line1.id, line2.id))
return True
def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
"""Constrain a point to be at the midpoint of a line."""
pt = self._entities.get(point.id)
ln = self._entities.get(line.id)
if pt is None or ln is None or pt.handle is None or ln.handle is None:
return False
self._solver.midpoint(pt.handle, ln.handle, self._wp)
self._record_constraint("midpoint", (point.id, line.id), labels=("mid",))
if "mid" not in ln.constraints:
ln.constraints.append("mid")
return True
def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
"""Constrain two entities to be tangent."""
e1 = self._entities.get(entity1.id)
e2 = self._entities.get(entity2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.tangent(e1.handle, e2.handle, self._wp)
self._record_constraint("tangent", (entity1.id, entity2.id))
return True
def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to have equal length."""
e1 = self._entities.get(line1.id)
e2 = self._entities.get(line2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.equal(e1.handle, e2.handle, self._wp)
self._record_constraint("equal", (line1.id, line2.id), labels=("eql",))
return True
def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
"""Circle equal-radius (tracked only — solver limit)."""
self._record_constraint("equal_radius", (circle1.id, circle2.id))
return True
def constrain_diameter(self, circle: SketchEntity, diameter: float) -> bool:
"""Set the diameter of a circle."""
radius = diameter / 2.0
# Update the circle's radius in the sketch
if circle.id in self._circles:
center_id, _ = self._circles[circle.id]
self._circles[circle.id] = (center_id, radius)
# Update the entity geometry. Circle geometry is
# ``((cx, cy), old_radius)`` — keep the center, swap the radius.
ent = self._entities.get(circle.id)
if ent is not None and ent.geometry is not None:
if isinstance(ent.geometry[0], (tuple, list)):
(cx, cy), _old_radius = ent.geometry
else:
cx, cy = ent.geometry
ent.geometry = ((cx, cy), radius)
self._record_constraint("diameter", (circle.id,), (diameter,))
return True
def constrain_arc_diameter(self, arc: SketchEntity, diameter: float) -> bool:
"""Pin the diameter of an arc to a specific value.
Without this constraint an arc is implicit-radius: its diameter
is whatever the geometry needs it to be to keep
``distance(start, centre) = distance(end, centre)`` — perfect for
fillets that grow with the rectangle they're attached to. When
the user wants a quarter-circle of *exactly* N mm they pin it
with this method; afterwards the solver enforces the diameter
and our ``_arc_diameter_fixed`` set tells ``_sync_solved_positions``
to stop overwriting the stored radius.
"""
ent = self._entities.get(arc.id)
if ent is None or ent.handle is None or arc.id not in self._arcs:
return False
try:
d = float(diameter)
except (TypeError, ValueError) as e:
logger.error("Arc diameter must be numeric: %s", e)
return False
try:
self._solver.diameter(ent.handle, d)
except Exception as e:
logger.error("Arc diameter constraint failed: %s", e)
return False
radius = d / 2.0
self._arcs[arc.id]["radius"] = radius
if isinstance(ent.geometry, dict):
ent.geometry["radius"] = radius
self._arc_diameter_fixed.add(arc.id)
try:
self._record_constraint("arc_diameter", (arc.id,), (d,))
except Exception as e:
logger.error("Recording arc diameter constraint failed: %s", e)
return False
return True
def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place via dragged constraint."""
ent = self._entities.get(entity.id)
if ent is None or ent.handle is None:
return False
self._solver.dragged(ent.handle, self._wp)
self._record_constraint("fixed", (entity.id,))
return True
def is_entity_dragged(self, entity_id: int) -> bool:
"""True if the entity already has a ``dragged`` (fixed) constraint.
Used by the UI to avoid stacking duplicate ``dragged`` constraints
on the same point every time the user moves it — SolveSpace can
take several dragged constraints on the same point, but each one
bloats the constraint log without changing the locked position.
The user can still move the point later: a fresh
``set_entity_position`` updates the params and the existing
``dragged`` keeps the point at the new location on the next solve.
"""
for entry in self._constraint_log:
if entry["type"] == "fixed" and entity_id in entry["ids"]:
return True
return False
def constrain_symmetric(
self, entity1: SketchEntity, entity2: SketchEntity, line: SketchEntity
) -> bool:
"""Constrain symmetry about a line."""
e1 = self._entities.get(entity1.id)
e2 = self._entities.get(entity2.id)
ln = self._entities.get(line.id)
if e1 is None or e2 is None or ln is None:
return False
if e1.handle is None or e2.handle is None or ln.handle is None:
return False
self._solver.symmetric(e1.handle, e2.handle, ln.handle, self._wp)
self._record_constraint("symmetric", (entity1.id, entity2.id, line.id))
return True
# ─── Position updates (for moving entities) ──────────────────────────
def set_entity_position(self, entity: SketchEntity, x: float, y: float) -> bool:
"""Move a point entity's position in BOTH the solver (params) and local tracking.
Updating only ``entity.geometry`` is not enough — ``solve()`` reads from
the solver's internal parameter values and would revert the move. We push
the new coordinates into the solver via ``set_params`` so unconstrained
points keep their dragged location and constrained ones are recomputed.
"""
ent = self._entities.get(entity.id)
if ent is None or ent.handle is None:
return False
try:
self._solver.set_params(ent.handle.params, (x, y))
except Exception as e:
logger.debug(f"set_params failed for entity {entity.id}: {e}")
return False
ent.geometry = (x, y)
if entity.id in self._points:
self._points[entity.id] = (x, y)
return True
def set_positions(self, positions: Dict[int, Tuple[float, float]]) -> bool:
"""Bulk-apply new positions for a set of point entities (entity_id -> (x, y))."""
ok = True
for eid, (x, y) in positions.items():
ent = self._entities.get(eid)
if ent is None or ent.handle is None:
continue
try:
self._solver.set_params(ent.handle.params, (x, y))
ent.geometry = (x, y)
if eid in self._points:
self._points[eid] = (x, y)
except Exception as e:
logger.debug(f"set_positions failed for entity {eid}: {e}")
ok = False
return ok
# ─── Solving ───────────────────────────────────────────────────────────
def solve(self) -> bool:
"""Solve all constraints via SolveSpace solver.
Returns True on success, False if the solver returns a non-OKAY
result (INCONSISTENT, DIDNT_CONVERGE, TOO_MANY_UNKNOWNS). When
False, :attr:`last_solve_status` is set to a human-readable string
describing the failure so the UI can surface it to the user.
Callers that need to know *which* failure happened should use
:meth:`last_solve_result` (returns the raw :class:`ResultFlag`).
"""
try:
result = self._solver.solve()
self._last_solve_result = int(result)
if result == ResultFlag.OKAY:
# Sync solved positions back to entity geometries
self._sync_solved_positions()
self._last_solve_status = "ok"
return True
# Map SolveSpace's result enum to a one-line user-facing
# hint. INCONSISTENT is the most common and the most useful
# to call out: a new constraint conflicts with existing
# ones, so the geometry can't satisfy all of them.
status_map = {
int(ResultFlag.INCONSISTENT): (
"inconsistent: the new constraint conflicts with existing constraints"
),
int(ResultFlag.DIDNT_CONVERGE): (
"didn't converge: try simplifying the constraints or removing one"
),
int(ResultFlag.TOO_MANY_UNKNOWNS): (
"too many unknowns: the sketch is under-constrained"
),
}
self._last_solve_status = status_map.get(
int(result), f"failed (result code {int(result)})"
)
logger.warning(f"Solver returned: {result}{self._last_solve_status}")
return False
except Exception as e:
logger.error(f"Solver error: {e}")
self._last_solve_status = f"error: {e}"
self._last_solve_result = -1
return False
def last_solve_result(self) -> int:
"""Raw SolveSpace result code from the most recent :meth:`solve` call.
``0`` = OKAY, ``1`` = INCONSISTENT, ``2`` = DIDNT_CONVERGE,
``3`` = TOO_MANY_UNKNOWNS, ``-1`` if solve raised an exception.
Use :attr:`last_solve_status` for a human-readable version.
"""
return getattr(self, "_last_solve_result", 0)
@property
def last_solve_status(self) -> str:
"""One-line human-readable description of the most recent solve outcome.
``"ok"`` on success, or a short explanation of the failure
(e.g. ``"inconsistent: the new constraint conflicts with existing
constraints"``). Useful for status-bar messages when the solver
can't satisfy the current set of constraints.
"""
return getattr(self, "_last_solve_status", "ok")
def _sync_solved_positions(self) -> None:
"""Read solved point positions from solver and update entity geometries.
After syncing points and lines, also refreshes every arc's stored
radius from the current centre→start distance. This is what
makes a coincident-constrained arc follow the rectangle it's
attached to: as the start/end points move with the rectangle's
corners, the solver shifts the centre onto the perpendicular
bisector and the radius becomes the new centre-to-endpoint
distance. Without this refresh the renderer would still draw
the arc with the original radius and the visual would desync
from the constraints.
Arcs whose diameter has been explicitly pinned via
:meth:`constrain_arc_diameter` are skipped — the user wants
the diameter fixed and we must not overwrite it.
"""
import math as _math
for entity_id, entity in list(self._entities.items()):
if entity.entity_type == "point" and entity.handle is not None:
try:
x, y = self._solver.params(entity.handle.params)
entity.geometry = (x, y)
if entity_id in self._points:
self._points[entity_id] = (x, y)
except Exception as e:
logger.debug(f"Could not sync point {entity_id}: {e}")
elif entity.entity_type == "line" and entity_id in self._lines:
start_id, end_id = self._lines[entity_id]
start_entity = self._entities.get(start_id)
end_entity = self._entities.get(end_id)
if start_entity and end_entity and start_entity.geometry and end_entity.geometry:
entity.geometry = (start_entity.geometry, end_entity.geometry)
elif entity.entity_type == "arc" and entity_id in self._arcs:
if entity_id in self._arc_diameter_fixed:
# User has pinned the diameter; don't touch it.
continue
arc_data = self._arcs[entity_id]
center_id = arc_data.get("center")
start_id = arc_data.get("start")
end_id = arc_data.get("end")
center_ent = self._entities.get(center_id) if center_id is not None else None
start_ent = self._entities.get(start_id) if start_id is not None else None
end_ent = self._entities.get(end_id) if end_id is not None else None
if (
center_ent is not None
and start_ent is not None
and end_ent is not None
and center_ent.geometry is not None
and start_ent.geometry is not None
and end_ent.geometry is not None
):
cx, cy = center_ent.geometry
sx, sy = start_ent.geometry
ex, ey = end_ent.geometry
# ── Side lock: keep the arc on the side the user drew it on.
# The L2-norm minimisation SolveSpace uses to pick
# the new centre position can land on the *opposite*
# side of the chord from where the user originally
# drew the arc — for example, when a corner is
# dragged upward past the original centre, the chord
# ends up above the centre and the arc now bulges
# INTO the rectangle. The sign of the stored
# ``sweep`` encodes which side the centre is on
# (positive = CCW from start→end, negative = CW),
# so we use that to detect a side flip and mirror
# the centre across the chord midpoint to put it
# back on the correct side.
sa = _math.atan2(sy - cy, sx - cx)
ea = _math.atan2(ey - cy, ex - cx)
new_sweep = ea - sa
while new_sweep > _math.pi:
new_sweep -= 2 * _math.pi
while new_sweep < -_math.pi:
new_sweep += 2 * _math.pi
prev_sweep = arc_data.get("sweep")
if (
prev_sweep is not None
and prev_sweep != 0.0
and new_sweep != 0.0
and (prev_sweep * new_sweep) < 0.0
):
# Sign flipped — mirror the centre across the
# chord so the arc stays on the original side.
mid_x = (sx + ex) * 0.5
mid_y = (sy + ey) * 0.5
cx = 2.0 * mid_x - cx
cy = 2.0 * mid_y - cy
center_ent.geometry = (cx, cy)
# Recompute sweep with the mirrored centre.
sa = _math.atan2(sy - cy, sx - cx)
ea = _math.atan2(ey - cy, ex - cx)
new_sweep = ea - sa
while new_sweep > _math.pi:
new_sweep -= 2 * _math.pi
while new_sweep < -_math.pi:
new_sweep += 2 * _math.pi
new_radius = _math.dist((cx, cy), (sx, sy))
arc_data["radius"] = new_radius
arc_data["sweep"] = new_sweep
if isinstance(entity.geometry, dict):
entity.geometry["radius"] = new_radius
entity.geometry["center"] = (cx, cy)
entity.geometry["start"] = (sx, sy)
entity.geometry["end"] = (ex, ey)
entity.geometry["sweep"] = new_sweep
# ── Auto-scale: preserve the original sweep ──
# The user-drawn sweep (captured in
# ``original_sweep`` at add_arc time) describes the
# arc's *shape* — its angular span and which side
# of the chord the centre is on. When the host
# geometry (e.g. a rectangle the arc is attached
# to) resizes, the L2-minimising solver leaves the
# centre close to its previous position, which
# gives the *wrong* shape (the sweep drifts). If
# the centre is free, we override it with the
# position that exactly preserves the original
# sweep on the new chord — geometrically, this is
# the only well-defined choice for an arc whose
# start and end are constrained but whose radius
# should scale with the host.
original_sweep = arc_data.get("original_sweep")
if (
original_sweep is not None
and abs(original_sweep) > 1e-9
and not self._is_centre_constrained(center_id)
):
new_cx, new_cy = self._centre_for_sweep((sx, sy), (ex, ey), original_sweep)
if new_cx is not None:
# Push the new centre into the solver
# AND the entity geometry. The solver
# accepts set_params even after solve()
# because the centre is a free point and
# the arc constraint
# (|s-c| = |e-c|) is satisfied
# automatically when the centre sits on
# the perpendicular bisector.
assert new_cy is not None # both-or-neither from _centre_for_sweep
try:
self._solver.set_params(
center_ent.handle.params,
(new_cx, new_cy),
)
except Exception as e:
logger.debug("set_params for arc centre failed: %s", e)
center_ent.geometry = (new_cx, new_cy)
cx, cy = new_cx, new_cy
new_radius = _math.dist((cx, cy), (sx, sy))
arc_data["radius"] = new_radius
# Recompute the sweep from the new
# geometry — should equal original_sweep
# up to floating point.
sa = _math.atan2(sy - cy, sx - cx)
ea = _math.atan2(ey - cy, ex - cx)
new_sweep = ea - sa
while new_sweep > _math.pi:
new_sweep -= 2 * _math.pi
while new_sweep < -_math.pi:
new_sweep += 2 * _math.pi
arc_data["sweep"] = new_sweep
if isinstance(entity.geometry, dict):
entity.geometry["radius"] = new_radius
entity.geometry["center"] = (cx, cy)
entity.geometry["sweep"] = new_sweep
def _is_centre_constrained(self, centre_id: Optional[int]) -> bool:
"""True if *centre_id* is referenced by any constraint in the log.
Used by the arc auto-scale path in :meth:`_sync_solved_positions`
to avoid moving a centre that's locked by a coincident, fixed,
distance, or symmetric constraint — in those cases the user
has expressed an intent about where the centre should be, and
overriding it would silently break the constraint.
"""
if centre_id is None:
return True # Conservative: don't move a centre we can't identify.
for entry in self._constraint_log:
if centre_id in entry.get("ids", ()):
return True
return False
def _centre_for_sweep(
self,
start: Tuple[float, float],
end: Tuple[float, float],
sweep: float,
) -> Tuple[Optional[float], Optional[float]]:
"""Return the centre position that gives an arc a specific sweep on a chord.
Given two endpoints *start* and *end* and a signed sweep
*sweep* (positive = CCW from start→end, negative = CW), the
unique centre on the perpendicular bisector at the right
distance is::
d = |start end| / (2 * tan(|sweep| / 2))
C = midpoint ± d * normal
where *normal* is the unit vector 90° CCW from the chord
direction and the sign of *sweep* picks which side the centre
is on. Returns ``(None, None)`` for degenerate inputs (zero
chord, sweep ≥ π so d ≤ 0).
"""
import math as _math
sx, sy = start
ex, ey = end
dx = ex - sx
dy = ey - sy
chord_len = _math.hypot(dx, dy)
if chord_len < 1e-12:
return None, None
half_sweep = abs(sweep) * 0.5
# tan(π/2) is infinite — the arc is a semicircle and the centre
# sits on the chord. Skip rather than divide by zero.
if half_sweep >= _math.pi * 0.5 - 1e-9:
return None, None
d = chord_len / (2.0 * _math.tan(half_sweep))
# Left normal: rotate the chord direction 90° CCW. For a chord
# direction (dx, dy) the CCW perpendicular is (-dy, dx). With
# this convention, a POSITIVE sweep (CCW from start→end) places
# the centre on the +n side — i.e. the bulge is on the "left"
# of the chord, matching what the user sees when they draw the
# arc. Get the sign wrong and the centre lands on the wrong side
# and the stored ``original_sweep`` flips sign on the next solve.
nx = -dy / chord_len
ny = dx / chord_len
side = 1.0 if sweep > 0 else -1.0
mid_x = (sx + ex) * 0.5
mid_y = (sy + ey) * 0.5
return mid_x + d * side * nx, mid_y + d * side * ny
def get_solved_point(self, entity_id: int) -> Optional[Tuple[float, float]]:
"""Get the solved position of a point entity."""
entity = self._entities.get(entity_id)
if entity and entity.entity_type == "point" and entity.handle is not None:
try:
x, y = self._solver.params(entity.handle.params)
return (float(x), float(y))
except Exception:
pass
return None
def get_solved_param(self, handle: Any) -> Optional[Tuple[float, float]]:
"""Get solved params for a solver entity handle."""
try:
x, y = self._solver.params(handle.params)
return (float(x), float(y))
except Exception:
return None
# ─── Geometry extraction for operations ────────────────────────────────
def get_geometry(self) -> GeometryObject:
"""Get the solved geometry as an OCC ``TopoDS_Face`` on the workplane.
If the sketch has exactly one detected face (outer boundary + optional
holes) that face is returned. Otherwise falls back to returning a
single circle or polygon as a face. The returned object carries the
workplane normal in ``metadata["normal"]`` so the kernel can extrude
along the plane normal (not a hardcoded +Z).
"""
faces = self.detect_faces()
if len(faces) == 1:
return self.build_face_geometry(faces[0])
# Fallback: wrap the first non-external circle, or the polygon, as a
# single-loop face. External (underlay) circles are reference geometry
# and must not be returned as the extruded profile.
if self._circles:
for entity_id, (center_id, radius) in self._circles.items():
if entity_id in self._external_entity_ids:
continue
center_entity = self._entities.get(center_id)
circle_ent = self._entities.get(entity_id)
if center_entity and center_entity.geometry and not center_entity.is_external:
# Skip construction circles — they're reference geometry.
if circle_ent is not None and circle_ent.is_construction:
continue
cx, cy = center_entity.geometry
face_dict = {
"outer": {"type": "circle", "center": (cx, cy), "radius": radius},
"holes": [],
}
return self.build_face_geometry(face_dict)
points = self.get_polygon_points()
if not points:
return OCCGeometryObject(None)
face_dict = {
"outer": {"type": "polygon", "points": [(p.x, p.y) for p in points]},
"holes": [],
}
return self.build_face_geometry(face_dict)
def get_points(self) -> List[Point2D]:
"""Get all point positions from solved solver data."""
points: List[Point2D] = []
for entity_id, entity in self._entities.items():
if entity.entity_type == "point":
# Try to get solved position first
if entity.handle is not None:
try:
x, y = self._solver.params(entity.handle.params)
points.append(Point2D(x, y))
continue
except Exception:
pass
# Fall back to stored geometry
if entity.geometry:
x, y = entity.geometry
points.append(Point2D(x, y))
return points
def get_line_axis(self, line_id: int) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]:
"""Return ``((origin_x, origin_y, origin_z), (dir_x, dir_y, dir_z))`` for a line.
The axis is computed from the line's solved endpoints mapped into world
coordinates on the sketch workplane: origin = line start, direction =
normalized start→end. Returns ``None`` if the id is missing, not a
line, or degenerate. Used by the revolve tool and feature replay so
the revolve axis tracks the sketch line even after it is dragged.
"""
import math
ent = self._entities.get(line_id)
if ent is None or ent.entity_type != "line" or ent.is_external:
return None
geom = ent.geometry
if not geom or len(geom) != 2 or not geom[0] or not geom[1]:
return None
try:
start = self._uv_to_world(*geom[0])
end = self._uv_to_world(*geom[1])
except Exception:
return None
dx = end.X() - start.X()
dy = end.Y() - start.Y()
dz = end.Z() - start.Z()
length = math.sqrt(dx * dx + dy * dy + dz * dz)
if length < 1e-9:
return None
return (
(float(start.X()), float(start.Y()), float(start.Z())),
(dx / length, dy / length, dz / length),
)
def get_polygon_points(self) -> List[Point2D]:
"""Get ordered polygon points from connected lines (uses solved positions).
External (underlay) and construction lines are skipped — they are
reference geometry only, not part of the sketch profile.
"""
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
):
p1, p2 = entity.geometry
if p1 not in adjacency:
adjacency[p1] = []
if p2 not in adjacency:
adjacency[p2] = []
adjacency[p1].append(p2)
adjacency[p2].append(p1)
if not adjacency:
return []
ordered: List[Point2D] = []
visited: set = set()
current = next(iter(adjacency.keys()))
while current and tuple(current) not in visited:
ordered.append(Point2D(current[0], current[1]))
visited.add(tuple(current))
neighbors = adjacency.get(current, [])
next_point = None
for n in neighbors:
if tuple(n) not in visited:
next_point = n
break
current = next_point
if len(ordered) > 2:
ordered.append(ordered[0])
return ordered
# ─── Closed-loop / face detection (for region selection + holes) ──────
_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).
Returns both straight line segments AND tessellated arc segments so
that arcs participate in closed-loop / face detection. Construction
and external entities are excluded — they're reference geometry and
must not affect the sketch profile.
Tessellation density: roughly 12 segments per π radians of arc sweep,
which gives smooth-looking closed loops for face detection.
"""
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
# ── Straight line segments ──
for line_id, (sid, eid2) in self._lines.items():
if line_id in self._external_entity_ids:
continue
line_ent = self._entities.get(line_id)
if line_ent is not None and line_ent.is_construction:
continue
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])),
)
)
# ── Arc segments (tessellated) ──
for arc_id, arc_data in self._arcs.items():
arc_ent = self._entities.get(arc_id)
if arc_ent is not None and arc_ent.is_construction:
continue
center_id = arc_data.get("center")
start_id = arc_data.get("start")
end_id = arc_data.get("end")
radius = arc_data.get("radius", 0.0)
sweep = arc_data.get("sweep")
if sweep is None or radius <= 0:
continue
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
):
continue
cx, cy = c_ent.geometry
sx, sy = s_ent.geometry
start_angle = math.atan2(sy - cy, sx - cx)
# ~12 segments per π radians
n = max(4, int(abs(sweep) / (math.pi / 12)))
for i in range(n):
t1 = i / n
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))
segs.append((p1, p2))
return segs
def get_closed_loops(self) -> List[Dict[str, Any]]:
"""Detect closed loops: polygon cycles from connected lines + each circle.
Each loop is one of:
{"type": "polygon", "points": [(x,y), ...]} (closed, last == first)
{"type": "circle", "center": (x,y), "radius": r}
Line endpoint coordinates are snapped to ``_SNAP_TOL`` so a closed
rectangle's four corners join into one cycle even after solver floating
point jitter. Only connected components where every node has degree 2
(a simple closed polyline) are accepted as polygon loops.
"""
loops: List[Dict[str, Any]] = []
segs = self._line_segments()
if segs:
# Snap endpoints to integer-ish keys to group coincident points.
def key(pt):
return (round(pt[0] / self._SNAP_TOL), round(pt[1] / self._SNAP_TOL))
reprs: Dict[Any, Tuple[float, float]] = {} # key -> averaged world pt
edges: List[Tuple[Any, Any]] = []
for p1, p2 in segs:
k1, k2 = key(p1), key(p2)
reprs.setdefault(k1, p1)
reprs.setdefault(k2, p2)
edges.append((k1, k2))
# Undirected adjacency.
adj: Dict[Any, List[Any]] = {}
for a, b in edges:
adj.setdefault(a, []).append(b)
adj.setdefault(b, []).append(a)
# Connected components (each node with degree 2 → closed loop).
seen: set = set()
for start in adj:
if start in seen or len(adj[start]) != 2:
continue
# Walk the component.
comp: List[Any] = []
stack = [start]
comp_seen: set = set()
while stack:
n = stack.pop()
if n in comp_seen:
continue
comp_seen.add(n)
comp.append(n)
for nb in adj.get(n, []):
if nb not in comp_seen:
stack.append(nb)
if all(len(adj[n]) == 2 for n in comp) and len(comp) >= 3:
# Order the cycle by following each node's neighbor not yet visited.
ordered: List[Any] = []
cur = comp[0]
prev = None
for _ in range(len(comp)):
ordered.append(cur)
nbrs = [nb for nb in adj[cur] if nb != prev]
if not nbrs:
break
prev = cur
cur = nbrs[0]
if len(ordered) == len(comp):
pts = [reprs[k] for k in ordered]
pts.append(pts[0])
loops.append({"type": "polygon", "points": pts})
seen |= comp_seen
# Circles are closed loops of their own.
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),
}
)
return loops
@staticmethod
def _point_in_polygon(
pt: Tuple[float, float],
poly: List[Tuple[float, float]],
margin: float = 0.0,
) -> bool:
"""Ray-casting point-in-polygon test.
Returns *True* for points strictly inside the polygon. Points on
the boundary (within eps=1e-9) are *outside* by default so the
outer boundary of a nested shape doesn't falsely contain a hole's
rep point. When *margin* > 0, points that are within that many
world-unit of the boundary are also treated as inside — used by
``_loop_contains`` to prevent float rounding from breaking
thin-wall nesting detection.
"""
x, y = pt
eps = 1e-9 # strict boundary rejection
margin = float(margin)
n = len(poly)
inside = False
j = n - 1
for i in range(n):
xi, yi = poly[i]
xj, yj = poly[j]
# 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
):
# Check collinearity
cross = (x - xi) * (yj - yi) - (y - yi) * (xj - xi)
abs_cross = abs(cross)
if abs_cross < eps:
# Strictly on boundary — return False unless margin says otherwise.
if margin > 0 and abs_cross < margin:
pass # fall through to ray-cast below
else:
return False
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi + 1e-30) + xi):
inside = not inside
j = i
return inside
@staticmethod
def _loop_contains(inner: Dict[str, Any], outer: Dict[str, Any]) -> bool:
"""Does ``outer`` fully enclose ``inner``?
For polygon-polygon: checks that ALL vertices of ``inner`` are strictly
inside ``outer`` using ray-casting. This is robust for convex polygons
and avoids the representative-point issue where a large nested loop's
centroid lands inside an inner loop.
For circle-in-polygon: checks the circle centre is inside the polygon
(vertex check would be too strict for tessellated arc segments).
For circle-in-circle: checks distance between centres + inner radius
< outer radius + margin.
For polygon-in-circle: checks all polygon vertices are inside the
circle.
"""
eps = 1e-3
if outer["type"] == "circle":
ox, oy = outer["center"]
orad = outer["radius"]
if inner["type"] == "circle":
# Two circles: centre distance + inner radius < outer radius
dx = inner["center"][0] - ox
dy = inner["center"][1] - oy
return math.hypot(dx, dy) + inner["radius"] < orad + eps
else:
# Polygon in circle: all vertices inside
pts = inner["points"]
if len(pts) > 1 and pts[0] == pts[-1]:
pts = pts[:-1]
for pt in pts:
if math.hypot(pt[0] - ox, pt[1] - oy) > orad - eps:
return False
return True
else:
# outer is polygon
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)
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):
return False
return True
@staticmethod
def _loop_rep_point(loop: Dict[str, Any]) -> Tuple[float, float]:
"""An interior representative point inside a loop.
Only used for circle-in-polygon containment checks (polygon-in-polygon
uses all-vertex containment). Returns the centroid for polygons and
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"]
)
n = len(pts)
if n < 3:
return loop.get("center", (0.0, 0.0))
sx = sum(p[0] for p in pts) / n
sy = sum(p[1] for p in pts) / n
return (sx, sy)
return loop.get("center", (0.0, 0.0))
@staticmethod
def _loop_area(loop: Dict[str, Any]) -> float:
if loop["type"] == "circle":
return math.pi * loop["radius"] ** 2
pts = loop["points"]
if len(pts) < 4:
return 0.0
area = 0.0
n = len(pts) - 1 # last == first
for i in range(n):
x1, y1 = pts[i]
x2, y2 = pts[i + 1]
area += x1 * y2 - x2 * y1
return abs(area) / 2.0
def detect_faces(self) -> List[Dict[str, Any]]:
"""Build faces from closed loops using nesting depth.
Nesting rule (standard CAD even-odd): a loop's depth = number of other
loops that strictly contain it. Even-depth loops (0, 2, ...) are outer
boundaries (solid material); odd-depth loops directly inside them are
holes. So a rectangle (depth 0) wrapping a circle (depth 1) yields a face
that is the rectangle minus the circle — exactly the
"shape within a shape = closed without inner" behavior. A shape nested
inside a hole (depth 2) becomes its own solid face again.
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int}``.
"""
loops = self.get_closed_loops()
if not loops:
return []
depths: List[int] = []
for i, li in enumerate(loops):
d = 0
for j, lj in enumerate(loops):
if i != j and OCCSketch._loop_contains(li, lj):
d += 1
depths.append(d)
faces: List[Dict[str, Any]] = []
for i, outer in enumerate(loops):
if depths[i] % 2 != 0:
continue # only even-depth loops are outer boundaries
holes: List[Dict[str, Any]] = []
for j, inner in enumerate(loops):
if i == j:
continue
# directly nested: depth one greater, and outer contains inner.
if depths[j] == depths[i] + 1 and OCCSketch._loop_contains(inner, outer):
holes.append(inner)
faces.append({"outer": outer, "holes": holes, "depth": depths[i]})
return faces
def find_face_at(self, x: float, y: float) -> Optional[Dict[str, Any]]:
"""Return the face whose solid region (outer minus holes) contains (x, y)."""
pt = (x, y)
best: Optional[Dict[str, Any]] = None
best_area = float("inf")
for face in self.detect_faces():
outer = face["outer"]
if outer["type"] == "polygon":
if not OCCSketch._point_in_polygon(pt, outer["points"]):
continue
else:
cx, cy = outer["center"]
if not (math.hypot(pt[0] - cx, pt[1] - cy) < outer["radius"]):
continue
# Must not be inside any hole of this face.
in_hole = False
for h in face["holes"]:
if h["type"] == "polygon":
if OCCSketch._point_in_polygon(pt, h["points"]):
in_hole = True
break
else:
hcx, hcy = h["center"]
if math.hypot(pt[0] - hcx, pt[1] - hcy) < h["radius"]:
in_hole = True
break
if in_hole:
continue
area = OCCSketch._loop_area(outer)
if area < best_area:
best_area = area
best = face
return best
@staticmethod
def _loop_signed_area(loop: Dict[str, Any]) -> float:
"""Signed area of a loop. Positive = CCW, negative = CW.
Circles are treated as CCW (positive area) because
``gp_Circ`` / ``gp_Ax2`` creates edges with CCW parametric
direction when looking against the normal.
"""
if loop["type"] == "circle":
r = loop.get("radius", 0.0)
return math.pi * r * r # always positive (CCW)
pts = loop["points"]
if len(pts) < 3:
return 0.0
area = 0.0
n = len(pts) - 1 # last point == first for closed loops
for i in range(n):
x1, y1 = pts[i]
x2, y2 = pts[i + 1]
area += x1 * y2 - x2 * y1
return area / 2.0
def build_face_geometry(self, face: Dict[str, Any]) -> OCCGeometryObject:
"""Build an OCC face (outer boundary + inner holes) on the workplane.
Wires are constructed from UV coordinates mapped through
:meth:`_uv_to_world`, so the resulting ``TopoDS_Face`` lies on this
sketch's 3D plane (not necessarily XY). The returned object stores
the raw OCC face in ``.shape`` and the plane normal in
``metadata["normal"]`` for the extrude kernel.
Hole wires are oriented to have OPPOSITE geometric winding relative
to the outer wire, which is what OCC's face builder expects for
proper hole treatment. Previous code unconditionally reversed ALL
hole wires, which produced solid islands (not holes) whenever the
outer loop had clockwise winding — e.g. after dragging a rectangle
from top-left to bottom-right.
"""
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakePolygon,
BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakeWire,
BRepBuilderAPI_MakeEdge,
)
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"]:
mp.Add(self._uv_to_world(pu, pv))
mp.Close()
mp.Build()
return mp.Wire()
cu, cv = loop["center"]
r = loop["radius"]
circ = gp_Circ(self._circle_axis(cu, cv), r)
me = BRepBuilderAPI_MakeEdge(circ)
me.Build()
mw = BRepBuilderAPI_MakeWire()
mw.Add(me.Edge())
mw.Build()
return mw.Wire()
outer_loop = face["outer"]
outer_wire = _wire_from_loop(outer_loop)
outer_winding = self._loop_signed_area(outer_loop)
face_maker = BRepBuilderAPI_MakeFace(outer_wire, True)
for h in face["holes"]:
hole_wire = _wire_from_loop(h)
hole_winding = self._loop_signed_area(h)
# OCC expects hole wires to have OPPOSITE winding to the outer
# 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
):
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,
{
"type": "sketch_face",
"normal": self._wp_normal,
"origin": self._wp_origin,
},
)
return obj
def get_solver_dof(self) -> int:
"""Get remaining degrees of freedom from solver."""
return self._solver.dof()
def get_solver_failures(self) -> List[Any]:
"""Get list of failed constraints."""
return self._solver.failures()
# ─── Management ────────────────────────────────────────────────────────
def clear(self) -> None:
"""Clear all geometry and constraints from both solver and tracker."""
self._solver = SolverSystem()
self._wp = self._solver.create_2d_base()
self._entities.clear()
self._points.clear()
self._lines.clear()
self._circles.clear()
self._arcs.clear()
self._entity_counter = 0
self._constraint_count = 0
self._constraint_log.clear()
self._external_entity_ids.clear()
self._centerline_ids.clear()
self._first_point_id = None
# New solver = new work plane; cached normal_2d is now stale.
self._wp_normal_handle = None
self._arc_diameter_fixed.clear()
def _prune_log_for(self, removed_ids: set) -> None:
"""Drop constraint-log entries that reference any id in ``removed_ids``."""
kept_log: List[Dict[str, Any]] = []
for entry in self._constraint_log:
if not (set(entry["ids"]) & removed_ids):
kept_log.append(entry)
self._constraint_log = kept_log
self._constraint_count = len(kept_log)
def delete_line(self, line: SketchEntity) -> bool:
"""Delete a single line and recompute the surviving constraints.
python_solvespace has no API to remove an individual entity/constraint,
so this removes the line from local tracking, prunes any logged
constraint that referenced it, rebuilds the whole solver system from
the surviving points/lines + pruned log, and re-solves. The line's
endpoint points are NOT removed — only the line segment.
Note: centerlines (reference axes through origin) cannot be deleted.
"""
if line.id not in self._lines or line.id not in self._entities:
return False
# Centerlines are permanent reference axes — refuse deletion.
if line.id in self._centerline_ids:
logger.debug("Refusing to delete centerline")
return False
del self._lines[line.id]
if line.id in self._entities:
del self._entities[line.id]
# Prune log entries referencing the deleted line (labels are re-derived
# from the surviving log below, so no manual label stripping here).
self._prune_log_for({line.id})
self._rebuild_solver()
self._rebuild_labels()
return self.solve()
def remove_constraint_at(self, index: int) -> bool:
"""Remove a single constraint (by log index) and recompute the rest.
Used by the sketch widget when the user hovers a constraint tag and
presses Delete. Drops that one log entry, rebuilds the solver from the
surviving log, re-derives UI labels, and re-solves.
"""
if index < 0 or index >= len(self._constraint_log):
return False
del self._constraint_log[index]
self._constraint_count = len(self._constraint_log)
self._rebuild_solver()
self._rebuild_labels()
return self.solve()
def delete_point(self, point: SketchEntity) -> bool:
"""Delete a point, any lines that use it as an endpoint, and recompute.
Removing a point invalidates every line that references it (a line with
a missing endpoint is meaningless), so those lines are removed too.
All constraints that reference the point OR the removed lines are
pruned from the log, the solver is rebuilt from survivors, labels are
re-derived, and the system is re-solved.
Note: centerlines (reference axes through origin) cannot be deleted.
"""
if point.id not in self._entities or point.id not in self._points:
return False
# Centerline points are permanent reference anchors — refuse deletion.
if point.id in self._centerline_ids:
logger.debug("Refusing to delete centerline point")
return False
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())
if sid == point.id or eid2 == point.id
]
for lid in removed_line_keys:
removed_ids.add(lid)
del self._lines[lid]
if lid in self._entities:
del self._entities[lid]
# Remove the point itself.
del self._points[point.id]
if point.id in self._entities:
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
]
for cid in removed_circle_keys:
removed_ids.add(cid)
del self._circles[cid]
if cid in self._entities:
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())
if adata.get("center") == point.id
or adata.get("start") == point.id
or adata.get("end") == point.id
]
for aid in removed_arc_keys:
removed_ids.add(aid)
del self._arcs[aid]
if aid in self._entities:
del self._entities[aid]
# If a diameter-pinned arc is being torn down, clear its flag
# so the set doesn't grow stale.
self._arc_diameter_fixed.discard(aid)
self._prune_log_for(removed_ids)
self._rebuild_solver()
self._rebuild_labels()
return self.solve()
def _rebuild_labels(self) -> None:
"""Re-derive each entity's UI constraint labels from the surviving log.
paintEvent displays labels read off the endpoint POINT entities ("hrz",
"vrt", "mid", ...). After a delete, recompute them from scratch so a
removed line's labels don't linger on points that still belong to other
(unaffected) lines.
"""
for ent in self._entities.values():
ent.constraints = []
for entry in self._constraint_log:
labels = entry.get("labels") or set()
if not labels:
continue
ctype = entry["type"]
ids = entry["ids"]
targets: List[OCCSketchEntity] = []
if ctype in ("horizontal", "vertical"):
sid, eid2 = self._lines.get(ids[0], (None, None))
for pid in (sid, eid2):
if pid is not None and pid in self._entities:
targets.append(self._entities[pid])
elif ctype == "midpoint":
sid, eid2 = self._lines.get(ids[1], (None, None))
for pid in (sid, eid2):
if pid is not None and pid in self._entities:
targets.append(self._entities[pid])
if ids[0] in self._entities:
targets.append(self._entities[ids[0]])
else:
# distance / equal / parallel / etc.: tag referenced entities'
# endpoints (lines) or the points themselves.
for eid in ids:
if eid in self._lines:
sid, eid2 = self._lines[eid]
for pid in (sid, eid2):
if pid in self._entities:
targets.append(self._entities[pid])
elif eid in self._entities:
targets.append(self._entities[eid])
for t in targets:
for lbl in labels:
if lbl not in t.constraints:
t.constraints.append(lbl)
def delete_entity(self, entity: SketchEntity) -> bool:
"""Delete an entity and its constraints (no solver rebuild)."""
if entity.id not in self._entities:
return False
# Remove from solver (clear + rebuild is simplest)
# For simplicity, we skip solver removal — on next solve, stale handles
# will be ignored. A full rebuild would need entity-by-entity solver removal.
del self._entities[entity.id]
if entity.id in self._points:
del self._points[entity.id]
if entity.id in self._lines:
del self._lines[entity.id]
if entity.id in self._circles:
del self._circles[entity.id]
if entity.id in self._arcs:
del self._arcs[entity.id]
return True
def get_entity_count(self) -> int:
"""Get the number of entities in the sketch."""
return len(self._entities)
def get_constraint_count(self) -> int:
"""Get the number of constraints applied via solver."""
return self._constraint_count
def is_fully_constrained(self) -> bool:
"""Check if the sketch is fully constrained (0 DOF)."""
try:
return self._solver.dof() == 0
except Exception:
return False
# ─── Serialization (used by fluency.io.project_io) ─────────────────────
def to_dict(self) -> Dict[str, Any]:
"""Serialize the sketch to a plain-dict for JSON storage.
Captures: the workplane, every entity (with its current geometry and
flags), the constraint log, and the entity counter. Live solver
handles are intentionally NOT saved — the consumer must call
:meth:`from_dict` (or :meth:`rebuild_from_dict`) to rebuild the
SolveSpace system before solving again.
"""
# Sort entities by id so replay order is deterministic and matches
# creation order (ids are assigned monotonically by ``_next_id``).
entities_payload: List[Dict[str, Any]] = []
for eid in sorted(self._entities.keys()):
ent = self._entities[eid]
# Serialize line / circle / arc geometry DERIVED from the point
# entities they reference, not from ``ent.geometry``. A drag
# (set_entity_position) updates the point but not the line/circle
# stored geometry, so the raw attribute can be stale — writing it
# produces files whose lines/circles no longer match their
# endpoints, and the next load drops those entities.
geometry_payload = ent.geometry
if ent.entity_type == "line":
line_ref = self._lines.get(eid)
if line_ref is not None:
s_ent = self._entities.get(line_ref[0])
e_ent = self._entities.get(line_ref[1])
if (
s_ent is not None
and e_ent is not None
and s_ent.geometry is not None
and e_ent.geometry is not None
):
geometry_payload = (tuple(s_ent.geometry), tuple(e_ent.geometry))
elif ent.entity_type == "circle":
circle_ref = self._circles.get(eid)
if circle_ref is not None:
c_ent = self._entities.get(circle_ref[0])
if c_ent is not None and c_ent.geometry is not None:
try:
geometry_payload = (tuple(c_ent.geometry), float(circle_ref[1]))
except (TypeError, ValueError):
# Corrupt in-memory radius must not abort the save.
geometry_payload = ent.geometry
elif ent.entity_type == "arc" and isinstance(ent.geometry, dict):
arc_data = self._arcs.get(eid)
if arc_data is not None:
c_ent = self._entities.get(arc_data.get("center"))
s_ent = self._entities.get(arc_data.get("start"))
e_ent = self._entities.get(arc_data.get("end"))
if (
c_ent is not None
and s_ent is not None
and e_ent is not None
and c_ent.geometry is not None
and s_ent.geometry is not None
and e_ent.geometry is not None
):
try:
radius_val = float(
arc_data.get("radius", ent.geometry.get("radius", 0.0))
)
sweep_val = float(arc_data.get("sweep", ent.geometry.get("sweep", 0.0)))
except (TypeError, ValueError):
radius_val = ent.geometry.get("radius", 0.0)
sweep_val = ent.geometry.get("sweep", 0.0)
geometry_payload = {
"center": tuple(c_ent.geometry),
"start": tuple(s_ent.geometry),
"end": tuple(e_ent.geometry),
"radius": radius_val,
"sweep": sweep_val,
}
entities_payload.append(
{
"id": eid,
"type": ent.entity_type,
# geometry shape varies: point→(x,y), line→((x1,y1),(x2,y2)),
# circle→((cx,cy),r), arc→dict. All JSON-friendly.
"geometry": geometry_payload,
"is_construction": bool(ent.is_construction),
"is_external": bool(ent.is_external),
"constraints": list(ent.constraints),
}
)
# Sets become sorted lists for JSON. ``labels`` inside constraint_log
# is a set on the wire; convert to sorted list for JSON round-trip.
constraint_log_payload: List[Dict[str, Any]] = []
for entry in self._constraint_log:
constraint_log_payload.append(
{
"type": entry["type"],
"ids": list(entry["ids"]),
"params": list(entry["params"]),
"labels": sorted(entry["labels"]),
}
)
return {
"wp_origin": list(self._wp_origin),
"wp_normal": list(self._wp_normal),
"wp_x_dir": list(self._wp_x_dir),
"wp_y_dir": list(self._wp_y_dir),
"entity_counter": self._entity_counter,
"first_point_id": self._first_point_id,
"external_entity_ids": sorted(self._external_entity_ids),
"centerline_ids": sorted(self._centerline_ids),
"constraint_count": self._constraint_count,
"entities": entities_payload,
"constraint_log": constraint_log_payload,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "OCCSketch":
"""Build a fresh OCCSketch that reproduces the saved state.
Replays the construction sequence (points → lines → circles → arcs,
respecting external/centerline flags) and re-applies every constraint
in the saved log. The SolveSpace solver is deterministic for a given
input, so the post-solve state matches the saved one.
"""
sk = cls()
sk.rebuild_from_dict(data)
return sk
def rebuild_from_dict(self, data: Dict[str, Any]) -> None:
"""In-place restore from a dict produced by :meth:`to_dict`.
Wipes the current solver/state and re-creates every entity in id
order so :attr:`_first_point_id` is anchored correctly. Existing
callers (notably the solver-rebuild path on entity delete) don't use
this; only the project load path does.
Numeric coercions (``int``/``float``) and entity-replay calls are
wrapped in try/except: a single corrupt entry in a project file
(hand-edited, partially-written, from a different app version) must
not abort the whole load. The bad entry is logged and skipped so
the surviving geometry can still be loaded and used.
"""
# Wipe solver + trackers (don't lose the workplane yet — we set it
# explicitly below).
self.clear()
self._external_entity_ids.clear()
self._centerline_ids.clear()
# 1. Workplane.
self.set_workplane(
tuple(data["wp_origin"]),
tuple(data["wp_normal"]),
tuple(data["wp_x_dir"]),
)
# 2. Force the entity counter so the replay assigns the same ids as
# the saved sketch — the constraint log references those ids.
try:
self._entity_counter = int(data.get("entity_counter", 0))
except (TypeError, ValueError) as e:
logger.warning("entity_counter invalid (%s); starting at 0", e)
self._entity_counter = 0
# 3. Replay entities. Points are loaded in a first pass (in file
# order) and lines / circles / arcs in a second pass, so an
# endpoint reference can resolve by position even when the
# referenced point has a HIGHER id than the line (re-saved
# recovery points, hand-edited files). We need the
# OCCSketchEntity objects back (for arc center/start/end
# lookups), so we reconstruct by id and let ``_next_id`` advance
# the counter.
entities_by_id: Dict[int, OCCSketchEntity] = {}
# Pre-compute the centerline id set (step 5 below restores it after
# the loops) and the highest saved id. A legacy file can save a
# centerline whose axis point has stale coordinates, leaving the
# line's other endpoint unmatched; in that case we reconstruct the
# missing point with an id ABOVE every saved id so it can't collide
# with the entities still to load.
try:
centerline_ids_set = {int(x) for x in data.get("centerline_ids", [])}
except (TypeError, ValueError):
centerline_ids_set = set()
max_saved_id = 0
try:
max_saved_id = max(int(e["id"]) for e in data.get("entities", []))
except (TypeError, ValueError):
pass
def _replay_entry(entry: Dict[str, Any]) -> None:
"""Recreate one saved entity, preserving its id and flags."""
nonlocal entities_by_id
try:
eid = int(entry["id"])
except (TypeError, ValueError) as e:
logger.warning("Skipping entity with invalid id (%s): %r", e, entry)
return
# Ensure the next _next_id() call returns eid.
self._entity_counter = eid - 1
etype = entry["type"]
geom = entry.get("geometry")
is_external = bool(entry.get("is_external", False))
if geom is None:
logger.warning("Skipping entity %s during load: missing geometry", eid)
return
try:
if etype == "point":
try:
x, y = float(geom[0]), float(geom[1])
except (TypeError, ValueError, IndexError) as e:
logger.warning("Skipping point %s during load: bad geometry (%s)", eid, e)
return
if is_external:
ent = self.add_external_point(x, y)
else:
ent = self.add_point(x, y)
elif etype == "line":
# line geometry is ((x1,y1),(x2,y2)); the endpoints are
# point entities already loaded in pass 1. Look them up
# by saved position via _points. Older files can carry
# line geometry that is stale relative to the endpoint
# points (a drag without a subsequent solve), so fall
# back to the nearest point within a small world
# tolerance before giving up.
try:
(x1, y1), (x2, y2) = geom
except (TypeError, ValueError) as e:
logger.warning("Skipping line %s during load: bad geometry (%s)", eid, e)
return
s_id = self._find_point_at(x1, y1) or self._find_point_near(x1, y1)
e_id = self._find_point_at(x2, y2) or self._find_point_near(x2, y2)
if (s_id is None or e_id is None) and eid in centerline_ids_set:
# Centerline endpoint missing — older files saved the
# axis point with stale coordinates. Reconstruct it
# from the line's own geometry so the reference axis
# survives; allocate above every saved id to avoid
# colliding with entities that load afterwards.
self._entity_counter = max(max_saved_id, self._entity_counter)
if s_id is None:
s_ent = self.add_point(x1, y1)
s_ent.is_construction = True
s_id = s_ent.id
entities_by_id[s_id] = s_ent
self._centerline_ids.add(s_id)
if e_id is None:
e_ent = self.add_point(x2, y2)
e_ent.is_construction = True
e_id = e_ent.id
entities_by_id[e_id] = e_ent
self._centerline_ids.add(e_id)
# Recovery points were allocated above every saved
# id; re-point the counter at the line's own id so
# the line below keeps its saved id (and the next
# file entity re-points the counter anyway).
self._entity_counter = eid - 1
if s_id is None or e_id is None:
logger.warning("Skipping line %s during load: endpoints not found", eid)
return
if is_external:
ent = self.add_external_line(entities_by_id[s_id], entities_by_id[e_id])
else:
ent = self.add_line(entities_by_id[s_id], entities_by_id[e_id])
elif etype == "circle":
try:
(cx, cy), radius = geom
radius = float(radius)
except (TypeError, ValueError) as e:
logger.warning("Skipping circle %s during load: bad geometry (%s)", eid, e)
return
c_id = self._find_point_at(cx, cy) or self._find_point_near(cx, cy)
if c_id is None:
logger.warning("Skipping circle %s during load: center not found", eid)
return
ent = self.add_circle(entities_by_id[c_id], radius)
elif etype == "arc":
try:
center_pos = tuple(geom["center"])
start_pos = tuple(geom["start"])
end_pos = tuple(geom["end"])
radius = float(geom["radius"])
sweep = float(geom.get("sweep", 0.0))
except (TypeError, ValueError, KeyError) as e:
logger.warning("Skipping arc %s during load: bad geometry (%s)", eid, e)
return
c_id = self._find_point_at(*center_pos) or self._find_point_near(*center_pos)
s_id = self._find_point_at(*start_pos) or self._find_point_near(*start_pos)
e_id = self._find_point_at(*end_pos) or self._find_point_near(*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)
return
ent = self.add_arc(
entities_by_id[c_id],
radius,
entities_by_id[s_id],
entities_by_id[e_id],
sweep=sweep,
)
else:
logger.warning("Unknown sketch entity type %r; skipping", etype)
return
except Exception as e:
# Last-ditch guard: a single bad entity must not abort the
# whole load. Log and move on so the rest of the sketch
# can still be reconstructed.
logger.warning("Skipping entity %s during load: %s", eid, e)
return
# Restore the per-entity UI flags / labels that aren't carried
# by the add_* methods themselves.
ent.is_construction = bool(entry.get("is_construction", False))
ent.constraints = list(entry.get("constraints", []))
entities_by_id[eid] = ent
all_entries = data.get("entities", [])
# Pass 1: every point, so pass 2 can resolve endpoint references by
# position regardless of the id order in the file.
for entry in all_entries:
if entry.get("type") == "point":
_replay_entry(entry)
# Pass 2: lines, circles, arcs (id order preserved per entry).
for entry in all_entries:
if entry.get("type") != "point":
_replay_entry(entry)
# 4. Replay constraint log. ``_apply_constraint_log`` re-issues the
# solver call and pushes back into the entity tracker via
# ``entity.constraints``. We don't double-record into the log
# itself (the log was already cleared by ``clear()`` and we
# re-populate it here, so the live ``_constraint_count`` will
# reflect the saved state at the end).
for entry in data.get("constraint_log", []):
self._record_constraint(
entry["type"],
tuple(entry["ids"]),
tuple(entry.get("params", ())),
tuple(entry.get("labels", ())),
)
# Re-apply the live solver calls AFTER the log is restored, so that
# the constraint tracker matches the solver state on a fresh solve.
for entry in self._constraint_log:
self._apply_constraint_log(entry)
# 5. Restore external / centerline id sets. ``add_external_*`` adds
# to the set internally; if the entity's id was a regular entity
# for some reason (legacy / hand-edited file), fold it in too so
# the saved flag is authoritative.
for eid in data.get("external_entity_ids", []):
try:
self._external_entity_ids.add(int(eid))
except (TypeError, ValueError) as exc:
logger.warning("Skipping invalid external_entity_ids entry: %s", exc)
for eid in data.get("centerline_ids", []):
try:
self._centerline_ids.add(int(eid))
except (TypeError, ValueError) as exc:
logger.warning("Skipping invalid centerline_ids entry: %s", exc)
def _find_point_at(self, x: float, y: float, tol: float = 1e-6) -> Optional[int]:
"""Return the entity id of a point sitting at UV ``(x, y)`` (within tol)."""
for pid, pos in self._points.items():
if abs(pos[0] - x) < tol and abs(pos[1] - y) < tol:
return pid
return None
def _find_point_near(self, x: float, y: float, tol: float = _LOAD_POINT_TOL) -> Optional[int]:
"""Return the id of the point *closest* to ``(x, y)`` within ``tol``.
Fallback for ``_find_point_at`` when loading older project files
whose line/circle/arc geometry is stale relative to the point
entities (saved after a drag that was never re-solved). Points are
authoritative — the derived geometry just has to reach them.
"""
best_id: Optional[int] = None
best_d = tol
for pid, pos in self._points.items():
d = math.hypot(pos[0] - x, pos[1] - y)
if d < best_d:
best_d = d
best_id = pid
return best_id