Spaces:
Sleeping
Sleeping
| from dataclasses import dataclass, field | |
| from typing import List, Tuple, Optional, Iterable | |
| import cadquery as cq | |
| import math | |
| Point2D = Tuple[float, float] | |
| class PolyProfile: | |
| points: List[Point2D] | |
| class CircleProfile: | |
| center: Point2D | |
| radius: float | |
| 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") |