#!/usr/bin/env python3 # dxf_to_svg.py — DXF 를 웹 왼쪽 칸의 SVG 로 바꾼다 (표준 라이브러리만, SPEC 29.1) # 사용: python3 tools/dxf_to_svg.py <출력.svg> # 상자·배치는 views.json 에서만 읽는다. 이 파일에는 도면 좌표가 없다. import colorsys import json import math import re import sys COORD_X = ("10", "11") COORD_Y = ("20", "21") ARC_STEP = 2.0 # 원호를 꺾은선으로 나눌 때 한 조각의 각도 (도) TEXT_EM = 1.38942 # 글자 크기 = DXF 글자 높이 × 이 값 (원본 SVG 와 같게) HIDDEN_LAYER = "Defpoints" MAX_DEPTH = 8 # 블록 안 블록 깊이 한계 BASIC_ACI = {1: (255, 0, 0), 2: (255, 255, 0), 3: (0, 255, 0), 4: (0, 255, 255), 5: (0, 0, 255), 6: (255, 0, 255), 7: (0, 0, 0), 8: (128, 128, 128), 9: (192, 192, 192)} GRAY_ACI = (51, 91, 132, 173, 214, 255) # 250~255 BRIGHT_ACI = (1, 1, 0.8, 0.8, 0.6, 0.6, 0.5, 0.5, 0.3, 0.3) TEXT_ANCHOR_72 = {1: "middle", 2: "end", 4: "middle", 5: "middle"} def aci_rgb(i): if i in BASIC_ACI: return BASIC_ACI[i] if 250 <= i <= 255: g = GRAY_ACI[i - 250] return (g, g, g) if 10 <= i <= 249: hue = ((i - 10) // 10) * 15 / 360 sat = 1.0 if i % 2 == 0 else 0.5 r, g, b = colorsys.hsv_to_rgb(hue, sat, BRIGHT_ACI[i % 10]) return (round(r * 255), round(g * 255), round(b * 255)) return BASIC_ACI[7] def read_pairs(path): # DXF 는 (그룹 코드 줄, 값 줄) 쌍이다 with open(path, encoding="utf-8", errors="replace") as f: lines = [ln[:-1] if ln.endswith("\r") else ln for ln in f.read().split("\n")] if lines and lines[-1] == "": lines.pop() return [(lines[i].strip(), lines[i + 1].strip()) for i in range(0, len(lines) - 1, 2)] def sections(pairs): # 구역 이름 → (내용 시작 줄, ENDSEC 줄) out = {} for i in range(len(pairs) - 1): if pairs[i] == ("0", "SECTION") and pairs[i + 1][0] == "2": end = next(j for j in range(i + 2, len(pairs)) if pairs[j] == ("0", "ENDSEC")) out[pairs[i + 1][1]] = (i + 2, end) return out def groups_of(pairs, lo, hi): # 객체 묶음: 0 줄마다 시작. POLYLINE 과 코드 66 이 1 인 INSERT 는 SEQEND 까지 한 묶음 groups = [] i = lo while i < hi: j = i + 1 while j < hi and pairs[j][0] != "0": j += 1 head = pairs[i][1] flag66 = next((v for c, v in pairs[i + 1:j] if c == "66"), None) if head == "POLYLINE" or (head == "INSERT" and flag66 == "1"): while j < hi and pairs[j] != ("0", "SEQEND"): j += 1 while j < hi and pairs[j][0] != "0": j += 1 if j < hi: j += 1 while j < hi and pairs[j][0] != "0": j += 1 groups.append(pairs[i:j]) i = j return groups def val(g, code): return next((v for c, v in g[1:] if c == code), None) def num(g, code, default=0.0): v = val(g, code) return float(v) if v is not None else default def layers_of(pairs, lo, hi): # 레이어 표: 이름 → 색(절댓값), 꺼짐(음수), 얼림(70 의 1 비트) tab = {} for g in groups_of(pairs, lo, hi): if g[0][1] != "LAYER": continue color = int(val(g, "62") or 7) tab[val(g, "2")] = {"color": abs(color), "off": color < 0, "frozen": bool(int(val(g, "70") or 0) & 1)} return tab def blocks_of(pairs, lo, hi): # 블록 이름 → 기준점과 안의 객체 묶음 blocks = {} cur = None for g in groups_of(pairs, lo, hi): head = g[0][1] if head == "BLOCK": cur = {"base": (num(g, "10"), num(g, "20")), "ents": []} blocks[val(g, "2")] = cur elif head == "ENDBLK": cur = None elif cur is not None: cur["ents"].append(g) return blocks def pts_of(g): # 객체 좌표 (select_views.py 와 같은 규칙): 코드 10·11 의 X 와 뒤따르는 20·21 의 Y. # POLYLINE 은 머리 기준점을 빼고 VERTEX 부터, CIRCLE·ARC 는 중심 ± 반지름도 start = 0 if g[0][1] == "POLYLINE": start = next((k for k, p in enumerate(g) if p == ("0", "VERTEX")), None) if start is None: return [] body = g[start:] pts = [] for k, (c, v) in enumerate(body[:-1]): if c in COORD_X and body[k + 1][0] in COORD_Y: pts.append((float(v), float(body[k + 1][1]))) if g[0][1] in ("CIRCLE", "ARC") and pts: r = num(g, "40") cx, cy = pts[0] pts += [(cx - r, cy - r), (cx + r, cy + r)] return pts def inside(pts, box): x0, y0, x1, y1 = box return all(x0 <= x <= x1 and y0 <= y <= y1 for x, y in pts) def mul(p, q): # 행렬 (a, b, c, d, e, f): x' = a x + c y + e, y' = b x + d y + f. mul(p, q) = p∘q, q 를 먼저 적용한다 a1, b1, c1, d1, e1, f1 = p a2, b2, c2, d2, e2, f2 = q return (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, a1 * c2 + c1 * d2, b1 * c2 + d1 * d2, a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1) def apply(m, p): a, b, c, d, e, f = m return (a * p[0] + c * p[1] + e, b * p[0] + d * p[1] + f) def det(m): return m[0] * m[3] - m[1] * m[2] def dir_angle(m, deg): # 도면에서 deg 방향인 선이 SVG 에서 향하는 각도 (도) rot = math.radians(deg) vx = m[0] * math.cos(rot) + m[2] * math.sin(rot) vy = m[1] * math.cos(rot) + m[3] * math.sin(rot) return math.degrees(math.atan2(vy, vx)) def fmt(v): s = ("%.3f" % v).rstrip("0").rstrip(".") return "0" if s in ("", "-0") else s def pts_attr(points): return " ".join(f"{fmt(x)},{fmt(y)}" for x, y in points) def arc_pts(p, q, b): # 꼭짓점 p→q 사이 볼록 원호(bulge b, 양수 = 반시계)의 안쪽 점들 (p, q 제외) th = 4 * math.atan(b) chord = math.dist(p, q) h = chord / 2 / math.tan(th / 2) nx, ny = -(q[1] - p[1]) / chord, (q[0] - p[0]) / chord cx = (p[0] + q[0]) / 2 + nx * h cy = (p[1] + q[1]) / 2 + ny * h r = math.dist(p, (cx, cy)) a0 = math.atan2(p[1] - cy, p[0] - cx) k = max(2, math.ceil(abs(math.degrees(th)) / ARC_STEP)) return [(cx + r * math.cos(a0 + th * t / k), cy + r * math.sin(a0 + th * t / k)) for t in range(1, k)] def verts_of(codes): # 꼭짓점: 코드 10 마다 새 점, 20 = Y, 42 = 볼록 vs = [] for c, v in codes: if c == "10": vs.append([float(v), 0.0, 0.0]) elif c == "20" and vs: vs[-1][1] = float(v) elif c == "42" and vs: vs[-1][2] = float(v) return vs def mtext_clean(s): # MTEXT 서식 코드 지우기: \P 는 공백, \f…; 와 높이·폭 같은 코드, 중괄호 s = s.replace("\\P", " ") s = re.sub(r"\\[fF][^;]*;", "", s) s = re.sub(r"\\[HhWwQqTtCcAa][^;]*;", "", s) return s.replace("{", "").replace("}", "") def text_clean(s): return s.replace("%%c", "Ø").replace("%%d", "°").replace("%%p", "±").replace("%%u", "") def xml_escape(s): return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) def emit(g, m, layers, blocks, parent_layer, inherit, out, depth): # 객체 하나를 m(도면 → SVG 행렬)으로 옮겨 색별 목록 out 에 넣는다 head = g[0][1] layer = val(g, "8") or "0" if depth and layer == "0": layer = parent_layer lay = layers.get(layer, {"color": 7, "off": False, "frozen": False}) if lay["off"] or lay["frozen"] or layer == HIDDEN_LAYER: return c = int(val(g, "62") or 256) if c == 256: rgb = aci_rgb(lay["color"]) elif c == 0: rgb = inherit if inherit is not None else aci_rgb(lay["color"]) else: rgb = aci_rgb(abs(c)) key = "rgb(%d,%d,%d)" % rgb els = out.setdefault(key, []) scale = math.sqrt(abs(det(m))) if head == "LINE": pts = [apply(m, (num(g, "10"), num(g, "20"))), apply(m, (num(g, "11"), num(g, "21")))] els.append(f'') elif head in ("LWPOLYLINE", "POLYLINE"): codes = [p for p in g[1:] if p[0] != "0"] if head == "LWPOLYLINE" else \ [p for p in g[next(k for k, q in enumerate(g) if q == ("0", "VERTEX")):] if p[0] != "0"] vs = verts_of(codes) closed = bool(int(val(g, "70") or 0) & 1) if len(vs) >= 2: ring = vs + ([vs[0]] if closed else []) pts = [] for (x0, y0, b0), (x1, y1, _) in zip(ring, ring[1:]): pts.append((x0, y0)) if b0: pts += arc_pts((x0, y0), (x1, y1), b0) pts.append((ring[-1][0], ring[-1][1])) els.append(f'') elif head == "LEADER": vs = verts_of([p for p in g[1:] if p[0] in ("10", "20")]) if len(vs) >= 2: els.append(f'') elif head == "CIRCLE": cx, cy = apply(m, (num(g, "10"), num(g, "20"))) els.append(f'') elif head == "ARC": cx, cy, r = num(g, "10"), num(g, "20"), num(g, "40") s0 = num(g, "50") span = (num(g, "51") - s0) % 360 or 360 k = max(2, math.ceil(span / ARC_STEP)) pts = [(cx + r * math.cos(math.radians(s0 + span * t / k)), cy + r * math.sin(math.radians(s0 + span * t / k))) for t in range(k + 1)] els.append(f'') elif head == "SOLID": # 채움 사각형: 점 순서 10·11·13·12 order = [("10", "20"), ("11", "21"), ("13", "23"), ("12", "22")] pts = [apply(m, (num(g, x), num(g, y))) for x, y in order] els.append(f'') elif head == "TEXT": h = num(g, "40") ax, ay = ("11", "21") if (val(g, "72") or "0") != "0" or (val(g, "73") or "0") != "0" else ("10", "20") x, y = apply(m, (num(g, ax), num(g, ay))) ang = dir_angle(m, num(g, "50")) anchor = TEXT_ANCHOR_72.get(int(val(g, "72") or 0), "start") tr = f' transform="rotate({fmt(ang)} {fmt(x)} {fmt(y)})"' if abs(ang) > 1e-9 else "" els.append(f'{xml_escape(text_clean(val(g, "1") or ""))}') elif head == "MTEXT": h = num(g, "40") text = "".join(v for c, v in g if c == "3") + (val(g, "1") or "") x, y = apply(m, (num(g, "10"), num(g, "20"))) ang = dir_angle(m, num(g, "50")) col = (int(val(g, "71") or 1) - 1) % 3 anchor = ("start", "middle", "end")[col] tr = f' transform="rotate({fmt(ang)} {fmt(x)} {fmt(y)})"' if abs(ang) > 1e-9 else "" els.append(f'{xml_escape(mtext_clean(text))}') elif head == "INSERT": name = val(g, "2") if name in blocks and depth < MAX_DEPTH: # 블록 기준점을 빼고 (배율 → 회전 → 위치) 순으로 놓는다 bx, by = blocks[name]["base"] sx, sy = num(g, "41", 1.0), num(g, "42", 1.0) rot = math.radians(num(g, "50")) ix, iy = num(g, "10"), num(g, "20") t = mul((1, 0, 0, 1, ix, iy), mul((math.cos(rot), math.sin(rot), -math.sin(rot), math.cos(rot), 0, 0), mul((sx, 0, 0, sy, 0, 0), (1, 0, 0, 1, -bx, -by)))) for child in blocks[name]["ents"]: emit(child, mul(m, t), layers, blocks, layer, rgb, out, depth + 1) elif head == "DIMENSION": name = val(g, "2") if name in blocks and depth < MAX_DEPTH: for child in blocks[name]["ents"]: emit(child, m, layers, blocks, layer, rgb, out, depth + 1) def main(argv): if len(argv) != 4: print("사용법: python3 tools/dxf_to_svg.py <출력.svg>") return 2 with open(argv[2], encoding="utf-8") as f: cfg = json.load(f) pairs = read_pairs(argv[1]) secs = sections(pairs) layers = layers_of(pairs, *secs["TABLES"]) blocks = blocks_of(pairs, *secs["BLOCKS"]) groups = groups_of(pairs, *secs["ENTITIES"]) out = {} kept = 0 for g in groups: pts = pts_of(g) if not pts: continue k = next((k for k, b in enumerate(cfg["boxes"]) if inside(pts, b["range"])), None) if k is None: continue dx, dy = cfg["boxes"][k]["place"] # 도면 (X, Y) → SVG (X − dx, dy − Y): 축척 없이 옮기고 Y 를 뒤집는다 emit(g, (1.0, 0.0, 0.0, -1.0, -dx, dy), layers, blocks, "0", None, out, 0) kept += 1 vb = " ".join(str(v) for v in cfg["viewBox"]) lines = [f'', ""] n_el = 0 for key, els in out.items(): lines.append(f'') lines += els lines.append("") n_el += len(els) lines.append("") with open(argv[3], "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") print(f"완료: {argv[3]} (객체 {kept}개 → 요소 {n_el}개)") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))