# app.py import io import math import tempfile from dataclasses import dataclass from pathlib import Path from typing import List, Tuple, Dict, Iterable import streamlit as st import ezdxf import cadquery as cq from cadquery import exporters Point2D = Tuple[float, float] # ========================= # Data structures # ========================= @dataclass class PolyProfile: points: List[Point2D] layer: str source: str @dataclass class CircleProfile: center: Point2D radius: float layer: str source: str # ========================= # Geometry helpers # ========================= def dist(a: Point2D, b: Point2D) -> float: return math.hypot(a[0] - b[0], a[1] - b[1]) def almost_same(a: Point2D, b: Point2D, tol: float = 1e-4) -> bool: return dist(a, b) <= tol def round_pt(p: Point2D, ndigits: int = 4) -> Point2D: return (round(float(p[0]), ndigits), round(float(p[1]), ndigits)) def area_polygon(points: List[Point2D]) -> float: """Signed area * 0.5""" if len(points) < 3: return 0.0 s = 0.0 for i in range(len(points)): x1, y1 = points[i] x2, y2 = points[(i + 1) % len(points)] s += x1 * y2 - x2 * y1 return s / 2.0 def ensure_closed(points: List[Point2D], tol: float = 1e-4) -> List[Point2D]: if not points: return points if not almost_same(points[0], points[-1], tol): return points + [points[0]] return points def dedupe_sequential(points: List[Point2D], tol: float = 1e-7) -> List[Point2D]: if not points: return points out = [points[0]] for p in points[1:]: if dist(p, out[-1]) > tol: out.append(p) if len(out) > 1 and dist(out[0], out[-1]) <= tol: out[-1] = out[0] return out def sample_arc(center: Point2D, radius: float, start_deg: float, end_deg: float, segments: int = 48) -> List[Point2D]: """DXF ARC is CCW from start_angle to end_angle.""" start = math.radians(start_deg) end = math.radians(end_deg) if end < start: end += 2 * math.pi pts = [] for i in range(segments + 1): t = start + (end - start) * i / segments pts.append((center[0] + radius * math.cos(t), center[1] + radius * math.sin(t))) return pts # ========================= # DXF parsing # ========================= def load_dxf_from_upload(uploaded_file) -> ezdxf.document.Drawing: suffix = ".dxf" with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tf: raw = uploaded_file.getvalue() tf.write(raw) temp_path = tf.name return ezdxf.readfile(temp_path, encoding="cp932") def get_layers(doc: ezdxf.document.Drawing) -> List[str]: layers = set() for e in doc.modelspace(): if hasattr(e.dxf, "layer"): layers.add(e.dxf.layer) return sorted(layers) def extract_profiles(doc, target_layers: List[str], tol: float = 1e-4): """ まずは扱いやすい形だけ抽出: - 閉じた LWPOLYLINE / POLYLINE - CIRCLE - LINE の閉ループ化 - ARC はプレビュー用に保持(面化は現状しない) """ msp = doc.modelspace() selected = [] for e in msp: layer = getattr(e.dxf, "layer", "0") if (not target_layers) or (layer in target_layers): selected.append(e) poly_profiles: List[PolyProfile] = [] circle_profiles: List[CircleProfile] = [] line_segments: List[Tuple[Point2D, Point2D, str]] = [] preview_arcs: List[List[Point2D]] = [] warnings: List[str] = [] for e in selected: dxftype = e.dxftype() layer = getattr(e.dxf, "layer", "0") try: if dxftype == "LWPOLYLINE": pts = [(float(x), float(y)) for x, y, *_ in e.get_points()] pts = dedupe_sequential(pts) if e.closed or (len(pts) >= 3 and almost_same(pts[0], pts[-1], tol)): pts = ensure_closed(pts, tol) poly_profiles.append(PolyProfile(points=pts, layer=layer, source=dxftype)) else: # 開いた LWPOLYLINE は今回はスキップ warnings.append(f"Open LWPOLYLINE skipped on layer '{layer}'") elif dxftype == "POLYLINE": pts = [(float(v.dxf.location.x), float(v.dxf.location.y)) for v in e.vertices] pts = dedupe_sequential(pts) is_closed = False if hasattr(e, "is_closed"): is_closed = bool(e.is_closed) if is_closed or (len(pts) >= 3 and almost_same(pts[0], pts[-1], tol)): pts = ensure_closed(pts, tol) poly_profiles.append(PolyProfile(points=pts, layer=layer, source=dxftype)) else: warnings.append(f"Open POLYLINE skipped on layer '{layer}'") elif dxftype == "CIRCLE": c = e.dxf.center circle_profiles.append( CircleProfile( center=(float(c.x), float(c.y)), radius=float(e.dxf.radius), layer=layer, source=dxftype, ) ) elif dxftype == "LINE": s = e.dxf.start t = e.dxf.end line_segments.append(((float(s.x), float(s.y)), (float(t.x), float(t.y)), layer)) elif dxftype == "ARC": c = e.dxf.center pts = sample_arc( center=(float(c.x), float(c.y)), radius=float(e.dxf.radius), start_deg=float(e.dxf.start_angle), end_deg=float(e.dxf.end_angle), segments=48, ) preview_arcs.append(pts) warnings.append(f"ARC detected on layer '{layer}' (preview only, not solidified)") else: # 必要ならここに ELLIPSE, SPLINE, HATCH を足す pass except Exception as ex: warnings.append(f"{dxftype} parse error on layer '{layer}': {ex}") # LINE 群から簡易ループ生成 line_loops, line_warnings = chain_segments_to_closed_loops(line_segments, tol=tol) warnings.extend(line_warnings) for loop_pts, layer in line_loops: poly_profiles.append(PolyProfile(points=loop_pts, layer=layer, source="LINE_LOOP")) return poly_profiles, circle_profiles, preview_arcs, warnings def chain_segments_to_closed_loops( segments: List[Tuple[Point2D, Point2D, str]], tol: float = 1e-4 ) -> Tuple[List[Tuple[List[Point2D], str]], List[str]]: """ かなり素朴なループ化: 同一レイヤー内で端点がつながる線分を順につなぎ、 閉じたら 1 ループとして返す。 """ warnings = [] by_layer: Dict[str, List[Tuple[Point2D, Point2D]]] = {} for s, e, layer in segments: by_layer.setdefault(layer, []).append((s, e)) loops: List[Tuple[List[Point2D], str]] = [] for layer, segs in by_layer.items(): unused = segs[:] while unused: s0, e0 = unused.pop(0) chain = [s0, e0] changed = True while changed: changed = False i = 0 while i < len(unused): a, b = unused[i] if almost_same(chain[-1], a, tol): chain.append(b) unused.pop(i) changed = True continue elif almost_same(chain[-1], b, tol): chain.append(a) unused.pop(i) changed = True continue elif almost_same(chain[0], b, tol): chain = [a] + chain unused.pop(i) changed = True continue elif almost_same(chain[0], a, tol): chain = [b] + chain unused.pop(i) changed = True continue i += 1 chain = dedupe_sequential(chain) if len(chain) >= 4 and almost_same(chain[0], chain[-1], tol): loops.append((ensure_closed(chain, tol), layer)) else: warnings.append(f"Open LINE chain skipped on layer '{layer}'") return loops, warnings # ========================= # CadQuery build # ========================= # def build_solid( # poly_profiles: List[PolyProfile], # circle_profiles: List[CircleProfile], # height: float, # scale: float = 1.0, # min_area: float = 1e-6, # ): # """ # 各閉輪郭を独立したソリッドとして Z 方向に押し出し、最後に union する。 # 制約: # - 輪郭の内外判定はしない # - 穴(内周)はサポートしない # - 自己交差ポリゴンは未対応 # - 不正形状に対する例外処理は未実装 # """ # solids = [] # for prof in poly_profiles: # pts = [(p[0] * scale, p[1] * scale) for p in prof.points] # pts = dedupe_sequential(pts) # pts = ensure_closed(pts) # if len(pts) < 4: # continue # if abs(area_polygon(pts[:-1])) < min_area: # continue # wp = cq.Workplane("XY").polyline(pts[:-1]).close().extrude(height) # solids.append(wp) # for c in circle_profiles: # wp = ( # cq.Workplane("XY") # .center(c.center[0] * scale, c.center[1] * scale) # .circle(c.radius * scale) # .extrude(height) # ) # solids.append(wp) # if not solids: # return None # #[TODO] 要修正 # model = solids[0] # for s in solids[1:]: # model = model.union(s) # return model from core import build_solid from llm import generate_cadquery_with_openai, execute_generated_cadquery def export_bytes(model) -> Tuple[bytes, bytes]: with tempfile.TemporaryDirectory() as td: stl_path = str(Path(td) / "result.stl") step_path = str(Path(td) / "result.step") exporters.export(model, stl_path) exporters.export(model, step_path) stl_bytes = Path(stl_path).read_bytes() step_bytes = Path(step_path).read_bytes() return stl_bytes, step_bytes # ========================= # Preview # ========================= def build_svg_preview(poly_profiles, circle_profiles, arc_previews, width=900, height=700) -> str: all_pts = [] for p in poly_profiles: all_pts.extend(p.points) for c in circle_profiles: cx, cy = c.center r = c.radius all_pts.extend([(cx - r, cy - r), (cx + r, cy + r)]) for arc in arc_previews: all_pts.extend(arc) if not all_pts: return "" xs = [p[0] for p in all_pts] ys = [p[1] for p in all_pts] min_x, max_x = min(xs), max(xs) min_y, max_y = min(ys), max(ys) dx = max(max_x - min_x, 1e-6) dy = max(max_y - min_y, 1e-6) margin = 20 sx = (width - 2 * margin) / dx sy = (height - 2 * margin) / dy s = min(sx, sy) def tr(p): x = margin + (p[0] - min_x) * s y = height - (margin + (p[1] - min_y) * s) return x, y out = [ f"", "" ] # polylines for prof in poly_profiles: pts = [tr(p) for p in prof.points] d = " ".join(f"{x:.2f},{y:.2f}" for x, y in pts) out.append(f"") # circles for c in circle_profiles: cx, cy = tr(c.center) r = c.radius * s out.append(f"") # arcs preview for arc in arc_previews: pts = [tr(p) for p in arc] d = " ".join(f"{x:.2f},{y:.2f}" for x, y in pts) out.append(f"") out.append("") return "\n".join(out) # ========================= # Streamlit UI # ========================= st.set_page_config(page_title="2D -> 3D", layout="wide") st.title("3D Generation from 2D CAD") st.caption("2Dの図面を入力し、3Dに変換します。") with st.sidebar: st.header("設定") height = st.number_input("押し出し高さ", min_value=0.1, value=100.0, step=1.0) scale = st.number_input("スケール", min_value=0.0001, value=1.0, step=0.1, format="%.4f") tol = st.number_input("接続許容差", min_value=1e-6, value=1e-4, step=1e-4, format="%.6f") st.markdown( """ 対応: - Closed LWPOLYLINE - Closed POLYLINE - CIRCLE - LINE の閉ループ化 制限: - ARC は現在プレビューのみ - 穴(HOLE)の自動判定は未対応 - HATCH / SPLINE / ELLIPSE は未対応 """ ) st.write(st.get_option("server.enableXsrfProtection")) uploaded = st.file_uploader("DXFファイルをアップロード", type=["dxf"]) if uploaded is not None: try: doc = load_dxf_from_upload(uploaded) layers = get_layers(doc) selected_layers = st.selectbox( "対象レイヤー", options=layers, ) poly_profiles, circle_profiles, arc_previews, warnings = extract_profiles( doc, selected_layers, tol=tol ) c1, c2, c3 = st.columns(3) c1.metric("閉ポリライン数", len(poly_profiles)) c2.metric("円数", len(circle_profiles)) c3.metric("警告数", len(warnings)) svg = build_svg_preview(poly_profiles, circle_profiles, arc_previews) st.subheader("2Dプレビュー") st.components.v1.html(svg, height=720, scrolling=True) if warnings: with st.expander("警告 / スキップ情報"): for w in warnings: st.write("- " + w) if st.button("3Dモデルを生成"): with st.spinner("3Dモデルを生成中..."): # model = build_solid( # poly_profiles=poly_profiles, # circle_profiles=circle_profiles, # height=height, # scale=scale, # ) from utils import render_dxf_layer_to_png render_dxf_layer_to_png( doc = doc, layer_name=selected_layers, output_path="layer.png", ) st.image("layer.png") generation = generate_cadquery_with_openai( image_path="layer.png", poly_profiles=poly_profiles, circle_profiles=circle_profiles, modeling_instruction=( "これは2D輪郭レイヤーです。外形・穴・対称性を考慮し、" "板状部品として自然な3DモデルをCadQueryで作ってください。" "厚みは 8 mm を採用してください。" ), model="gpt-4.1", ) model = execute_generated_cadquery(generation["cadquery_code"]) if model is None: st.error("3D化できる閉輪郭が見つかりませんでした。") else: st.success("3Dモデルを生成しました。") st.code(f"solids = {len(poly_profiles) + len(circle_profiles)}") stl_bytes, step_bytes = export_bytes(model) col1, col2 = st.columns(2) with col1: st.download_button( "STLをダウンロード", data=stl_bytes, file_name="result.stl", mime="model/stl", ) with col2: st.download_button( "STEPをダウンロード", data=step_bytes, file_name="result.step", mime="application/step", ) except Exception as e: st.exception(e) else: st.info("まず DXF ファイルをアップロードしてください。")