"""Declarative constrained sketch objects for SimpleCADAPI. Sketches are intent-level 2D documents. Use sketch APIs to build sketch profiles; use concrete edge/wire APIs only for paths or pure geometry. """ from __future__ import annotations import math import uuid from dataclasses import dataclass, field from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union import numpy as np from .core import Edge, Face, TaggedMixin, TopoMixein, Wire from .expr import ScalarLike, evaluate_scalar _POINT_EPS = 1e-9 def _fresh_id(prefix: str, existing: Mapping[str, Any]) -> str: index = 0 while True: candidate = f"{prefix}_{index}" if candidate not in existing: return candidate index += 1 def _as_float(value: ScalarLike) -> float: return float(evaluate_scalar(value)) def _angle_delta(value: float) -> float: while value <= -math.pi: value += 2.0 * math.pi while value > math.pi: value -= 2.0 * math.pi return value @dataclass(frozen=True) class SketchEntity: """Serializable entity inside a declarative sketch.""" entity_id: str kind: str data: Dict[str, Any] = field(default_factory=dict) construction: bool = False def to_dict(self) -> Dict[str, Any]: payload = { "id": self.entity_id, "kind": self.kind, "construction": self.construction, } payload.update(self.data) return payload @dataclass(frozen=True) class SketchConstraint: """Serializable constraint inside a declarative sketch.""" constraint_id: str kind: str targets: Tuple[Dict[str, Any], ...] value: Any = None driving: bool = True metadata: Dict[str, Any] = field(default_factory=dict) def to_dict(self) -> Dict[str, Any]: payload: Dict[str, Any] = { "id": self.constraint_id, "kind": self.kind, "targets": [dict(target) for target in self.targets], "driving": bool(self.driving), } if self.value is not None: payload["value"] = self.value if self.metadata: payload["metadata"] = dict(self.metadata) return payload @dataclass(frozen=True) class SketchConstraintDiagnostic: """Diagnostic emitted by the sketch solver.""" constraint_id: Optional[str] severity: str code: str message: str residual: Optional[float] = None class SketchRef(TaggedMixin): """Stable reference to a sketch entity or subentity.""" def __init__( self, sketch_id: str, entity_id: str, *, kind: str, subentity: str = "geometry", ) -> None: super().__init__() self.sketch_id = str(sketch_id) self.entity_id = str(entity_id) self.kind = str(kind) self.subentity = str(subentity) def to_dict(self) -> Dict[str, str]: return { "sketch_id": self.sketch_id, "entity_id": self.entity_id, "kind": self.kind, "subentity": self.subentity, } @classmethod def from_dict(cls, data: Mapping[str, Any]) -> "SketchRef": return cls( str(data["sketch_id"]), str(data["entity_id"]), kind=str(data["kind"]), subentity=str(data.get("subentity", "geometry")), ) def __repr__(self) -> str: return ( "SketchRef(" f"sketch_id={self.sketch_id!r}, entity_id={self.entity_id!r}, " f"kind={self.kind!r}, subentity={self.subentity!r})" ) @dataclass class SketchSolveResult(TaggedMixin): """Result of solving a declarative sketch.""" sketch_id: str status: str dof: int residual_norm: float iterations: int solved_points: Dict[str, Tuple[float, float]] solved_scalars: Dict[str, float] diagnostics: Tuple[SketchConstraintDiagnostic, ...] = () def __post_init__(self) -> None: TaggedMixin.__init__(self) def to_dict(self) -> Dict[str, Any]: return { "sketch_id": self.sketch_id, "status": self.status, "dof": int(self.dof), "residual_norm": float(self.residual_norm), "iterations": int(self.iterations), "solved_points": { key: [float(value[0]), float(value[1])] for key, value in self.solved_points.items() }, "solved_scalars": dict(self.solved_scalars), "diagnostics": [diag.__dict__.copy() for diag in self.diagnostics], } class Sketch(TaggedMixin, TopoMixein): """Declarative constrained sketch container. Use `make_sketch_rsketch(...)`, `add_point_rsketch(...)`, `add_line_rsketch(...)`, `add_circle_rsketch(...)`, and `constrain_*_rsketch(...)` as the canonical API for building sketch profiles. Public sketch construction APIs are functional and return an updated `Sketch` document. The legacy `curves` constructor remains only for reading already-built wire/edge containers. """ def __init__( self, curves: Iterable[Edge | Wire] | None = None, *, name: Optional[str] = None, plane: Any = "XY", sketch_id: Optional[str] = None, ) -> None: TaggedMixin.__init__(self) TopoMixein.__init__(self, level=2, self_shape_ref=self) self.sketch_id = str(sketch_id or f"sketch_{uuid.uuid4().hex[:8]}") self.name = name self.plane = plane self.entities: Dict[str, SketchEntity] = {} self.entity_order: List[str] = [] self.constraints: List[SketchConstraint] = [] self._last_solve_result: Optional[SketchSolveResult] = None if curves is not None: for curve in curves: self.add_curve(curve) def add_curve(self, curve: Edge | Wire) -> "Sketch": if not isinstance(curve, (Edge, Wire)): raise ValueError("Sketch only supports Edge or Wire curve inputs") self.add_child(curve) return self def curves(self) -> List[Edge | Wire]: return list(self.get_children()) def closed_wires(self) -> List[Wire]: result: List[Wire] = [] for curve in self.curves(): if isinstance(curve, Wire) and curve.is_closed(): result.append(curve) return result def to_faces(self) -> List[Face]: if self.entities: return [self.to_face()] from .operations import make_face_from_wire_rface return [make_face_from_wire_rface(wire) for wire in self.closed_wires()] def to_face(self, profile: int | str = 0) -> Face: from .operations import make_face_from_sketch_rface return make_face_from_sketch_rface(self, profile=profile) def clone(self, *, include_solve: bool = True) -> "Sketch": cloned = Sketch(name=self.name, plane=self.plane, sketch_id=self.sketch_id) cloned._tags = self._tags.copy() cloned._metadata = self._metadata.copy() cloned.entities = dict(self.entities) cloned.entity_order = list(self.entity_order) cloned.constraints = list(self.constraints) cloned._last_solve_result = self._last_solve_result if include_solve else None for curve in self.curves(): cloned.add_curve(curve) return cloned def to_dict(self) -> Dict[str, Any]: return { "sketch_id": self.sketch_id, "name": self.name, "plane": self.plane, "entities": [self.entities[key].to_dict() for key in self.entity_order], "constraints": [constraint.to_dict() for constraint in self.constraints], } @classmethod def from_dict(cls, data: Mapping[str, Any]) -> "Sketch": sketch = cls( name=data.get("name"), plane=data.get("plane", "XY"), sketch_id=str(data.get("sketch_id") or data.get("name") or "sketch"), ) for entity_data in data.get("entities", []): entity_id = str(entity_data["id"]) kind = str(entity_data["kind"]) construction = bool(entity_data.get("construction", False)) payload = dict(entity_data) payload.pop("id", None) payload.pop("kind", None) payload.pop("construction", None) sketch._add_entity( SketchEntity(entity_id, kind, payload, construction=construction) ) for constraint_data in data.get("constraints", []): sketch.constraints.append( SketchConstraint( constraint_id=str(constraint_data["id"]), kind=str(constraint_data["kind"]), targets=tuple(dict(target) for target in constraint_data.get("targets", [])), value=constraint_data.get("value"), driving=bool(constraint_data.get("driving", True)), metadata=dict(constraint_data.get("metadata", {})), ) ) return sketch def ref(self, entity_id: str, *, kind: Optional[str] = None, subentity: str = "geometry") -> SketchRef: if entity_id not in self.entities: raise ValueError(f"Unknown sketch entity '{entity_id}'") entity = self.entities[entity_id] return SketchRef(self.sketch_id, entity_id, kind=kind or entity.kind, subentity=subentity) def point_ref(self, path: str) -> SketchRef: if "." in path: entity_id, subentity = path.split(".", 1) if entity_id not in self.entities: raise ValueError(f"Unknown sketch entity '{entity_id}'") entity = self.entities[entity_id] valid_subentities = { "line": {"start", "end"}, "circle": {"center"}, "arc": {"start", "end"}, "bspline": {"start", "end"}, }.get(entity.kind, set()) if subentity not in valid_subentities: raise ValueError( f"Sketch entity '{entity_id}' has no point subentity '{subentity}'" ) return SketchRef(self.sketch_id, entity_id, kind="point", subentity=subentity) if path not in self.entities: raise ValueError(f"Unknown sketch point '{path}'") entity = self.entities[path] if entity.kind != "point": raise ValueError(f"Sketch entity '{path}' is kind '{entity.kind}', not 'point'") return SketchRef(self.sketch_id, path, kind="point") def resolve_target( self, target: Union[SketchRef, str], *, expected: Optional[Union[str, Sequence[str]]] = None, ) -> SketchRef: if isinstance(expected, str): expected_kinds = {expected} elif expected is None: expected_kinds = set() else: expected_kinds = {str(item) for item in expected} if isinstance(target, SketchRef): ref = target elif isinstance(target, str): if "." in target or expected_kinds == {"point"}: ref = self.point_ref(target) else: if target not in self.entities: raise ValueError(f"Unknown sketch entity '{target}'") entity = self.entities[target] ref = self.point_ref(target) if entity.kind == "point" else self.ref(target) else: raise TypeError("Sketch targets must be SketchRef or string ids") self.validate_ref(ref) if expected_kinds and ref.kind not in expected_kinds: expected_label = ", ".join(sorted(expected_kinds)) raise ValueError( f"Sketch target '{ref.entity_id}' is kind '{ref.kind}', expected {expected_label}" ) return ref def add_point(self, point_id: str, x: ScalarLike, y: ScalarLike) -> SketchRef: self._add_entity(SketchEntity(point_id, "point", {"x": x, "y": y})) return self.point_ref(point_id) def add_line( self, entity_id: str, start: SketchRef, end: SketchRef, *, construction: bool = False, ) -> "Sketch": start_id = self.resolve_point_id(start) end_id = self.resolve_point_id(end) if start_id == end_id: raise ValueError("A sketch line requires two distinct point refs") self._add_entity( SketchEntity( entity_id, "line", {"start": start_id, "end": end_id}, construction=construction, ) ) return self def add_circle( self, entity_id: str, center: SketchRef, radius: ScalarLike, *, construction: bool = False, ) -> "Sketch": center_id = self.resolve_point_id(center) if _as_float(radius) <= 0.0: raise ValueError("A sketch circle radius must be positive") self._add_entity( SketchEntity( entity_id, "circle", {"center": center_id, "radius": radius}, construction=construction, ) ) return self def add_arc( self, entity_id: str, start: SketchRef, end: SketchRef, center: SketchRef, *, construction: bool = False, ) -> "Sketch": """Add an arc edge defined by start point, end point, and center point.""" start_id = self.resolve_point_id(start) end_id = self.resolve_point_id(end) center_id = self.resolve_point_id(center) if start_id == end_id: raise ValueError("A sketch arc requires two distinct endpoint refs") self._add_entity( SketchEntity( entity_id, "arc", {"start": start_id, "end": end_id, "center": center_id}, construction=construction, ) ) return self def add_bspline( self, entity_id: str, start: SketchRef, end: SketchRef, control_points: Sequence[Sequence[float]], degree: int = 3, knots: Optional[Sequence[float]] = None, multiplicities: Optional[Sequence[int]] = None, weights: Optional[Sequence[float]] = None, periodic: bool = False, *, construction: bool = False, ) -> "Sketch": """Add a B-spline curve edge defined by control points. The start/end point refs link the B-spline into the profile loop. Control points are stored as literal 2-D coordinates (not point entity ids) so the solver does not modify them. """ start_id = self.resolve_point_id(start) end_id = self.resolve_point_id(end) if start_id == end_id: raise ValueError("A sketch bspline requires two distinct endpoint refs") if len(control_points) < degree + 1: raise ValueError(f"bspline requires at least degree+1 control points, got {len(control_points)}") literal_cps = [[float(p[0]), float(p[1])] for p in control_points] self._add_entity( SketchEntity( entity_id, "bspline", { "start": start_id, "end": end_id, "control_points": literal_cps, "degree": int(degree), "knots": list(knots) if knots is not None else None, "multiplicities": list(multiplicities) if multiplicities is not None else None, "weights": list(weights) if weights is not None else None, "periodic": bool(periodic), }, construction=construction, ) ) return self def add_constraint( self, kind: str, targets: Sequence[SketchRef], *, value: Any = None, constraint_id: Optional[str] = None, driving: bool = True, metadata: Optional[Dict[str, Any]] = None, ) -> "Sketch": for target in targets: self.validate_ref(target) existing = {constraint.constraint_id: constraint for constraint in self.constraints} cid = constraint_id or _fresh_id(f"c_{kind}", existing) if cid in existing: raise ValueError(f"Duplicate sketch constraint id '{cid}'") self.constraints.append( SketchConstraint( constraint_id=cid, kind=str(kind), targets=tuple(target.to_dict() for target in targets), value=value, driving=driving, metadata=dict(metadata or {}), ) ) return self def solve( self, *, require_fully_constrained: bool = False, strict: bool = True, tolerance: float = 1e-7, max_iterations: int = 80, ) -> SketchSolveResult: result = _SketchSolver(self, tolerance=tolerance, max_iterations=max_iterations).solve() self._last_solve_result = result if strict and result.status in {"conflicting", "failed"}: raise ValueError( f"Sketch solve failed with status={result.status}, residual={result.residual_norm:.6g}" ) if require_fully_constrained and result.dof > 0: raise ValueError(f"Sketch is underconstrained with {result.dof} remaining DOF") return result def solved_result(self) -> SketchSolveResult: if self._last_solve_result is None: return self.solve(strict=True) return self._last_solve_result def make_wire( self, profile: int | str = 0, *, solve_result: Optional[SketchSolveResult] = None, ) -> Wire: profile_payload = self._profile_payload(profile, solve_result=solve_result) return self._wire_from_profile_payload(profile_payload) def _wire_from_profile_payload(self, profile_payload: Mapping[str, Any]) -> Wire: from .operations import make_circle_redge, make_line_redge, make_wire_from_edges_rwire if profile_payload["kind"] == "circle": center = profile_payload["center"] edge = make_circle_redge(center, profile_payload["radius"], profile_payload["normal"]) return make_wire_from_edges_rwire([edge]) if profile_payload["kind"] == "line_loop": points = profile_payload["points"] edges = [ make_line_redge(points[index], points[(index + 1) % len(points)]) for index in range(len(points)) ] return make_wire_from_edges_rwire(edges) if profile_payload["kind"] == "edge_loop": return self._wire_from_edge_loop(profile_payload) raise ValueError(f"Unsupported sketch profile kind '{profile_payload['kind']}'") def _wire_from_edge_loop(self, profile_payload: Mapping[str, Any]) -> Wire: """Build a wire from a mixed-edge profile (line + arc + bspline).""" from .operations import ( make_angle_arc_redge, make_line_redge, make_spline_redge, make_wire_from_edges_rwire, ) entity_ids = profile_payload["entity_ids"] result: SketchSolveResult = profile_payload["solve_result"] # Build a point_id → 3-D coordinate map from the solve result # (includes ALL points, not just loop vertices — needed for arc centers) point_map: Dict[str, Tuple[float, float, float]] = {} for pid, pt in result.solved_points.items(): point_map[pid] = self._point3(pt) edges = [] for eid in entity_ids: entity = self.entities[eid] if entity.kind == "line": start_id, end_id = str(entity.data["start"]), str(entity.data["end"]) edges.append(make_line_redge(point_map[start_id], point_map[end_id])) elif entity.kind == "arc": start_id = str(entity.data["start"]) end_id = str(entity.data["end"]) center_id = str(entity.data["center"]) sp = point_map[start_id] ep = point_map[end_id] cp = point_map[center_id] import math as _math start_angle = _math.atan2(sp[1] - cp[1], sp[0] - cp[0]) end_angle = _math.atan2(ep[1] - cp[1], ep[0] - cp[0]) radius = _math.hypot(sp[0] - cp[0], sp[1] - cp[1]) if abs(end_angle - start_angle) < 1e-12: end_angle += 2.0 * _math.pi edges.append( make_angle_arc_redge( center=cp, radius=radius, start_angle=start_angle, end_angle=end_angle, normal=(0.0, 0.0, 1.0), ) ) elif entity.kind == "bspline": cps_2d = entity.data["control_points"] degree = int(entity.data.get("degree", 3)) knots = entity.data.get("knots") multiplicities = entity.data.get("multiplicities") weights = entity.data.get("weights") periodic = bool(entity.data.get("periodic", False)) cps_3d = [(p[0], p[1], 0.0) for p in cps_2d] edges.append( make_spline_redge( control_points=cps_3d, degree=degree, knots=knots, multiplicities=multiplicities, weights=weights, periodic=periodic, ) ) else: raise ValueError(f"Unsupported edge kind '{entity.kind}' in edge_loop profile") return make_wire_from_edges_rwire(edges) def make_face( self, profile: int | str = 0, *, solve_result: Optional[SketchSolveResult] = None, ) -> Face: from .operations import make_face_from_wire_rface wire = self.make_wire(profile=profile, solve_result=solve_result) return make_face_from_wire_rface(wire, normal=self._plane_normal_tuple()) def _add_entity(self, entity: SketchEntity) -> None: if entity.entity_id in self.entities: raise ValueError(f"Duplicate sketch entity id '{entity.entity_id}'") self.entities[entity.entity_id] = entity self.entity_order.append(entity.entity_id) def validate_ref(self, ref: SketchRef) -> None: if not isinstance(ref, SketchRef): raise TypeError("Sketch constraints require SketchRef targets") if ref.sketch_id != self.sketch_id: raise ValueError("SketchRef belongs to a different sketch") if ref.entity_id not in self.entities: raise ValueError(f"Unknown sketch entity '{ref.entity_id}'") if ref.kind == "point": self.resolve_point_id(ref) elif ref.kind in {"line", "circle", "arc", "bspline"}: entity = self.entities[ref.entity_id] if entity.kind != ref.kind: raise ValueError( f"SketchRef '{ref.entity_id}' is kind '{entity.kind}', not '{ref.kind}'" ) def resolve_point_id(self, ref: SketchRef) -> str: if ref.sketch_id != self.sketch_id: raise ValueError("SketchRef belongs to a different sketch") if ref.entity_id not in self.entities: raise ValueError(f"Unknown sketch entity '{ref.entity_id}'") entity = self.entities[ref.entity_id] if ref.kind == "point" and entity.kind == "point": return ref.entity_id if entity.kind == "line" and ref.subentity in {"start", "end"}: return str(entity.data[ref.subentity]) if entity.kind == "circle" and ref.subentity == "center": return str(entity.data["center"]) if entity.kind in {"arc", "bspline"} and ref.subentity in {"start", "end"}: return str(entity.data[ref.subentity]) raise ValueError(f"Cannot resolve {ref!r} to a sketch point") def _constraint_refs(self, constraint: SketchConstraint) -> List[SketchRef]: return [SketchRef.from_dict(target) for target in constraint.targets] def _plane_frame(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: plane = self.plane if isinstance(plane, str): token = plane.upper() if token == "XY": return ( np.array([0.0, 0.0, 0.0]), np.array([1.0, 0.0, 0.0]), np.array([0.0, 1.0, 0.0]), np.array([0.0, 0.0, 1.0]), ) if token == "XZ": return ( np.array([0.0, 0.0, 0.0]), np.array([1.0, 0.0, 0.0]), np.array([0.0, 0.0, 1.0]), np.array([0.0, -1.0, 0.0]), ) if token == "YZ": return ( np.array([0.0, 0.0, 0.0]), np.array([0.0, 1.0, 0.0]), np.array([0.0, 0.0, 1.0]), np.array([1.0, 0.0, 0.0]), ) if isinstance(plane, Mapping): origin = np.array(plane.get("origin", (0.0, 0.0, 0.0)), dtype=float) x_axis = np.array(plane.get("x_axis", (1.0, 0.0, 0.0)), dtype=float) y_axis = np.array(plane.get("y_axis", (0.0, 1.0, 0.0)), dtype=float) x_axis = x_axis / np.linalg.norm(x_axis) y_axis = y_axis / np.linalg.norm(y_axis) normal = np.cross(x_axis, y_axis) normal = normal / np.linalg.norm(normal) return origin, x_axis, y_axis, normal raise ValueError("Sketch plane must be 'XY', 'XZ', 'YZ', or a plane mapping") def _point3(self, point: Tuple[float, float]) -> Tuple[float, float, float]: origin, x_axis, y_axis, _normal = self._plane_frame() vec = origin + float(point[0]) * x_axis + float(point[1]) * y_axis return (float(vec[0]), float(vec[1]), float(vec[2])) def _plane_normal_tuple(self) -> Tuple[float, float, float]: _origin, _x_axis, _y_axis, normal = self._plane_frame() return (float(normal[0]), float(normal[1]), float(normal[2])) def _profile_payload( self, profile: int | str = 0, *, solve_result: Optional[SketchSolveResult] = None, ) -> Dict[str, Any]: result = solve_result or self.solved_result() profiles = self._profiles_from_solution(result) if not profiles: raise ValueError("Sketch does not contain a closed non-construction profile") if isinstance(profile, str): for item in profiles: if item.get("id") == profile: return item raise ValueError(f"Unknown sketch profile '{profile}'") index = int(profile) if index < 0 or index >= len(profiles): raise ValueError(f"Sketch profile index {index} is out of range") return profiles[index] def _profiles_from_solution(self, result: SketchSolveResult) -> List[Dict[str, Any]]: profiles: List[Dict[str, Any]] = [] for entity_id in self.entity_order: entity = self.entities[entity_id] if entity.construction: continue if entity.kind == "circle": center_id = str(entity.data["center"]) scalar_key = f"circle:{entity_id}:radius" center = result.solved_points[center_id] profiles.append( { "id": entity_id, "kind": "circle", "entity_ids": [entity_id], "center": self._point3(center), "radius": float(result.solved_scalars[scalar_key]), "normal": self._plane_normal_tuple(), } ) profiles.extend(self._edge_loop_profiles(result)) return profiles # --- Edge kinds that participate in closed-loop profiles --- _EDGE_KINDS = frozenset({"line", "arc", "bspline"}) @staticmethod def _edge_endpoints(entity: SketchEntity) -> Tuple[str, str]: """Extract (start_point_id, end_point_id) from any edge entity.""" return str(entity.data["start"]), str(entity.data["end"]) def _edge_loop_profiles(self, result: SketchSolveResult) -> List[Dict[str, Any]]: edge_ids = [ entity_id for entity_id in self.entity_order if self.entities[entity_id].kind in self._EDGE_KINDS and not self.entities[entity_id].construction ] unused = set(edge_ids) profiles: List[Dict[str, Any]] = [] while unused: first_edge = min(unused, key=self.entity_order.index) component = self._edge_component(first_edge, unused) unused.difference_update(component) ordered = self._ordered_edge_loop(component) if ordered is None: continue point_ids, ordered_edge_ids = ordered profiles.append( { "id": component[0], "kind": "edge_loop", "entity_ids": list(ordered_edge_ids), "point_ids": list(point_ids), "points": [self._point3(result.solved_points[pid]) for pid in point_ids], "solve_result": result, } ) return profiles def _edge_component(self, first_edge: str, candidates: set[str]) -> List[str]: queue = [first_edge] seen_edges: set[str] = set() seen_points: set[str] = set() while queue: edge_id = queue.pop(0) if edge_id in seen_edges: continue seen_edges.add(edge_id) entity = self.entities[edge_id] for point_id in self._edge_endpoints(entity): if point_id in seen_points: continue seen_points.add(point_id) for other_id in candidates: other = self.entities[other_id] if point_id in set(self._edge_endpoints(other)): queue.append(other_id) return sorted(seen_edges, key=self.entity_order.index) def _ordered_edge_loop(self, edge_ids: Sequence[str]) -> Optional[Tuple[List[str], List[str]]]: adjacency: Dict[str, List[str]] = {} for edge_id in edge_ids: entity = self.entities[edge_id] start, end = self._edge_endpoints(entity) adjacency.setdefault(start, []).append(edge_id) adjacency.setdefault(end, []).append(edge_id) if not adjacency or any(len(edges) != 2 for edges in adjacency.values()): return None start_edge = edge_ids[0] entity = self.entities[start_edge] start_point, current_point = self._edge_endpoints(entity) used_edges = {start_edge} ordered_points = [start_point, current_point] ordered_edge_ids = [start_edge] while current_point != start_point: options = [eid for eid in adjacency[current_point] if eid not in used_edges] if not options: return None next_edge = options[0] used_edges.add(next_edge) ordered_edge_ids.append(next_edge) next_entity = self.entities[next_edge] next_start, next_end = self._edge_endpoints(next_entity) current_point = next_end if next_start == current_point else next_start if current_point != start_point: ordered_points.append(current_point) if len(used_edges) > len(edge_ids): return None if len(used_edges) != len(edge_ids): return None return ordered_points, ordered_edge_ids class _SketchSolver: def __init__(self, sketch: Sketch, *, tolerance: float, max_iterations: int) -> None: self.sketch = sketch self.tolerance = float(tolerance) self.max_iterations = int(max_iterations) self.point_ids = [ entity_id for entity_id in sketch.entity_order if sketch.entities[entity_id].kind == "point" ] self.scalar_ids = [ f"circle:{entity_id}:radius" for entity_id in sketch.entity_order if sketch.entities[entity_id].kind == "circle" ] self.var_names = [f"point:{pid}:x" for pid in self.point_ids] self.var_names.extend(f"point:{pid}:y" for pid in self.point_ids) self.var_names.extend(self.scalar_ids) def solve(self) -> SketchSolveResult: if not self.var_names: return SketchSolveResult( sketch_id=self.sketch.sketch_id, status="solved", dof=0, residual_norm=0.0, iterations=0, solved_points={}, solved_scalars={}, ) x = self._initial_vector() diagnostics: List[SketchConstraintDiagnostic] = [] iterations = 0 residual = self._residual_vector(x) best_norm = float(np.linalg.norm(residual)) damping = 1e-6 for iterations in range(self.max_iterations): if best_norm <= self.tolerance: break jacobian = self._finite_difference_jacobian(x, residual) lhs = jacobian.T @ jacobian + damping * np.eye(len(x)) rhs = -(jacobian.T @ residual) try: step = np.linalg.solve(lhs, rhs) except np.linalg.LinAlgError: step = np.linalg.lstsq(lhs, rhs, rcond=None)[0] if not np.all(np.isfinite(step)): diagnostics.append( SketchConstraintDiagnostic(None, "error", "nonfinite_step", "Sketch solver produced a non-finite step.") ) break accepted = False scale = 1.0 while scale >= 1e-4: candidate = x + scale * step candidate_residual = self._residual_vector(candidate) candidate_norm = float(np.linalg.norm(candidate_residual)) if candidate_norm <= best_norm: x = candidate residual = candidate_residual best_norm = candidate_norm accepted = True damping = max(damping * 0.5, 1e-12) break scale *= 0.5 if not accepted: damping = min(damping * 10.0, 1e6) final_jacobian = self._finite_difference_jacobian(x, residual) rank = int(np.linalg.matrix_rank(final_jacobian, tol=1e-7)) if final_jacobian.size else 0 dof = max(0, len(x) - rank) if len(residual) > rank and best_norm <= self.tolerance: diagnostics.append( SketchConstraintDiagnostic(None, "warning", "redundant_constraints", "Sketch has redundant but consistent constraints.") ) if best_norm > self.tolerance: status = "conflicting" diagnostics.append( SketchConstraintDiagnostic(None, "error", "residual_too_large", "Sketch constraints could not be satisfied.", best_norm) ) elif dof > 0: status = "underconstrained" diagnostics.append( SketchConstraintDiagnostic(None, "warning", "underconstrained", f"Sketch has {dof} remaining DOF.") ) else: status = "solved" points, scalars = self._state_from_vector(x) return SketchSolveResult( sketch_id=self.sketch.sketch_id, status=status, dof=dof, residual_norm=best_norm, iterations=iterations, solved_points=points, solved_scalars=scalars, diagnostics=tuple(diagnostics), ) def _initial_vector(self) -> np.ndarray: values: List[float] = [] for point_id in self.point_ids: entity = self.sketch.entities[point_id] values.append(_as_float(entity.data["x"])) for point_id in self.point_ids: entity = self.sketch.entities[point_id] values.append(_as_float(entity.data["y"])) for scalar_id in self.scalar_ids: _prefix, entity_id, _name = scalar_id.split(":", 2) entity = self.sketch.entities[entity_id] values.append(_as_float(entity.data["radius"])) return np.array(values, dtype=float) def _state_from_vector(self, x: np.ndarray) -> Tuple[Dict[str, Tuple[float, float]], Dict[str, float]]: points: Dict[str, Tuple[float, float]] = {} offset_y = len(self.point_ids) for idx, point_id in enumerate(self.point_ids): points[point_id] = (float(x[idx]), float(x[offset_y + idx])) scalars: Dict[str, float] = {} scalar_offset = 2 * len(self.point_ids) for idx, scalar_id in enumerate(self.scalar_ids): scalars[scalar_id] = float(x[scalar_offset + idx]) return points, scalars def _finite_difference_jacobian(self, x: np.ndarray, residual: np.ndarray) -> np.ndarray: if len(residual) == 0: return np.zeros((0, len(x))) jacobian = np.zeros((len(residual), len(x)), dtype=float) for idx in range(len(x)): step = 1e-6 * max(1.0, abs(float(x[idx]))) shifted = x.copy() shifted[idx] += step jacobian[:, idx] = (self._residual_vector(shifted) - residual) / step return jacobian def _residual_vector(self, x: np.ndarray) -> np.ndarray: points, scalars = self._state_from_vector(x) residuals: List[float] = [] for constraint in self.sketch.constraints: residuals.extend(self._constraint_residuals(constraint, points, scalars)) return np.array(residuals, dtype=float) def _constraint_residuals( self, constraint: SketchConstraint, points: Mapping[str, Tuple[float, float]], scalars: Mapping[str, float], ) -> List[float]: refs = self.sketch._constraint_refs(constraint) kind = constraint.kind if kind == "fix": return self._fix_residuals(refs[0], points, scalars) if kind == "coincident": a = self._point(refs[0], points) b = self._point(refs[1], points) return [a[0] - b[0], a[1] - b[1]] if kind == "horizontal": a, b = self._line_points(refs[0], points) return [b[1] - a[1]] if kind == "vertical": a, b = self._line_points(refs[0], points) return [b[0] - a[0]] if kind == "parallel": return [self._cross_normalized(refs[0], refs[1], points)] if kind == "perpendicular": return [self._dot_normalized(refs[0], refs[1], points)] if kind == "collinear": a, _b = self._line_points(refs[0], points) return [self._cross_normalized(refs[0], refs[1], points), self._point_line_distance(a, refs[1], points)] if kind == "equal_length": return [self._line_length(refs[0], points) - self._line_length(refs[1], points)] if kind == "equal_radius": return [self._circle_radius(refs[0], scalars) - self._circle_radius(refs[1], scalars)] if kind == "distance": return [self._point_distance(refs[0], refs[1], points) - _as_float(constraint.value)] if kind == "distance_x": a = self._point(refs[0], points) b = self._point(refs[1], points) return [(b[0] - a[0]) - _as_float(constraint.value)] if kind == "distance_y": a = self._point(refs[0], points) b = self._point(refs[1], points) return [(b[1] - a[1]) - _as_float(constraint.value)] if kind == "length": return [self._line_length(refs[0], points) - _as_float(constraint.value)] if kind == "angle": return [self._line_angle_delta(refs[0], refs[1], points, _as_float(constraint.value))] if kind == "radius": return [self._circle_radius(refs[0], scalars) - _as_float(constraint.value)] if kind == "diameter": return [2.0 * self._circle_radius(refs[0], scalars) - _as_float(constraint.value)] if kind == "point_on": return self._point_on_residuals(refs[0], refs[1], points, scalars) if kind == "concentric": a = self._circle_center(refs[0], points) b = self._circle_center(refs[1], points) return [a[0] - b[0], a[1] - b[1]] if kind == "midpoint": point = self._point(refs[0], points) a, b = self._line_points(refs[1], points) return [point[0] - 0.5 * (a[0] + b[0]), point[1] - 0.5 * (a[1] + b[1])] if kind == "tangent": return [self._tangent_residual(refs[0], refs[1], points, scalars)] if kind == "symmetric": a = self._point(refs[0], points) b = self._point(refs[1], points) axis_a, axis_b = self._line_points(refs[2], points) axis = self._sub(axis_b, axis_a) mid = ((a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5) return [self._point_line_distance(mid, refs[2], points), self._dot(self._sub(a, b), axis) / max(self._norm(axis), _POINT_EPS)] raise ValueError(f"Unsupported sketch constraint kind '{kind}'") def _fix_residuals( self, ref: SketchRef, points: Mapping[str, Tuple[float, float]], scalars: Mapping[str, float], ) -> List[float]: entity = self.sketch.entities[ref.entity_id] if ref.kind == "point" or entity.kind == "point": pid = self.sketch.resolve_point_id(ref) target = self.sketch.entities[pid] point = points[pid] return [point[0] - _as_float(target.data["x"]), point[1] - _as_float(target.data["y"])] if entity.kind == "line": start = self._fix_residuals(self.sketch.point_ref(f"{ref.entity_id}.start"), points, scalars) end = self._fix_residuals(self.sketch.point_ref(f"{ref.entity_id}.end"), points, scalars) return start + end if entity.kind == "circle": center = self._fix_residuals(self.sketch.point_ref(f"{ref.entity_id}.center"), points, scalars) radius_key = f"circle:{ref.entity_id}:radius" return center + [scalars[radius_key] - _as_float(entity.data["radius"])] raise ValueError(f"Cannot fix sketch entity kind '{entity.kind}'") def _point(self, ref: SketchRef, points: Mapping[str, Tuple[float, float]]) -> Tuple[float, float]: return points[self.sketch.resolve_point_id(ref)] def _line_points( self, ref: SketchRef, points: Mapping[str, Tuple[float, float]] ) -> Tuple[Tuple[float, float], Tuple[float, float]]: entity = self.sketch.entities[ref.entity_id] if entity.kind != "line": raise ValueError(f"Expected line ref, got '{entity.kind}'") return points[str(entity.data["start"])], points[str(entity.data["end"])] def _circle_center(self, ref: SketchRef, points: Mapping[str, Tuple[float, float]]) -> Tuple[float, float]: entity = self.sketch.entities[ref.entity_id] if entity.kind != "circle": raise ValueError(f"Expected circle ref, got '{entity.kind}'") return points[str(entity.data["center"])] def _circle_radius(self, ref: SketchRef, scalars: Mapping[str, float]) -> float: entity = self.sketch.entities[ref.entity_id] if entity.kind != "circle": raise ValueError(f"Expected circle ref, got '{entity.kind}'") return scalars[f"circle:{ref.entity_id}:radius"] def _point_distance( self, a: SketchRef, b: SketchRef, points: Mapping[str, Tuple[float, float]] ) -> float: return self._norm(self._sub(self._point(b, points), self._point(a, points))) def _line_length(self, ref: SketchRef, points: Mapping[str, Tuple[float, float]]) -> float: a, b = self._line_points(ref, points) return self._norm(self._sub(b, a)) def _cross_normalized(self, a_ref: SketchRef, b_ref: SketchRef, points: Mapping[str, Tuple[float, float]]) -> float: a0, a1 = self._line_points(a_ref, points) b0, b1 = self._line_points(b_ref, points) a = self._sub(a1, a0) b = self._sub(b1, b0) return self._cross(a, b) / max(self._norm(a) * self._norm(b), _POINT_EPS) def _dot_normalized(self, a_ref: SketchRef, b_ref: SketchRef, points: Mapping[str, Tuple[float, float]]) -> float: a0, a1 = self._line_points(a_ref, points) b0, b1 = self._line_points(b_ref, points) a = self._sub(a1, a0) b = self._sub(b1, b0) return self._dot(a, b) / max(self._norm(a) * self._norm(b), _POINT_EPS) def _line_angle_delta( self, a_ref: SketchRef, b_ref: SketchRef, points: Mapping[str, Tuple[float, float]], target: float, ) -> float: a0, a1 = self._line_points(a_ref, points) b0, b1 = self._line_points(b_ref, points) a = self._sub(a1, a0) b = self._sub(b1, b0) angle = math.atan2(self._cross(a, b), self._dot(a, b)) return _angle_delta(angle - target) def _point_on_residuals( self, point_ref: SketchRef, entity_ref: SketchRef, points: Mapping[str, Tuple[float, float]], scalars: Mapping[str, float], ) -> List[float]: point = self._point(point_ref, points) entity = self.sketch.entities[entity_ref.entity_id] if entity.kind == "line": return [self._point_line_distance(point, entity_ref, points)] if entity.kind == "circle": center = self._circle_center(entity_ref, points) radius = self._circle_radius(entity_ref, scalars) return [self._norm(self._sub(point, center)) - radius] raise ValueError(f"Unsupported point_on target kind '{entity.kind}'") def _point_line_distance( self, point: Tuple[float, float], line_ref: SketchRef, points: Mapping[str, Tuple[float, float]], ) -> float: a, b = self._line_points(line_ref, points) ab = self._sub(b, a) return self._cross(self._sub(point, a), ab) / max(self._norm(ab), _POINT_EPS) def _tangent_residual( self, a_ref: SketchRef, b_ref: SketchRef, points: Mapping[str, Tuple[float, float]], scalars: Mapping[str, float], ) -> float: a_kind = self.sketch.entities[a_ref.entity_id].kind b_kind = self.sketch.entities[b_ref.entity_id].kind if {a_kind, b_kind} == {"line", "circle"}: line_ref = a_ref if a_kind == "line" else b_ref circle_ref = b_ref if a_kind == "line" else a_ref center = self._circle_center(circle_ref, points) return abs(self._point_line_distance(center, line_ref, points)) - self._circle_radius(circle_ref, scalars) if a_kind == "circle" and b_kind == "circle": a_center = self._circle_center(a_ref, points) b_center = self._circle_center(b_ref, points) return self._norm(self._sub(a_center, b_center)) - ( self._circle_radius(a_ref, scalars) + self._circle_radius(b_ref, scalars) ) raise ValueError(f"Unsupported tangent target kinds '{a_kind}' and '{b_kind}'") @staticmethod def _sub(a: Tuple[float, float], b: Tuple[float, float]) -> Tuple[float, float]: return (float(a[0] - b[0]), float(a[1] - b[1])) @staticmethod def _dot(a: Tuple[float, float], b: Tuple[float, float]) -> float: return float(a[0] * b[0] + a[1] * b[1]) @staticmethod def _cross(a: Tuple[float, float], b: Tuple[float, float]) -> float: return float(a[0] * b[1] - a[1] * b[0]) @staticmethod def _norm(a: Tuple[float, float]) -> float: return float(math.hypot(a[0], a[1])) __all__ = [ "Sketch", "SketchRef", "SketchSolveResult", "SketchConstraint", "SketchConstraintDiagnostic", ]