- 정리는 코치 세션이 저장소 밖 도구로 수행, 하이쿠 세션이 커밋. - 옛 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>
358 lines
14 KiB
Python
358 lines
14 KiB
Python
#!/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("&", "&").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'<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))
|