优化engine
This commit is contained in:
@@ -38,7 +38,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
@@ -84,8 +84,9 @@ def _contour_arc(
|
||||
end_mm: list[float],
|
||||
center_mm: list[float],
|
||||
radius_mm: float | None,
|
||||
clockwise: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
result = {
|
||||
"type": "arc",
|
||||
"start_mm": [
|
||||
float(start_mm[0]),
|
||||
@@ -104,6 +105,9 @@ def _contour_arc(
|
||||
],
|
||||
"radius_mm": float(radius_mm) if radius_mm is not None else None,
|
||||
}
|
||||
if clockwise is not None:
|
||||
result["clockwise"] = bool(clockwise)
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
@@ -1558,6 +1562,235 @@ def _gen_compound_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ct
|
||||
return [], []
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Evidence v2 analytic contours
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_ANALYTIC_TOLERANCE_MM = 1e-5
|
||||
|
||||
|
||||
def _distance_2d(left: list[float], right: list[float]) -> float:
|
||||
return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1]))
|
||||
|
||||
|
||||
def _reverse_analytic_edge(edge: _Ctx) -> _Ctx:
|
||||
result = deepcopy(edge)
|
||||
result["start_mm"], result["end_mm"] = result["end_mm"], result["start_mm"]
|
||||
if result.get("type") == "arc" and "clockwise" in result:
|
||||
result["clockwise"] = not bool(result["clockwise"])
|
||||
return result
|
||||
|
||||
|
||||
def _join_analytic_edges(edges: list[_Ctx], *, closed: bool) -> list[_Ctx]:
|
||||
"""Order/reorient a contour without depending on SolidWorks segment order."""
|
||||
if not edges:
|
||||
return []
|
||||
pending = [deepcopy(edge) for edge in edges]
|
||||
ordered = [pending.pop(0)]
|
||||
while pending:
|
||||
tail = ordered[-1]["end_mm"]
|
||||
match_index = None
|
||||
reverse = False
|
||||
for index, edge in enumerate(pending):
|
||||
if _distance_2d(tail, edge["start_mm"]) <= _ANALYTIC_TOLERANCE_MM:
|
||||
match_index = index
|
||||
break
|
||||
if _distance_2d(tail, edge["end_mm"]) <= _ANALYTIC_TOLERANCE_MM:
|
||||
match_index = index
|
||||
reverse = True
|
||||
break
|
||||
if match_index is None:
|
||||
raise ValueError("analytic_contours: segments do not form a connected contour")
|
||||
edge = pending.pop(match_index)
|
||||
ordered.append(_reverse_analytic_edge(edge) if reverse else edge)
|
||||
if closed and _distance_2d(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _ANALYTIC_TOLERANCE_MM:
|
||||
raise ValueError("analytic_contours: closed contour endpoints do not meet")
|
||||
return ordered
|
||||
|
||||
|
||||
def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
center = segment.get("center") or [0.0, 0.0]
|
||||
radius = float(segment.get("radius_mm") or 0.0)
|
||||
if radius <= 0:
|
||||
raise ValueError("analytic_contours: circle radius_mm must be > 0")
|
||||
cx, cy = float(center[0]), float(center[1])
|
||||
clockwise = bool(segment.get("clockwise", False))
|
||||
angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0]
|
||||
points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles]
|
||||
return [
|
||||
_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise)
|
||||
for index in range(4)
|
||||
]
|
||||
|
||||
|
||||
def _analytic_segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
segment_type = segment.get("type")
|
||||
if segment_type == "line":
|
||||
return [_contour_line(segment["start"], segment["end"])]
|
||||
if segment_type == "arc":
|
||||
return [
|
||||
_contour_arc(
|
||||
segment["start"], segment["end"], segment["center"],
|
||||
segment.get("radius_mm"), segment.get("clockwise"),
|
||||
)
|
||||
]
|
||||
if segment_type == "circle":
|
||||
return _analytic_circle_edges(segment)
|
||||
if segment_type == "bspline":
|
||||
raise ValueError("analytic_contours: bspline requires an explicit approximation capability")
|
||||
raise ValueError(f"analytic_contours: unsupported segment type {segment_type!r}")
|
||||
|
||||
|
||||
def _sample_analytic_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
|
||||
"""Create a deterministic planar sample only for containment classification."""
|
||||
points: list[tuple[float, float]] = []
|
||||
for edge in edges:
|
||||
start = edge["start_mm"]
|
||||
points.append((float(start[0]), float(start[1])))
|
||||
if edge.get("type") != "arc":
|
||||
continue
|
||||
center = edge["center_mm"]
|
||||
end = edge["end_mm"]
|
||||
sx, sy = float(start[0]) - float(center[0]), float(start[1]) - float(center[1])
|
||||
ex, ey = float(end[0]) - float(center[0]), float(end[1]) - float(center[1])
|
||||
start_angle = math.atan2(sy, sx)
|
||||
end_angle = math.atan2(ey, ex)
|
||||
delta = end_angle - start_angle
|
||||
if edge.get("clockwise"):
|
||||
if delta >= 0:
|
||||
delta -= math.tau
|
||||
elif delta <= 0:
|
||||
delta += math.tau
|
||||
for fraction in (0.25, 0.5, 0.75):
|
||||
angle = start_angle + delta * fraction
|
||||
radius = float(edge.get("radius_mm") or math.hypot(sx, sy))
|
||||
points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle)))
|
||||
return points
|
||||
|
||||
|
||||
def _loop_area(points: list[tuple[float, float]]) -> float:
|
||||
if len(points) < 3:
|
||||
return 0.0
|
||||
return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0)
|
||||
|
||||
|
||||
def _endpoint_signed_area(edges: list[_Ctx]) -> float:
|
||||
points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges]
|
||||
return sum(
|
||||
points[index][0] * points[(index + 1) % len(points)][1]
|
||||
- points[(index + 1) % len(points)][0] * points[index][1]
|
||||
for index in range(len(points))
|
||||
) / 2.0
|
||||
|
||||
|
||||
def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None:
|
||||
"""Repair inconsistent sweep flags on a conventional rounded rectangle.
|
||||
|
||||
Evidence exports occasionally label one or more 90-degree corner arcs
|
||||
with the opposite direction. Honouring those isolated flags creates
|
||||
270-degree loops. This normalizer applies only to the unambiguous shape:
|
||||
exactly four equal-radius quarter arcs in one closed loop. Other arcs,
|
||||
including annular sectors and long sweeps, retain their captured flags.
|
||||
"""
|
||||
arcs = [edge for edge in edges if edge.get("type") == "arc"]
|
||||
if len(arcs) != 4:
|
||||
return
|
||||
radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs]
|
||||
if min(radii) <= _ANALYTIC_TOLERANCE_MM or max(radii) - min(radii) > _ANALYTIC_TOLERANCE_MM:
|
||||
return
|
||||
for edge in arcs:
|
||||
center = edge.get("center_mm")
|
||||
if not isinstance(center, list):
|
||||
return
|
||||
start, end = edge["start_mm"], edge["end_mm"]
|
||||
first = (float(start[0]) - float(center[0]), float(start[1]) - float(center[1]))
|
||||
second = (float(end[0]) - float(center[0]), float(end[1]) - float(center[1]))
|
||||
angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1]))
|
||||
if abs(angle - math.pi / 2) > 1e-4:
|
||||
return
|
||||
# A clockwise endpoint loop needs clockwise short corner arcs; a
|
||||
# counter-clockwise loop needs their reverse. This preserves the actual
|
||||
# rounded-rectangle boundary, independent of per-segment export noise.
|
||||
clockwise = _endpoint_signed_area(edges) < 0.0
|
||||
for edge in arcs:
|
||||
edge["clockwise"] = clockwise
|
||||
|
||||
|
||||
def _point_in_loop(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool:
|
||||
if len(loop) < 3:
|
||||
return False
|
||||
inside = False
|
||||
x, y = point
|
||||
previous = loop[-1]
|
||||
for current in loop:
|
||||
x1, y1 = current
|
||||
x2, y2 = previous
|
||||
if (y1 > y) != (y2 > y):
|
||||
intersect_x = (x2 - x1) * (y - y1) / (y2 - y1) + x1
|
||||
if x < intersect_x:
|
||||
inside = not inside
|
||||
previous = current
|
||||
return inside
|
||||
|
||||
|
||||
def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
|
||||
"""Resolve Evidence v2 line/arc/circle loops into engine-neutral regions.
|
||||
|
||||
The returned regions preserve holes and islands. The build adapter owns
|
||||
B-rep creation; this profile generator only reasons about sketch geometry.
|
||||
"""
|
||||
loops: list[_Ctx] = []
|
||||
entities: list[_Ctx] = []
|
||||
for contour_index, contour in enumerate(profile.get("contours") or []):
|
||||
if not contour.get("closed"):
|
||||
raise ValueError(f"analytic_contours: contour {contour_index} is open")
|
||||
segment_edges: list[_Ctx] = []
|
||||
for segment in contour.get("segments") or []:
|
||||
segment_type = segment.get("type")
|
||||
if segment_type == "line":
|
||||
entities.append(_line(segment["start"], segment["end"]))
|
||||
elif segment_type == "circle":
|
||||
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0)))
|
||||
segment_edges.extend(_analytic_segment_edges(segment))
|
||||
if not segment_edges:
|
||||
continue
|
||||
edges = _join_analytic_edges(segment_edges, closed=True)
|
||||
_normalize_quarter_rounding_direction(edges)
|
||||
points = _sample_analytic_loop(edges)
|
||||
area = _loop_area(points)
|
||||
if area <= _ANALYTIC_TOLERANCE_MM * _ANALYTIC_TOLERANCE_MM:
|
||||
raise ValueError(f"analytic_contours: contour {contour_index} is degenerate")
|
||||
loops.append({"role": contour.get("role", "unknown"), "edges": edges, "points": points, "area": area})
|
||||
|
||||
for segment in profile.get("construction") or []:
|
||||
if segment.get("type") == "line":
|
||||
entities.append(_line(segment["start"], segment["end"], construction=True))
|
||||
elif segment.get("type") == "circle":
|
||||
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True))
|
||||
|
||||
if not loops:
|
||||
return entities, []
|
||||
for loop in loops:
|
||||
# Role tags captured from the source sketch are useful provenance but
|
||||
# not authoritative geometry. A number of exports label separate
|
||||
# closed contours as ``inner`` although no outer contour contains
|
||||
# them. The even-odd containment rule is deterministic for the
|
||||
# supported analytic curves and preserves those independent regions.
|
||||
contained_by = sum(_point_in_loop(loop["points"][0], other["points"]) for other in loops if other is not loop)
|
||||
loop["role"] = "inner" if contained_by % 2 else "outer"
|
||||
outers = [loop for loop in loops if loop["role"] == "outer"]
|
||||
inners = [loop for loop in loops if loop["role"] == "inner"]
|
||||
regions = [{"outer": outer["edges"], "holes": []} for outer in outers]
|
||||
for inner in inners:
|
||||
containing = [outer for outer in outers if _point_in_loop(inner["points"][0], outer["points"])]
|
||||
if not containing:
|
||||
raise ValueError("analytic_contours: inner contour has no containing outer contour")
|
||||
selected = min(containing, key=lambda outer: outer["area"])
|
||||
regions[outers.index(selected)]["holes"].append(inner["edges"])
|
||||
meta["_regions"] = regions
|
||||
return entities, []
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 生成器注册表 —— 唯一索引点
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
@@ -1588,6 +1821,7 @@ SHAPE_GENERATORS: dict[str, Any] = {
|
||||
"radial_slot": _gen_radial_slot,
|
||||
"patterned_cutouts": _gen_patterned_cutouts,
|
||||
"compound_patterned_cutouts": _gen_compound_patterned_cutouts,
|
||||
"analytic_contours": _gen_analytic_contours,
|
||||
"arc_chain": _gen_arc_chain,
|
||||
"complex_arc_shape": _gen_polygon, # 从 compiler_context entities 重建
|
||||
"unknown_shape": _gen_polygon, # 未分类形状也走 compiler_context 回退
|
||||
@@ -1748,3 +1982,72 @@ def resolve_all_sketches(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
out = deepcopy(cdsl)
|
||||
out.setdefault("geometry", {})["sketches"] = result
|
||||
return out
|
||||
|
||||
|
||||
def resolve_required_sketches(
|
||||
cdsl: dict[str, Any],
|
||||
sketch_ids: Iterable[str],
|
||||
*,
|
||||
errors: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve only profiles that an executable feature actually consumes.
|
||||
|
||||
``profile_from`` dependencies are resolved recursively. Callers that
|
||||
pass ``errors`` get feature-addressable failures without losing unrelated
|
||||
resolved sketches; callers that omit it retain the strict exception
|
||||
behavior useful to profile tooling.
|
||||
"""
|
||||
sketches = list((cdsl.get("geometry") or {}).get("sketches") or [])
|
||||
by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None}
|
||||
resolved: dict[str, dict[str, Any]] = {}
|
||||
resolving: set[str] = set()
|
||||
|
||||
def resolve_one(sketch_id: str) -> dict[str, Any]:
|
||||
if sketch_id in resolved:
|
||||
return resolved[sketch_id]
|
||||
sketch = by_id.get(sketch_id)
|
||||
if sketch is None:
|
||||
raise ValueError(f"sketch {sketch_id!r} was not found")
|
||||
if sketch_id in resolving:
|
||||
raise ValueError(f"sketch {sketch_id}: profile_from contains a cycle")
|
||||
resolving.add(sketch_id)
|
||||
try:
|
||||
if "profile" in sketch:
|
||||
output = resolve_profile(sketch)
|
||||
elif sketch.get("profile_from"):
|
||||
source_id = str(sketch["profile_from"])
|
||||
source = resolve_one(source_id)
|
||||
if not source.get("profile"):
|
||||
raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} has no profile")
|
||||
output = deepcopy(sketch)
|
||||
output["profile"] = deepcopy(source["profile"])
|
||||
output.pop("profile_from", None)
|
||||
shift = sketch.get("profile_shift")
|
||||
if shift and len(shift) == 2 and output["profile"].get("type") == "polygon":
|
||||
du, dv = float(shift[0]), float(shift[1])
|
||||
for vertex in output["profile"]["vertices"]:
|
||||
vertex[0] = round(vertex[0] + du, 6)
|
||||
vertex[1] = round(vertex[1] + dv, 6)
|
||||
output.pop("profile_shift", None)
|
||||
output = resolve_profile(output)
|
||||
else:
|
||||
output = deepcopy(sketch)
|
||||
resolved[sketch_id] = output
|
||||
return output
|
||||
finally:
|
||||
resolving.discard(sketch_id)
|
||||
|
||||
for sketch_id in {str(item) for item in sketch_ids}:
|
||||
try:
|
||||
resolve_one(sketch_id)
|
||||
except ValueError as error:
|
||||
if errors is None:
|
||||
raise
|
||||
errors[sketch_id] = str(error)
|
||||
|
||||
output = deepcopy(cdsl)
|
||||
output.setdefault("geometry", {})["sketches"] = [
|
||||
resolved.get(str(sketch.get("id")), deepcopy(sketch))
|
||||
for sketch in sketches
|
||||
]
|
||||
return output
|
||||
|
||||
Reference in New Issue
Block a user