공개용 정리본: 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

+162
View File
@@ -0,0 +1,162 @@
{
"_": "설계 규칙 (SPEC 27장). 도면 치수·좌표는 넣지 않는다.",
"parts": [
{
"id": "ladder_19B",
"name": "사다리 (침사구 아래 동쪽 벽)",
"kind": "ladder",
"type": "ladder",
"color": "#d0a040",
"x0": 4668,
"x1": 4708,
"yc": 0,
"width": 400,
"z0": -68700,
"z1": -66200,
"rung": 300
},
{
"id": "pump_pipe",
"name": "펌프 배출관 (STS Ø100)",
"kind": "pipe",
"type": "ring",
"color": "#9aa0a6",
"cx": 0,
"cy": 4220,
"z0": -68800,
"z1": -2000,
"r_out": 57,
"r_in": 50
},
{
"id": "ladder_up_W",
"name": "위쪽 사다리 서쪽 (1B→분기구 바닥)",
"kind": "ladder_up",
"type": "ladder",
"color": "#d0a040",
"x0": -4622,
"x1": -4582,
"yc": -1260,
"width": 400,
"z0": -7400,
"z1": -4100,
"rung": 300
},
{
"id": "ladder_up_C",
"name": "위쪽 사다리 가운데 (1B→맨홀→지표)",
"kind": "ladder_up",
"type": "ladder",
"color": "#d0a040",
"x0": -20,
"x1": 20,
"yc": 0,
"width": 360,
"z0": -7400,
"z1": 0,
"rung": 300
},
{
"id": "ladder_vent_wall",
"name": "환기구 벽 사다리 (1B→덕트 바닥)",
"kind": "ladder_vent",
"type": "ladder",
"color": "#d0a040",
"angle_deg": 300.3,
"x0": 4582,
"x1": 4622,
"yc": 0,
"width": 400,
"z0": -7400,
"z1": -4100,
"rung": 300
},
{
"id": "ladder_vent_tower",
"name": "환기탑 사다리 (덕트 바닥→지표)",
"kind": "ladder_vent",
"type": "ladder",
"color": "#d0a040",
"angle_deg": 30.3,
"x0": 10100,
"x1": 10140,
"yc": -8650,
"width": 400,
"z0": -4100,
"z1": 0,
"rung": 300
}
],
"style": {
"wall": {
"color": "#8a9bb0",
"alpha": 0.25
},
"topslab": {
"color": "#c8c8c8"
},
"neck": {
"color": "#b8b8b8"
},
"slab": {
"color": "#c8c8c8"
},
"pad": {
"color": "#e0b060"
},
"bottom": {
"color": "#a0a0a0"
},
"lean": {
"color": "#707070"
},
"tunnel": {
"color": "#b08a5a"
},
"stair": {
"color": "#7fb07f"
},
"fill": {
"color": "#c8bfa8"
},
"weir": {
"color": "#9a9a9a"
},
"pit": {
"color": "#8fc3e0",
"alpha": 0.25
},
"branch": {
"color": "#9fb8c8"
},
"vent": {
"color": "#b8c8a0"
},
"railing": {
"color": "#d8c890"
},
"pipe": {
"color": "#9aa0a6"
},
"ladder": {
"color": "#d0a040"
},
"ladder_up": {
"color": "#d0a040"
},
"ladder_vent": {
"color": "#d0a040"
}
},
"ids": {
"fill_gravel": {
"color": "#7a7a7a"
},
"fill_pad_high": {
"color": "#e0d8c4"
},
"pit_tbm": {
"color": "#a8d8f0"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# dwg_to_dxf.sh — DWG 를 DXF(글자 파일)로 바꾼다 (GNU LibreDWG 의 dwg2dxf 사용, SPEC 28장)
# 사용: bash tools/dwg_to_dxf.sh <입력.dwg> <출력.dxf>
set -eu
if [ "$#" -ne 2 ]; then
echo "사용법: bash tools/dwg_to_dxf.sh <입력.dwg> <출력.dxf>"
exit 2
fi
DWG2DXF="$(command -v dwg2dxf || true)"
if [ -z "$DWG2DXF" ] && [ -x "$HOME/.local/bin/dwg2dxf" ]; then
DWG2DXF="$HOME/.local/bin/dwg2dxf"
fi
if [ -z "$DWG2DXF" ]; then
echo "dwg2dxf 를 찾지 못했다. 설치는 README.md 의 「변환 패키지」 절을 본다."
exit 2
fi
"$DWG2DXF" -y -o "$2" "$1"
echo "완료: $2"
+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))
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# make_model.sh — DWG 한 장에서 추출 모델까지 한 번에 만든다 (SPEC 28장)
# 사용: bash tools/make_model.sh [입력.dwg] [출력 폴더]
# 기본: 입력 private/drawing.dwg, 출력 private/
# 만드는 것: full.dxf(전체 DXF) → drawing.dxf(상자 안만) → drawing_gen.svg(웹 도면) → model_extracted.js
# 출력 폴더가 private/ 이면 리포 루트의 model_extracted.js 도 새로 쓴다.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
IN="${1:-$ROOT/private/drawing.dwg}"
OUT="${2:-$ROOT/private}"
if [ ! -f "$IN" ]; then
echo "입력 DWG 가 없다: $IN"
exit 2
fi
mkdir -p "$OUT"
OUT="$(cd "$OUT" && pwd)"
bash "$ROOT/tools/dwg_to_dxf.sh" "$IN" "$OUT/full.dxf"
python3 "$ROOT/tools/select_views.py" "$OUT/full.dxf" "$ROOT/tools/views.json" "$OUT/drawing.dxf"
python3 "$ROOT/tools/dxf_to_svg.py" "$OUT/drawing.dxf" "$ROOT/tools/views.json" "$OUT/drawing_gen.svg"
python3 "$ROOT/tools/extract_from_dxf.py" "$OUT/drawing.dxf" "$OUT/model_extracted.js" "$ROOT/tools/views.json"
if [ "$OUT" = "$ROOT/private" ]; then
cp "$OUT/model_extracted.js" "$ROOT/model_extracted.js"
echo "갱신: $ROOT/model_extracted.js"
fi
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
# prep_drawing.py — 도면 SVG 를 화면에서 선명하게 만든다 (표준 라이브러리만 사용)
# 사용: python3 tools/prep_drawing.py <원본.svg> private/drawing.svg
# 원본은 고치지 않는다. 0 이 아닌 stroke-width 를 1 로 바꾸고,
# 루트 <svg> 바로 안에 <style> 을 넣어 모든 선이 화면 1px 로 그려지게 한다.
import re
import sys
def prep(text):
# 선 굵기 속성: stroke-width="0.75" → "1" (0 은 그대로 둔다: 선 없음)
def fix_attr(m):
value = m.group(1)
try:
if float(value) == 0:
return m.group(0)
except ValueError:
return m.group(0)
return 'stroke-width="1"'
text = re.sub(r'stroke-width="([^"]*)"', fix_attr, text)
# style 속성 안의 stroke-width:0.75 도 같은 규칙
def fix_style(m):
try:
if float(m.group(1)) == 0:
return m.group(0)
except ValueError:
return m.group(0)
return 'stroke-width:1'
text = re.sub(r'stroke-width:\s*([0-9.]+)', fix_style, text)
# 루트 <svg ...> 바로 뒤에 모든 요소의 선을 화면 기준으로 고정
style = '<style>* { vector-effect: non-scaling-stroke; }</style>'
text, n = re.subn(r'(<svg\b[^>]*>)', lambda m: m.group(1) + style, text, count=1)
if n != 1:
raise SystemExit('루트 <svg> 태그를 찾지 못했다')
return text
def main(argv):
if len(argv) != 3:
print('사용법: python3 tools/prep_drawing.py <원본.svg> <출력.svg>')
return 2
src, dst = argv[1], argv[2]
with open(src, encoding='utf-8') as f:
text = f.read()
with open(dst, 'w', encoding='utf-8') as f:
f.write(prep(text))
print(f'완료: {dst}')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
# select_views.py — DXF 에서 이 과제가 쓰는 그림 상자 안의 객체만 남긴다 (표준 라이브러리만, SPEC 28장)
# 사용: python3 tools/select_views.py <전체.dxf> <views.json> <출력.dxf>
# 상자·뺄 레이어·뺄 블록 이름은 views.json 에서만 읽는다. 이 파일에는 도면 좌표·레이어 이름이 없다.
import json
import sys
COORD_X = ("10", "11")
COORD_Y = ("20", "21")
def read_pairs(path):
# DXF 는 (코드 줄, 값 줄) 쌍이다. 원본 글자를 그대로 두려고 줄 끝만 떼고 저장한다.
with open(path, encoding="utf-8", errors="surrogateescape", newline="") as f:
lines = f.read().split("\n")
lines = [ln[:-1] if ln.endswith("\r") else ln for ln in lines]
if lines and lines[-1] == "":
lines.pop()
return [(lines[i], lines[i + 1]) for i in range(0, len(lines) - 1, 2)]
def code_of(pair):
return pair[0].strip()
def value_of(pair):
return pair[1].strip()
def find_entities(pairs):
# ENTITIES 구역의 시작(SECTION 다음 줄이 ENTITIES)과 끝(ENDSEC) 위치
for s in range(len(pairs) - 1):
if code_of(pairs[s]) == "0" and value_of(pairs[s]) == "SECTION" and value_of(pairs[s + 1]) == "ENTITIES":
for e in range(s + 2, len(pairs)):
if code_of(pairs[e]) == "0" and value_of(pairs[e]) == "ENDSEC":
return s, e
raise SystemExit("ENTITIES 구역을 찾지 못했다")
def group_entities(body):
# 객체 묶음: 보통은 0 줄 하나에서 다음 0 줄 전까지.
# POLYLINE 은 SEQEND 까지. 코드 66 이 1 인 INSERT 는 ATTRIB…SEQEND 까지.
groups = []
i = 0
while i < len(body):
start = i
i += 1
while i < len(body) and code_of(body[i]) != "0":
i += 1
head_type = value_of(body[start])
if head_type == "POLYLINE" or (head_type == "INSERT" and has_attrib_flag(body[start:i])):
while i < len(body) and not (code_of(body[i]) == "0" and value_of(body[i]) == "SEQEND"):
i += 1
if i < len(body):
i += 1 # SEQEND 자체도 묶음에 넣는다
# 다음 객체 머리(0 줄)까지 같은 묶음
while i < len(body) and code_of(body[i]) != "0":
i += 1
groups.append(body[start:i])
return groups
def has_attrib_flag(head_pairs):
# INSERT 머리(첫 0 줄 다음) 안의 코드 66 값이 1 이면 속성이 뒤따른다
for p in head_pairs[1:]:
if code_of(p) == "66":
return value_of(p) == "1"
return False
def head_flag(group, code):
# 묶음 머리 객체의 코드 값 (ATTRIB 등 뒤 객체 값은 보지 않는다)
for p in group[1:]:
if code_of(p) == "0":
break
if code_of(p) == code:
return value_of(p)
return None
def coords(group):
# 좌표: 코드 10·11 의 X 와 그 뒤 20·21 의 Y. POLYLINE 은 머리의 기준점을 빼고 VERTEX 부터.
start = 0
if value_of(group[0]) == "POLYLINE":
for k, p in enumerate(group):
if code_of(p) == "0" and value_of(p) == "VERTEX":
start = k
break
else:
return []
pts = []
body = group[start:]
for k, p in enumerate(body):
if code_of(p) in COORD_X and k + 1 < len(body) and code_of(body[k + 1]) in COORD_Y:
try:
pts.append((float(value_of(p)), float(value_of(body[k + 1]))))
except ValueError:
pass
# CIRCLE·ARC: 중심 ± 반지름(코드 40)
if value_of(group[0]) in ("CIRCLE", "ARC") and pts:
r = head_flag(group, "40")
try:
r = float(r)
except (TypeError, ValueError):
r = 0.0
cx, cy = pts[0]
pts += [(cx - r, cy - r), (cx + r, cy + r)]
return pts
def dropped(group, cfg):
head = group[0]
if head_flag(group, "67") == "1":
return True # 배치 공간
if head_flag(group, "8") in cfg["drop_layers"]:
return True
if value_of(head) == "INSERT" and cfg["drop_block_word"] in (head_flag(group, "2") or ""):
return True
return False
def inside(pts, box):
x0, y0, x1, y1 = box
return all(x0 <= x <= x1 and y0 <= y <= y1 for x, y in pts)
def main(argv):
if len(argv) != 4:
print("사용법: python3 tools/select_views.py <전체.dxf> <views.json> <출력.dxf>")
return 2
with open(argv[2], encoding="utf-8") as f:
cfg = json.load(f)
boxes = [b["range"] for b in cfg["boxes"]]
pairs = read_pairs(argv[1])
s, e = find_entities(pairs)
groups = group_entities(pairs[s + 2:e])
kept, allpts = [], []
for g in groups:
if dropped(g, cfg):
continue
pts = coords(g)
if not pts:
continue
if any(inside(pts, b) for b in boxes):
kept.append(g)
allpts += pts
# 헤더의 $EXTMIN·$EXTMAX 를 남긴 좌표의 최소·최대로 바꾼다 (헤더 안 10/20 줄만)
head = pairs[:s]
if allpts:
xs = [p[0] for p in allpts]
ys = [p[1] for p in allpts]
new_min, new_max = (min(xs), min(ys)), (max(xs), max(ys))
for k in range(len(head) - 1):
name = value_of(head[k]) if code_of(head[k]) == "9" else None
if name not in ("$EXTMIN", "$EXTMAX"):
continue
target = new_min if name == "$EXTMIN" else new_max
j = k + 1
while j + 1 < len(head) and code_of(head[j]) != "9":
if code_of(head[j]) == "10":
head[j] = (head[j][0], repr(target[0]))
elif code_of(head[j]) == "20":
head[j] = (head[j][0], repr(target[1]))
j += 1
out = head + pairs[s:s + 2] + [p for g in kept for p in g] + pairs[e:]
with open(argv[3], "w", encoding="utf-8", errors="surrogateescape", newline="") as f:
for c, v in out:
f.write(c + "\r\n" + v + "\r\n")
print(f"완료: {argv[3]} (객체 {len(kept)}개 / 전체 {len(groups)}개)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+80
View File
@@ -0,0 +1,80 @@
{
"_": "도면 범위 지정 (사람 입력, SPEC 28장). DWG 한 파일에 수직구 두 개와 옛 개정판이 함께 있어, 이 과제가 쓰는 그림 상자를 사람이 정한다. 좌표는 DWG 모형 공간(m). place = 그 상자의 점 (X, Y) 를 SVG (X − place[0], place[1] − Y) 에 놓는다(축척 없이 옮김, 1 = 1 m). viewBox·fit 은 왼쪽 도면 칸의 크기·처음 화면.",
"boxes": [
{
"name": "전체 단면 A-A·B-B",
"range": [
11307.483,
8061.096,
11407.483,
8151.096
],
"place": [
11200.381,
8314.952
]
},
{
"name": "평면도 1·2",
"range": [
11527.483,
8103.096,
11555.483,
8149.096
],
"place": [
11313.624,
8313.914
]
},
{
"name": "단면 A-A·B-B (4B~15B 생략)",
"range": [
11570.483,
8099.096,
11619.483,
8149.096
],
"place": [
11322.610,
8313.912
]
},
{
"name": "슬래브 평면 5개",
"range": [
11653.483,
8103.096,
11725.483,
8147.096
],
"place": [
11437.082,
8360.590
]
}
],
"drop_layers": [
"T-타이틀",
"0-도곽",
"PDF_문자",
"Ptpoint",
"Grid",
"CP",
"CP-NO",
"CP-EL"
],
"drop_block_word": "도각",
"viewBox": [
0,
0,
443,
443
],
"fit": [
118,
160,
245,
250
]
}