Files
오문성andClaude Haiku 5.5 9068b6c7f2 공개용 정리본: 1차 제출본(도면 좌표·과업명 제거)으로 새 이력 시작 (SPEC 34장)
- 정리는 코치 세션이 저장소 밖 도구로 수행, 하이쿠 세션이 커밋.
- 옛 81커밋은 로컬에만 보존. 기록은 evidence/gitlog_v1.txt.
- F13 기준 변경: .gitignore 에 private/*.dwg·private/full.dxf 제외 (SPEC 34장 끝 문장).
- check.sh 결과: 결과: PASS 151 / FAIL 0 / SKIP 3 (검사 범위 F01~F46)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-27 14:27:50 +09:00

358 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# dxf_to_svg.py — DXF 를 웹 왼쪽 칸의 SVG 로 바꾼다 (표준 라이브러리만, SPEC 29.1)
# 사용: python3 tools/dxf_to_svg.py <drawing.dxf> <views.json> <출력.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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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'<polyline points="{pts_attr(pts)}" />')
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'<polyline points="{pts_attr([apply(m, p) for p in pts])}" />')
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'<polyline points="{pts_attr([apply(m, (v[0], v[1])) for v in vs])}" />')
elif head == "CIRCLE":
cx, cy = apply(m, (num(g, "10"), num(g, "20")))
els.append(f'<circle cx="{fmt(cx)}" cy="{fmt(cy)}" r="{fmt(num(g, "40") * scale)}" />')
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'<polyline points="{pts_attr([apply(m, p) for p in pts])}" />')
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'<polygon points="{pts_attr(pts)}" fill="{key}" stroke="none" />')
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'<text x="{fmt(x)}" y="{fmt(y)}" font-family="Dotum,\'sans-serif\'" font-size="{fmt(h * TEXT_EM * scale)}" '
f'text-anchor="{anchor}" fill="{key}" stroke="none"{tr}>{xml_escape(text_clean(val(g, "1") or ""))}</text>')
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'<text x="{fmt(x)}" y="{fmt(y)}" font-family="Dotum,\'sans-serif\'" font-size="{fmt(h * TEXT_EM * scale)}" '
f'text-anchor="{anchor}" fill="{key}" stroke="none"{tr}>{xml_escape(mtext_clean(text))}</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 <drawing.dxf> <views.json> <출력.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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}">',
"<style>* { vector-effect: non-scaling-stroke; }</style>"]
n_el = 0
for key, els in out.items():
lines.append(f'<g fill="none" stroke="{key}" stroke-width="1">')
lines += els
lines.append("</g>")
n_el += len(els)
lines.append("</svg>")
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))