Release / test (full, windows-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.12) (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s
Every one of these was reported ok/clean to the caller while being wrong.
Silently wrong numbers
- selection_filter(min_area) read `entity.dxf.get("area", 0.0)` for SOLID,
TRACE and HATCH, none of which carry that attribute, so every hatch
measured 0.0. A 1600 mm^2 hatch was excluded by min_area=1.0 while
`filtered_by` asserted the filter had run, and COM (which reads ActiveX
.Area) returned a different selection for the same call. It now answers
from the measurement `entity_measure` uses, so the two cannot disagree.
- Every headless dimension rendered through ezdxf's bare `Standard` style:
1.0 mm text with a comma decimal marker, against ISO 3098's 2.5 mm minimum
and ISO 129's point -- while the document header itself said $DIMTXT 2.5.
`ezdxf.new(setup=True)` is not the fix; measured, it renders a 100 mm
dimension as "10000". The attributes are now stored explicitly.
- Saving writes `dimrnd = 0.0` onto the Standard style, and ezdxf applies
`xround(value, dimrnd)` whenever the attribute is present at all --
`xround(33.333, 0.0)` is 33. So on any reopened file, including one this
server saved, every dimension was rounded to a whole unit before DIMDEC
formatted it: a 12.75 mm feature dimensioned 13. AutoCAD means DIMRND 0 as
*no* rounding; the sentinel is now discarded at every load site, and a real
0.5 mm rounding is left alone.
- The DIM* header fold gave out-of-range values teeth they did not have when
they died in the header: dim_decimals=-1 returned ok and then killed the
next dimension with `ValueError: Format specifier missing precision` from
inside ezdxf, and 40 printed forty digits of float noise onto the sheet.
Ranges are now AutoCAD's own (DIMDEC 0-8, DIMZIN 0-15, lengths positive).
Security
- security.py recorded the policy as "sanitize_command and sanitize_lisp each
have exactly ONE caller ... the policy is scoped to the CHANNEL". The
premise was false. `_ensure_linetype_loaded` and `ComBackend.linetype_load`
build `_-LINETYPE _LOAD {name} {file}` and hand it to SendCommand, which
treats a newline and ';' as segment separators -- so a `linetype=` argument
carrying either injected further AutoCAD commands, and the 36-verb denylist
never saw that channel. Guarded with the DXF symbol-name rule (the
injection characters fall out of it rather than being enumerated) plus
validate_path for the file argument, which was also the one path-taking
tool reaching the filesystem unchecked. The policy comment is corrected.
Gates that could not see
- drawing_critique returned zero issues on a quarantined document: all seven
checks wrapped their backend call in a bare `except Exception`. CLAUDE.md
rule 7 makes that call the gate an agent is told to trust. Only the
quarantine now escapes; an ordinary broken check still degrades.
`_check_dimension_legibility` also read `_doc` directly from the event loop
thread, missing the gate and performing exactly the unsynchronised access
the quarantine exists to prevent. `construction_clear` answered
"deleted: 0" -- there was nothing to clear -- for the same reason.
- The iso128 focus checked layer lineweights only, so a sheet lettered at
1.0 mm critiqued clean. It now reads the effective dimension height and
separator, header-then-style, in the order the renderer resolves.
- check_doc_numbers.py derived five values it never checked, and those were
the ones that had drifted. The collected-test count (README said 1274, the
tree had 1369) and the A/B pass-rate and Fixed columns are now gated.
- benchmarks/README.md's v1.4.0 table was impossible on its face -- 21
passing plus 5 fixed is 26, not 24 -- and contradicted the report it
linked, because `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that
already carried the dimension fixes and so never measured the v1.5.0 tag.
Replaced with a real run: ab-v1.4.0-vs-v1.5.1.json, 21/26 -> 26/26.
Release gate
- release.yml ran one ubuntu/3.11 lane, so a Windows-only defect could reach
PyPI with no Windows job having executed. That is not hypothetical: v1.5.0
shipped `except pywintypes.com_error` raising NameError wherever pywin32 is
absent. It now runs the same three lanes as ci.yml, Windows included.
1368 passed, 1 xfailed. ruff clean. Tool inventory and all 16 gated document
numbers match the tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
10 KiB
Python
245 lines
10 KiB
Python
"""Headless dimensions must be readable ISO drawings, not ezdxf defaults.
|
|
|
|
`drawing_new` called `ezdxf.new(dxfversion=...)` without `setup=True`, and
|
|
nothing in the repo ever created or configured a dimstyle. So every headless
|
|
dimension rendered through ezdxf's bare `Standard` style: **1.0 mm** text with a
|
|
**comma** decimal marker. ISO 3098 puts the minimum lettering height at 2.5 mm,
|
|
and ISO 129 wants a point.
|
|
|
|
Two things made that worse than a cosmetic default:
|
|
|
|
* The document's own header already said ``$DIMTXT 2.5``. ezdxf renders from the
|
|
dimstyle table entry and never reads the header, so the file asserted one
|
|
height and drew another.
|
|
* A live AutoCAD seat renders from whatever its template carries, so the two
|
|
engines printed a different text height *and* a different decimal marker for
|
|
byte-identical tool calls, with no capability key or test recording it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
#: ISO 3098 minimum lettering height for technical drawings.
|
|
ISO_MIN_TEXT_MM = 2.5
|
|
|
|
|
|
def _rendered(backend, handle):
|
|
"""The text and height a drafter actually sees, out of the dimension block."""
|
|
dim = backend._doc.entitydb.get(handle)
|
|
block = backend._doc.blocks.get(dim.dxf.geometry)
|
|
for entity in block:
|
|
if entity.dxftype() == "MTEXT":
|
|
return entity.text, float(entity.dxf.char_height)
|
|
if entity.dxftype() == "TEXT":
|
|
return entity.dxf.text, float(entity.dxf.height)
|
|
raise AssertionError("the dimension block carries no text")
|
|
|
|
|
|
async def test_a_headless_dimension_is_lettered_at_iso_height(backend):
|
|
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
|
|
|
|
_text, height = _rendered(backend, dim.handle)
|
|
|
|
assert height >= ISO_MIN_TEXT_MM, (
|
|
f"rendered at {height} mm; ISO 3098 minimum is {ISO_MIN_TEXT_MM} mm"
|
|
)
|
|
|
|
|
|
async def test_the_decimal_marker_is_a_point(backend):
|
|
"""ISO 129 wants a point. ezdxf's bare default is a comma."""
|
|
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
|
|
|
|
text, _height = _rendered(backend, dim.handle)
|
|
|
|
assert "," not in text, f"rendered {text!r} with a comma decimal marker"
|
|
assert "33.33" in text
|
|
|
|
|
|
async def test_the_header_and_the_dimstyle_agree_about_text_height(backend):
|
|
"""The file used to assert $DIMTXT 2.5 and draw 1.0."""
|
|
header = float(backend._doc.header.get("$DIMTXT"))
|
|
style = backend._doc.dimstyles.get("Standard")
|
|
|
|
assert float(style.dxf.dimtxt) == pytest.approx(header)
|
|
|
|
|
|
async def test_a_reopened_drawing_keeps_the_style(backend, tmp_path):
|
|
"""The style has to be in the file, not applied in memory on the way out."""
|
|
import ezdxf
|
|
|
|
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
|
|
path = tmp_path / "styled.dxf"
|
|
await backend.drawing_save_as(str(path))
|
|
|
|
reopened = ezdxf.readfile(str(path))
|
|
style = reopened.dimstyles.get("Standard")
|
|
assert float(style.dxf.dimtxt) >= ISO_MIN_TEXT_MM
|
|
assert int(style.dxf.dimdsep) == ord(".")
|
|
assert reopened.entitydb.get(dim.handle) is not None
|
|
|
|
|
|
# ── the gate has to be able to see it ───────────────────────────────────────
|
|
|
|
|
|
async def test_the_iso_critique_catches_sub_iso_dimension_text(backend):
|
|
"""A drawing whose dimensions are 1.0 mm used to critique clean.
|
|
|
|
Fixing the default stops this server *producing* such a drawing, but the
|
|
gate still has to catch one — a template, an opened file, or a caller who
|
|
sets the style back can all reintroduce it, and `drawing_critique`
|
|
returning [] is the claim that the sheet is ISO-conformant.
|
|
|
|
Both the style and the header are pushed down here. Since the DIM* header
|
|
fold, the header outranks the style at render time, so setting the style
|
|
alone no longer produces a 1.0 mm drawing — it would leave this test
|
|
asserting a defect that is not on the sheet.
|
|
"""
|
|
from engineering.critique import run_critique
|
|
|
|
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 1.0
|
|
await backend.system_set_variable("DIMTXT", 1.0)
|
|
await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
|
|
|
|
issues = await run_critique(backend, focus=["iso128"])
|
|
|
|
assert issues, "1.0 mm dimension text is below the ISO 3098 minimum"
|
|
assert any("2.5" in issue.message for issue in issues)
|
|
|
|
|
|
async def test_the_iso_critique_catches_a_comma_decimal_marker(backend):
|
|
from engineering.critique import run_critique
|
|
|
|
backend._doc.dimstyles.get("Standard").dxf.dimdsep = ord(",")
|
|
await backend.system_set_variable("DIMDSEP", ord(","))
|
|
await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
|
|
|
|
issues = await run_critique(backend, focus=["iso128"])
|
|
|
|
assert any("decimal" in issue.message.lower() for issue in issues)
|
|
|
|
|
|
async def test_a_conformant_drawing_still_critiques_clean(backend):
|
|
"""The rule must not fire on the styles this server now ships."""
|
|
from engineering.critique import run_critique
|
|
|
|
await backend.drawing_apply_iso_layers("mech")
|
|
await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
|
|
|
|
assert await run_critique(backend, focus=["iso128"]) == []
|
|
|
|
|
|
async def test_the_critique_grades_what_will_actually_be_drawn(backend):
|
|
"""When the header and the style disagree, the header is what gets drawn.
|
|
|
|
Reading the style alone made `drawing_critique` able to be wrong in both
|
|
directions once DIM* header variables started reaching the renderer: it
|
|
would report a 1.0 mm defect on a sheet lettered at 2.5, and clear a sheet
|
|
lettered at 1.0 because the style still said 2.5. The gate has to resolve
|
|
the same header-then-style order the renderer does.
|
|
"""
|
|
from engineering.critique import run_critique
|
|
|
|
# Style below the minimum, header above it -> the sheet is conformant.
|
|
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 1.0
|
|
await backend.system_set_variable("DIMTXT", 2.5)
|
|
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
|
|
assert _rendered(backend, dim.handle)[1] == pytest.approx(2.5)
|
|
assert await run_critique(backend, focus=["iso128"]) == []
|
|
|
|
# Style above it, header below -> the sheet is not.
|
|
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 2.5
|
|
await backend.system_set_variable("DIMTXT", 1.0)
|
|
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -60, layer="DIM")
|
|
assert _rendered(backend, dim.handle)[1] == pytest.approx(1.0)
|
|
assert await run_critique(backend, focus=["iso128"])
|
|
|
|
|
|
# ── the DIM* header fold must not fight the ISO defaults ────────────────────
|
|
|
|
|
|
async def test_the_iso_defaults_survive_the_header_fold(backend):
|
|
"""The regression that fails if the fold is keyed on the wrong baseline.
|
|
|
|
The fold skips a variable when the header already agrees with what the
|
|
renderer would use. That baseline is ezdxf's *renderer* fallback (dimtxt
|
|
1.0, dimasz 0.25, dimdsep 0 -> comma), not the DXF schema default (2.5,
|
|
2.5, 44). Key it on the schema defaults and a fresh drawing starts folding
|
|
values it should leave alone — most visibly reintroducing the comma this
|
|
file exists to keep out.
|
|
"""
|
|
from engineering.critique import run_critique
|
|
|
|
await backend.drawing_apply_iso_layers("mech")
|
|
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
|
|
|
|
text, height = _rendered(backend, dim.handle)
|
|
assert height == pytest.approx(ISO_MIN_TEXT_MM)
|
|
assert text == "33.33"
|
|
block = backend._doc.blocks.get(backend._doc.entitydb.get(dim.handle).dxf.geometry)
|
|
arrows = {round(float(e.dxf.get("xscale", 1.0)), 3) for e in block if e.dxftype() == "INSERT"}
|
|
assert arrows == {2.5}
|
|
assert await run_critique(backend, focus=["iso128"]) == []
|
|
|
|
|
|
# ── DIMRND 0.0 means "no rounding", not "round to whole units" ──────────────
|
|
|
|
|
|
async def _reopened(backend, tmp_path):
|
|
path = tmp_path / "roundtrip.dxf"
|
|
await backend.drawing_save_as(str(path))
|
|
await backend.drawing_open(str(path))
|
|
return path
|
|
|
|
|
|
def _text(backend, handle) -> str:
|
|
dim = backend._doc.entitydb.get(handle)
|
|
for entity in backend._doc.blocks.get(dim.dxf.geometry):
|
|
if entity.dxftype() == "MTEXT":
|
|
return entity.text
|
|
if entity.dxftype() == "TEXT":
|
|
return entity.dxf.text
|
|
raise AssertionError("the dimension block carries no text")
|
|
|
|
|
|
async def test_a_reopened_drawing_does_not_round_every_dimension_to_a_whole_unit(backend, tmp_path):
|
|
"""A saved file stores `dimrnd = 0.0`, and ezdxf rounds on it.
|
|
|
|
AutoCAD's DIMRND 0 means *no* rounding. ezdxf applies `xround(value,
|
|
dimrnd)` whenever the attribute is present at all, and `xround(33.333, 0.0)`
|
|
is 33 — so every dimension on any reopened file was silently rounded to a
|
|
whole unit *before* DIMDEC ever formatted it. Measured on a file this
|
|
server saved itself: a 12.75 mm feature dimensioned as 13.
|
|
"""
|
|
await _reopened(backend, tmp_path)
|
|
|
|
await backend.drawing_settings({"dim_decimals": 2})
|
|
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
|
|
|
|
assert _text(backend, dim.handle) == "33.33"
|
|
|
|
|
|
async def test_dim_decimals_still_works_after_a_reopen(backend, tmp_path):
|
|
await _reopened(backend, tmp_path)
|
|
|
|
for decimals, expected in ((0, "33"), (1, "33.3"), (3, "33.333")):
|
|
await backend.drawing_settings({"dim_decimals": decimals})
|
|
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30 * (decimals + 2))
|
|
assert _text(backend, dim.handle) == expected
|
|
|
|
|
|
async def test_a_real_rounding_value_is_left_alone(backend, tmp_path):
|
|
"""Only the 0.0 sentinel is discarded — a drafter who asked for 0.5 mm
|
|
rounding must keep it."""
|
|
backend._doc.dimstyles.get("Standard").dxf.dimrnd = 0.5
|
|
await _reopened(backend, tmp_path)
|
|
|
|
assert float(backend._doc.dimstyles.get("Standard").dxf.dimrnd) == 0.5
|
|
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
|
|
# 33.5, not 33.33: the rounding survived. The trailing zero is gone because
|
|
# DIMZIN defaults to 8, which suppresses it — that is formatting, and it is
|
|
# a different knob from the rounding this test is about.
|
|
assert _text(backend, dim.handle) == "33.5"
|