125 lines
4.3 KiB
Python
125 lines
4.3 KiB
Python
"""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": <ezdxf 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
|