from dataclasses import dataclass, field from typing import List, Tuple, Optional, Iterable import cadquery as cq import math Point2D = Tuple[float, float] @dataclass class PolyProfile: points: List[Point2D] @dataclass class CircleProfile: center: Point2D radius: float @dataclass class Contour: kind: str # "poly" | "circle" points: List[Point2D] # 閉ループ点列(最後は先頭と同じ) area_abs: float sample_point: Point2D # なるべく内部点 wire: Optional[cq.Wire] = None children: List["Contour"] = field(default_factory=list) parent: Optional["Contour"] = None depth: int = 0 def dedupe_sequential(pts: List[Point2D], eps: float = 1e-9) -> List[Point2D]: if not pts: return pts out = [pts[0]] for p in pts[1:]: if abs(p[0] - out[-1][0]) > eps or abs(p[1] - out[-1][1]) > eps: out.append(p) return out def ensure_closed(pts: List[Point2D], eps: float = 1e-9) -> List[Point2D]: if not pts: return pts if abs(pts[0][0] - pts[-1][0]) > eps or abs(pts[0][1] - pts[-1][1]) > eps: return pts + [pts[0]] return pts def signed_area_polygon(pts_closed: List[Point2D]) -> float: pts = pts_closed[:-1] a = 0.0 n = len(pts) for i in range(n): x1, y1 = pts[i] x2, y2 = pts[(i + 1) % n] a += x1 * y2 - x2 * y1 return 0.5 * a def polygon_centroid(pts_closed: List[Point2D]) -> Point2D: """ 面積重心。退化時は頂点平均にフォールバック。 """ pts = pts_closed[:-1] area2 = 0.0 cx = 0.0 cy = 0.0 n = len(pts) for i in range(n): x1, y1 = pts[i] x2, y2 = pts[(i + 1) % n] cross = x1 * y2 - x2 * y1 area2 += cross cx += (x1 + x2) * cross cy += (y1 + y2) * cross if abs(area2) < 1e-12: sx = sum(x for x, _ in pts) sy = sum(y for _, y in pts) return (sx / len(pts), sy / len(pts)) cx /= (3.0 * area2) cy /= (3.0 * area2) return (cx, cy) def point_on_segment(pt: Point2D, a: Point2D, b: Point2D, eps: float = 1e-9) -> bool: px, py = pt ax, ay = a bx, by = b cross = (px - ax) * (by - ay) - (py - ay) * (bx - ax) if abs(cross) > eps: return False dot = (px - ax) * (bx - ax) + (py - ay) * (by - ay) if dot < -eps: return False sq_len = (bx - ax) ** 2 + (by - ay) ** 2 if dot - sq_len > eps: return False return True def point_in_polygon(pt: Point2D, poly_closed: List[Point2D], include_boundary: bool = True) -> bool: x, y = pt poly = poly_closed[:-1] inside = False n = len(poly) for i in range(n): p1 = poly[i] p2 = poly[(i + 1) % n] if include_boundary and point_on_segment(pt, p1, p2): return True x1, y1 = p1 x2, y2 = p2 intersects = ((y1 > y) != (y2 > y)) and ( x < (x2 - x1) * (y - y1) / ((y2 - y1) + 1e-30) + x1 ) if intersects: inside = not inside return inside def polygon_interior_sample_point(pts_closed: List[Point2D]) -> Point2D: """ なるべく内部にある点を返す。 まず重心を使い、内部でなければ最初の辺の中点を少し内側へ寄せる簡易フォールバック。 """ c = polygon_centroid(pts_closed) if point_in_polygon(c, pts_closed, include_boundary=False): return c pts = pts_closed[:-1] if len(pts) >= 3: x0, y0 = pts[0] x1, y1 = pts[1] mid = ((x0 + x1) * 0.5, (y0 + y1) * 0.5) cc = polygon_centroid(pts_closed) sx = mid[0] * 0.9 + cc[0] * 0.1 sy = mid[1] * 0.9 + cc[1] * 0.1 probe = (sx, sy) if point_in_polygon(probe, pts_closed, include_boundary=False): return probe return c def contour_contains(a: Contour, b: Contour, eps: float = 1e-9) -> bool: """ b の代表点が a の内部にあるかで判定。 単純輪郭前提。 """ if a.kind == "poly": return point_in_polygon(b.sample_point, a.points, include_boundary=False) elif a.kind == "circle": cx, cy = a.sample_point rx = a.points[0][0] - cx ry = a.points[0][1] - cy r = math.hypot(rx, ry) bx, by = b.sample_point return math.hypot(bx - cx, by - cy) < (r - eps) return False def make_poly_contour(points: List[Point2D], scale: float, min_area: float) -> Optional[Contour]: pts = [(x * scale, y * scale) for x, y in points] pts = dedupe_sequential(pts) pts = ensure_closed(pts) if len(pts) < 4: return None area = signed_area_polygon(pts) if abs(area) < min_area: return None wire = cq.Workplane("XY").polyline(pts[:-1]).close().wire().val() sample = polygon_interior_sample_point(pts) return Contour( kind="poly", points=pts, area_abs=abs(area), sample_point=sample, wire=wire, ) def make_circle_contour(center: Point2D, radius: float, scale: float, min_area: float) -> Optional[Contour]: cx, cy = center[0] * scale, center[1] * scale r = radius * scale area = math.pi * r * r if area < min_area or r <= 0: return None wire = cq.Workplane("XY").center(cx, cy).circle(r).wire().val() # points は半径推定用に保持 pts = [ (cx + r, cy), (cx, cy + r), (cx - r, cy), (cx, cy - r), (cx + r, cy), ] return Contour( kind="circle", points=pts, area_abs=area, sample_point=(cx, cy), wire=wire, ) def build_nesting(contours: List[Contour]) -> List[Contour]: """ 最小包含親を求めて木構造化。 """ for c in contours: c.children = [] c.parent = None c.depth = 0 contours_sorted = sorted(contours, key=lambda c: c.area_abs) for child in contours_sorted: parent_candidates = [ cand for cand in contours_sorted if cand is not child and cand.area_abs > child.area_abs and contour_contains(cand, child) ] if parent_candidates: parent = min(parent_candidates, key=lambda c: c.area_abs) child.parent = parent parent.children.append(child) roots = [c for c in contours if c.parent is None] def assign_depth(node: Contour, depth: int): node.depth = depth for ch in node.children: assign_depth(ch, depth + 1) for r in roots: assign_depth(r, 0) return roots def face_from_contour_tree(node: Contour) -> cq.Face: """ depth 偶数: material depth 奇数: hole という偶奇規則を使う。 1つの material ノードについて、その直下の hole を穴として face 化する。 """ if node.wire is None: raise ValueError("Contour has no wire") outer = node.wire holes = [ch.wire for ch in node.children if ch.wire is not None] return cq.Face.makeFromWires(outer, holes) def collect_material_nodes(roots: List[Contour]) -> List[Contour]: out: List[Contour] = [] def walk(n: Contour): if n.depth % 2 == 0: out.append(n) for ch in n.children: walk(ch) for r in roots: walk(r) return out def union_workplanes(solids: List[cq.Workplane]) -> cq.Workplane: if not solids: raise ValueError("No solids to union") model = solids[0] for s in solids[1:]: model = model.union(s) return model def build_solid( poly_profiles: Iterable[PolyProfile], circle_profiles: Iterable[CircleProfile], height: float, scale: float = 1.0, min_area: float = 1e-6, ) -> Optional[cq.Workplane]: """ 輪郭を正規化し、包含関係から outer / hole / island を解釈して Z方向に押し出したソリッドを生成する。 仕様: - 単純閉曲線を想定 - 自己交差輪郭は未対応 - 偶奇規則で material / void を決定 """ if height <= 0: raise ValueError("height must be positive") contours: List[Contour] = [] for prof in poly_profiles: c = make_poly_contour(prof.points, scale=scale, min_area=min_area) if c is not None: contours.append(c) for cprof in circle_profiles: c = make_circle_contour( center=cprof.center, radius=cprof.radius, scale=scale, min_area=min_area, ) if c is not None: contours.append(c) if not contours: return None roots = build_nesting(contours) material_nodes = collect_material_nodes(roots) solids: List[cq.Workplane] = [] for node in material_nodes: face = face_from_contour_tree(node) solid = cq.Solid.extrudeLinear(face, cq.Vector(0, 0, height)) solids.append(cq.Workplane("XY").newObject([solid])) if not solids: return None model = union_workplanes(solids) print("completed!") return model def export_step(model: cq.Workplane, path: str) -> None: cq.exporters.export(model, path) def export_stl(model: cq.Workplane, path: str) -> None: cq.exporters.export(model, path) if __name__ == "__main__": # 例1: 外形四角 + 中央穴円 poly_profiles = [ PolyProfile(points=[ (0, 0), (100, 0), (100, 60), (0, 60), ]) ] circle_profiles = [ CircleProfile(center=(50, 30), radius=10) ] model = build_solid( poly_profiles=poly_profiles, circle_profiles=circle_profiles, height=8.0, scale=1.0, min_area=1e-6, ) if model is not None: export_step(model, "output.step") export_stl(model, "output.stl")