공개용 정리본: 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>
This commit is contained in:
오문성andClaude Haiku 5.5 committed 2026-10-27 14:27:50 +09:00
commit 9068b6c7f2
31 files changed
+374207

No files matched your search

+357
View File
@@ -0,0 +1,357 @@
#!/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))