diff --git a/scripts/render_pid_catalog.py b/scripts/render_pid_catalog.py index 9aa0dcf..873d5c8 100644 --- a/scripts/render_pid_catalog.py +++ b/scripts/render_pid_catalog.py @@ -7,6 +7,13 @@ The sheet is rendered through the same ezdxf/matplotlib frontend symbols stays legible on (the tool's 16 x 9 in screenshot is sized for a single drawing, not a catalogue). +``render`` reports what was actually drawn, not what the catalogue lists: +``symbols`` counts the specs whose insert left geometry with a real extent +in the modelspace (an empty block or a builder that draws nothing is +``blank``), and ``ink`` is the share of the PNG's pixels that are not the +background colour -- the labels-only sheet is the baseline the test +compares against. + uv run --frozen python scripts/render_pid_catalog.py """ @@ -46,6 +53,39 @@ def _render_png(backend, figsize: tuple[float, float], dpi: int) -> bytes: return buf.getvalue() +def ink_fraction(png: bytes) -> float: + """The share of pixels that are not the sheet's background colour. + + The background is the most common colour, so the number does not depend + on which background the frontend paints; a blank sheet reports 0.0. + """ + import io + + import numpy as np + from matplotlib.image import imread + + rgba = imread(io.BytesIO(png)) + pixels = (np.asarray(rgba).reshape(-1, rgba.shape[-1]) * 255).astype(np.uint32) + packed = np.zeros(len(pixels), dtype=np.uint32) + for channel in range(pixels.shape[1]): # one integer per pixel; unique() stays 1-D + packed = (packed << 8) | pixels[:, channel] + _colours, counts = np.unique(packed, return_counts=True) + return float(1.0 - counts.max() / len(packed)) + + +def _has_extent(doc, handle: str) -> bool: + """Whether the entity behind ``handle`` has any length once its block + content is resolved -- an INSERT of an empty block has no extent, while + a spec break (one vertical line) legitimately has none across x.""" + from ezdxf import bbox + + entity = doc.entitydb.get(handle) + if entity is None: + return False + box = bbox.extents([entity], fast=True) + return box.has_data and (box.size.x > 0 or box.size.y > 0) + + async def _render(output: Path) -> dict: from backends.ezdxf_backend import EzdxfBackend from engineering.layers import apply_layer_set @@ -57,12 +97,18 @@ async def _render(output: Path) -> dict: try: await backend.drawing_new() await apply_layer_set(backend, "pid") + doc = backend._require_doc() specs = all_specs() + blank: list[str] = [] for index, spec in enumerate(specs): col, row = index % COLUMNS, index // COLUMNS x, y = col * CELL, -row * CELL layer = "PROCESS-PIPING-MAIN" if spec.family == "marker" else None - await insert_symbol(backend, spec, x, y, 0.0, 1.0, {}, layer) + before = len(doc.modelspace()) + placed = await insert_symbol(backend, spec, x, y, 0.0, 1.0, {}, layer) + handles = [placed.get("handle")] if len(doc.modelspace()) > before else [] + if not any(_has_extent(doc, h) for h in handles if h): + blank.append(spec.name) await backend.entity_create_text( spec.name.replace("PID_", ""), x - CELL / 2 + 2, @@ -73,7 +119,13 @@ async def _render(output: Path) -> dict: png = await asyncio.to_thread(_render_png, backend, FIGSIZE, DPI) output.parent.mkdir(parents=True, exist_ok=True) output.write_bytes(png) - return {"output": str(output), "symbols": len(specs), "bytes": len(png)} + return { + "output": str(output), + "symbols": len(specs) - len(blank), + "blank": blank, + "bytes": len(png), + "ink": await asyncio.to_thread(ink_fraction, png), + } finally: await backend.disconnect() diff --git a/tests/test_pid_resources_prompt.py b/tests/test_pid_resources_prompt.py index 9786ca0..1c8616d 100644 --- a/tests/test_pid_resources_prompt.py +++ b/tests/test_pid_resources_prompt.py @@ -65,9 +65,25 @@ async def test_prompt_walks_the_tool_workflow(client): assert "circle with triangle" not in text, "no hand-drawn symbols" -def test_catalog_contact_sheet_renders_every_symbol(tmp_path): +def test_catalog_contact_sheet_renders_every_symbol(tmp_path, monkeypatch): pytest.importorskip("matplotlib") + import engineering.pid.insert as insert_module + from engineering.pid.symbols import all_specs from scripts.render_pid_catalog import render result = render(tmp_path / "sheet.png") - assert result["symbols"] >= 100 and result["bytes"] > 10_000 + assert result["blank"] == [], "every catalogue symbol must leave geometry on the sheet" + assert result["symbols"] == len(all_specs()) >= 100 and result["bytes"] > 10_000 + + # The evidence has to come from the drawing and the picture, not from the + # catalogue's length: a builder that draws nothing yields a labels-only + # sheet whose PNG is well past the byte threshold, so the symbol count and + # the ink share are what tell the two apart. + async def draw_nothing(*_args, **_kwargs): + return {"handle": "0", "ports": {}} + + monkeypatch.setattr(insert_module, "insert_symbol", draw_nothing) + labels_only = render(tmp_path / "labels_only.png") + assert labels_only["symbols"] == 0 and len(labels_only["blank"]) == len(all_specs()) + assert labels_only["bytes"] > 10_000, "bytes alone would not have caught this" + assert result["ink"] > labels_only["ink"] * 1.1, (result["ink"], labels_only["ink"])