fix(task-19): the contact-sheet test fails when the symbols are missing
render() reported symbols = len(all_specs()) without rendering anything, and the plan's bytes > 10_000 threshold was calibrated for view_screenshot's 16 x 9 in figure, not the 20 x 22 in sheet, where a labels-only render is 85 KB. A builder that draws nothing passed the test. render() now reports what was drawn: symbols counts the specs whose insert left an entity with a real extent in the modelspace (blank names the rest; a spec break is one vertical line, so either axis counts), and ink is the share of PNG pixels that are not the background colour. The test renders a labels-only baseline with insert_symbol monkeypatched to draw nothing and checks that baseline reports zero symbols, is past the byte threshold, and carries less ink than the real sheet (measured 0.0234 vs 0.0273). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
c441609254
commit
42154e025b
@@ -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
|
symbols stays legible on (the tool's 16 x 9 in screenshot is sized for a
|
||||||
single drawing, not a catalogue).
|
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
|
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()
|
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:
|
async def _render(output: Path) -> dict:
|
||||||
from backends.ezdxf_backend import EzdxfBackend
|
from backends.ezdxf_backend import EzdxfBackend
|
||||||
from engineering.layers import apply_layer_set
|
from engineering.layers import apply_layer_set
|
||||||
@@ -57,12 +97,18 @@ async def _render(output: Path) -> dict:
|
|||||||
try:
|
try:
|
||||||
await backend.drawing_new()
|
await backend.drawing_new()
|
||||||
await apply_layer_set(backend, "pid")
|
await apply_layer_set(backend, "pid")
|
||||||
|
doc = backend._require_doc()
|
||||||
specs = all_specs()
|
specs = all_specs()
|
||||||
|
blank: list[str] = []
|
||||||
for index, spec in enumerate(specs):
|
for index, spec in enumerate(specs):
|
||||||
col, row = index % COLUMNS, index // COLUMNS
|
col, row = index % COLUMNS, index // COLUMNS
|
||||||
x, y = col * CELL, -row * CELL
|
x, y = col * CELL, -row * CELL
|
||||||
layer = "PROCESS-PIPING-MAIN" if spec.family == "marker" else None
|
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(
|
await backend.entity_create_text(
|
||||||
spec.name.replace("PID_", ""),
|
spec.name.replace("PID_", ""),
|
||||||
x - CELL / 2 + 2,
|
x - CELL / 2 + 2,
|
||||||
@@ -73,7 +119,13 @@ async def _render(output: Path) -> dict:
|
|||||||
png = await asyncio.to_thread(_render_png, backend, FIGSIZE, DPI)
|
png = await asyncio.to_thread(_render_png, backend, FIGSIZE, DPI)
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output.write_bytes(png)
|
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:
|
finally:
|
||||||
await backend.disconnect()
|
await backend.disconnect()
|
||||||
|
|
||||||
|
|||||||
@@ -65,9 +65,25 @@ async def test_prompt_walks_the_tool_workflow(client):
|
|||||||
assert "circle with triangle" not in text, "no hand-drawn symbols"
|
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")
|
pytest.importorskip("matplotlib")
|
||||||
|
import engineering.pid.insert as insert_module
|
||||||
|
from engineering.pid.symbols import all_specs
|
||||||
from scripts.render_pid_catalog import render
|
from scripts.render_pid_catalog import render
|
||||||
|
|
||||||
result = render(tmp_path / "sheet.png")
|
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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user