3D-CAD-Generation-2 / src /streamlit_app.py
stardust-coder's picture
[debug] xsrf
996b95a
# 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 "<svg xmlns='http://www.w3.org/2000/svg' width='900' height='700'></svg>"
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"<svg xmlns='http://www.w3.org/2000/svg' width='{width}' height='{height}' viewBox='0 0 {width} {height}'>",
"<rect width='100%' height='100%' fill='white'/>"
]
# 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"<polyline points='{d}' fill='none' stroke='black' stroke-width='1.5'/>")
# circles
for c in circle_profiles:
cx, cy = tr(c.center)
r = c.radius * s
out.append(f"<circle cx='{cx:.2f}' cy='{cy:.2f}' r='{r:.2f}' fill='none' stroke='black' stroke-width='1.5'/>")
# 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"<polyline points='{d}' fill='none' stroke='gray' stroke-width='1' stroke-dasharray='4,2'/>")
out.append("</svg>")
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 ファイルをアップロードしてください。")