"""Exact symbol counting from CAD vector data. CAD drawings place repeated symbols as *block references* (DXF INSERT entities). The file therefore contains the exact count and exact location of every symbol — no raster matching, no threshold, no false positives. This module reads that structure. count_blocks() → list of named blocks with exact counts + every instance's model coordinates. render_block_thumbnail() draws one block's geometry so the user can visually identify which block is the symbol they want. """ from __future__ import annotations import logging import re from pathlib import Path import cairosvg import ezdxf from ezdxf.addons.drawing import Frontend, RenderContext, layout from ezdxf.addons.drawing.config import ( BackgroundPolicy, ColorPolicy, Configuration, ) from ezdxf.addons.drawing.svg import SVGBackend logger = logging.getLogger(__name__) # CAD-bookkeeping / paste-artifact block names users never want to count. _NOISE_PREFIX = re.compile(r"^(\*|A\$C|Clipboard|DWG\d|_|\$|ACAD_)", re.I) def _is_noise(name: str) -> bool: if not name or _NOISE_PREFIX.match(name): return True if name.isdigit(): # purely numeric auto-names return True if len(name.strip()) <= 1: return True return False def count_blocks(dxf_path: Path) -> dict: """Parse modelspace block references. Returns: { "blocks": [ {"name", "count", "noise": bool, "instances": [{"x","y","rot"}, ...]}, ... sorted: meaningful first, then by count desc ], "doc": # reused for thumbnail rendering } """ doc = ezdxf.readfile(str(dxf_path)) msp = doc.modelspace() blocks: dict[str, dict] = {} for ins in msp.query("INSERT"): name = ins.dxf.name rec = blocks.setdefault(name, {"name": name, "instances": []}) # MINSERT (array insert) places row_count × column_count copies. rows = int(getattr(ins.dxf, "row_count", 1) or 1) cols = int(getattr(ins.dxf, "column_count", 1) or 1) rsp = float(getattr(ins.dxf, "row_spacing", 0) or 0) csp = float(getattr(ins.dxf, "column_spacing", 0) or 0) ip = ins.dxf.insert rot = float(getattr(ins.dxf, "rotation", 0) or 0) for r in range(max(rows, 1)): for c in range(max(cols, 1)): rec["instances"].append({ "x": float(ip.x) + c * csp, "y": float(ip.y) + r * rsp, "rot": rot, }) out = [] for name, rec in blocks.items(): rec["count"] = len(rec["instances"]) rec["noise"] = _is_noise(name) out.append(rec) # Meaningful named blocks first, each sorted by count desc. out.sort(key=lambda b: (b["noise"], -b["count"])) total = sum(b["count"] for b in out) logger.info("Block scan: %d instances, %d distinct blocks (%d meaningful)", total, len(out), sum(1 for b in out if not b["noise"])) return {"blocks": out, "doc": doc} def render_block_thumbnail(src_doc, name: str, out_path: Path, px: int = 200) -> Path: """Render one block definition's geometry to a square PNG thumbnail.""" src_block = src_doc.blocks.get(name) if src_block is None: raise ValueError(f"Block not found: {name}") nd = ezdxf.new() nb = nd.blocks.new(name=name) if name not in nd.blocks \ else nd.blocks.get(name) for e in src_block: try: nb.add_foreign_entity(e) except Exception: # Some entity types can't be copied across docs — skip; the # remaining geometry is still enough to identify the symbol. pass msp = nd.modelspace() msp.add_blockref(name, (0, 0)) cfg = Configuration( background_policy=BackgroundPolicy.WHITE, color_policy=ColorPolicy.BLACK, ) backend = SVGBackend() page = layout.Page(120, 120, layout.Units.mm, layout.Margins.all(5)) Frontend(RenderContext(nd), backend, config=cfg).draw_layout( msp, finalize=True) svg = backend.get_string(page) png = cairosvg.svg2png(bytestring=svg.encode("utf-8"), output_width=px, output_height=px) Path(out_path).write_bytes(png) return out_path