fix(task-14): a border INSERT is not a port, and a turned symbol's box is its geometry
Two verified blocking findings on pid_graph. A foreign block's inferred ports were any line end within tolerance of its drawn box *or strictly inside it*. A sheet border or title block is an INSERT too, and its box encloses the whole drawing, so every end that missed a port attached to the border instead of becoming the junction or dangling end the tool exists to report: on a border sheet the plan's own small P&ID read back with no junction, no dangling end and its open signal line "connected" to A3_BORDER. The test is the boundary again, as spec 9.2 step 3 says (within tolerance of the block's bounding-box boundary); overshoot into a body is the tolerance's business. The nearest-insertion tie-break stays for two boundaries that share an end. The COM engine's `geometry_bbox` carried each definition member's own axis-aligned box through the INSERT corner by corner, which is exact at a right-angle rotation and too large everywhere else: a 45-degree-turned circle of radius 5 read as 7.07, a diagonal line stood upright read 7.07 wide, and the two engines disagreed on the same drawing with no marker. Off a right angle the member's real geometry is now measured - a LINE, POINT and straight polyline by their vertices, a CIRCLE by the closed-form extents of the scaled-and-turned ellipse, an ARC and a bulged polyline by their ends and the quadrant points the sweep passes (uniform scale, reflection reversing the sense) - and cross-checked against ezdxf's `virtual_entities` extents at 37 and 45 degrees, scaled and mirrored. A member that cannot be measured (text, hatch, a nested reference, an elliptical arc) is still carried by its corners and the box then says `approximate: true`; the graph takes the tighter wall of that box and the attribute-inclusive `bounding_box`, both of which hold the drawn geometry. 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
d17a26103f
commit
ea47cbdd7e
+129
-10
@@ -348,16 +348,113 @@ def _com_bulges(entity, coords, closed: bool, length) -> list[float]:
|
|||||||
return [_com_bulge(entity, i) for i in range(count)]
|
return [_com_bulge(entity, i) for i in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_right_angle(rotation: float) -> bool:
|
||||||
|
"""True when ``rotation`` (radians) is a multiple of a quarter turn."""
|
||||||
|
quarter = rotation / (math.pi / 2.0)
|
||||||
|
return abs(quarter - round(quarter)) <= 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def _arc_extent_points(cx, cy, radius, start, end):
|
||||||
|
"""The points that bound a counter-clockwise circular arc: its two ends
|
||||||
|
and every quadrant point (0, 90, 180, 270 degrees) the sweep passes."""
|
||||||
|
sweep = (end - start) % (2.0 * math.pi)
|
||||||
|
pts = [
|
||||||
|
(cx + radius * math.cos(start), cy + radius * math.sin(start)),
|
||||||
|
(cx + radius * math.cos(end), cy + radius * math.sin(end)),
|
||||||
|
]
|
||||||
|
for q in range(4):
|
||||||
|
angle = q * math.pi / 2.0
|
||||||
|
if (angle - start) % (2.0 * math.pi) <= sweep:
|
||||||
|
pts.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle)))
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
def _com_member_extent_points(member, xform, sx, sy, rotation):
|
||||||
|
"""WCS points whose extents are exactly the transformed member's extents,
|
||||||
|
for the members a P&ID symbol is drawn from; None when the member's
|
||||||
|
geometry is not one this reads (text, hatch, spline, ellipse, a nested
|
||||||
|
reference) so the caller falls back to its box and says so.
|
||||||
|
|
||||||
|
A LINE, a POINT and a straight polyline are bounded by their vertices
|
||||||
|
under any affine map. A CIRCLE becomes an ellipse under an axis scale, and
|
||||||
|
its extents after a rotation are closed-form. An ARC and a bulged
|
||||||
|
polyline segment stay circular only under a uniform scale; a reflection
|
||||||
|
(``sx * sy < 0``) reverses their sense.
|
||||||
|
"""
|
||||||
|
name = member.ObjectName
|
||||||
|
uniform = abs(abs(sx) - abs(sy)) <= 1e-12
|
||||||
|
mirrored = sx * sy < 0
|
||||||
|
if name == "AcDbLine":
|
||||||
|
s, e = member.StartPoint, member.EndPoint
|
||||||
|
return [xform(s[0], s[1]), xform(e[0], e[1])]
|
||||||
|
if name == "AcDbPoint":
|
||||||
|
c = member.Coordinates
|
||||||
|
return [xform(c[0], c[1])]
|
||||||
|
if name == "AcDbCircle":
|
||||||
|
c = member.Center
|
||||||
|
r = float(member.Radius)
|
||||||
|
cx, cy = xform(c[0], c[1])
|
||||||
|
a, b = r * abs(sx), r * abs(sy)
|
||||||
|
cos_r, sin_r = math.cos(rotation), math.sin(rotation)
|
||||||
|
hx = math.hypot(a * cos_r, b * sin_r)
|
||||||
|
hy = math.hypot(a * sin_r, b * cos_r)
|
||||||
|
return [(cx - hx, cy - hy), (cx + hx, cy + hy)]
|
||||||
|
if name == "AcDbArc":
|
||||||
|
if not uniform:
|
||||||
|
return None
|
||||||
|
c = member.Center
|
||||||
|
r = float(member.Radius)
|
||||||
|
cx, cy = xform(c[0], c[1])
|
||||||
|
ends = []
|
||||||
|
for angle in (float(member.StartAngle), float(member.EndAngle)):
|
||||||
|
px, py = xform(c[0] + r * math.cos(angle), c[1] + r * math.sin(angle))
|
||||||
|
ends.append(math.atan2(py - cy, px - cx))
|
||||||
|
if mirrored:
|
||||||
|
ends.reverse()
|
||||||
|
return _arc_extent_points(cx, cy, r * abs(sx), ends[0], ends[1])
|
||||||
|
if name in _COM_LWPOLYLINE_NAMES:
|
||||||
|
coords = list(member.Coordinates)
|
||||||
|
closed = bool(member.Closed)
|
||||||
|
bulges = _com_bulges(member, coords, closed, member.Length)
|
||||||
|
pts = [xform(coords[i], coords[i + 1]) for i in range(0, len(coords) - 1, 2)]
|
||||||
|
if not any(bulges):
|
||||||
|
return pts
|
||||||
|
if not uniform:
|
||||||
|
return None
|
||||||
|
from ezdxf.math import Vec2, bulge_to_arc
|
||||||
|
|
||||||
|
segments = list(zip(pts, pts[1:], bulges, strict=False))
|
||||||
|
if closed and len(pts) > 1:
|
||||||
|
segments.append((pts[-1], pts[0], bulges[len(pts) - 1]))
|
||||||
|
out = list(pts)
|
||||||
|
for a, b, bulge in segments:
|
||||||
|
if not bulge or math.dist(a, b) < 1e-12:
|
||||||
|
continue
|
||||||
|
centre, start, end, radius = bulge_to_arc(
|
||||||
|
Vec2(a), Vec2(b), -bulge if mirrored else bulge
|
||||||
|
)
|
||||||
|
out.extend(_arc_extent_points(centre.x, centre.y, radius, start, end))
|
||||||
|
return out
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _com_geometry_bbox(entity) -> dict | None:
|
def _com_geometry_bbox(entity) -> dict | None:
|
||||||
"""WCS extents of a block reference's *drawn* geometry, attributes excluded.
|
"""WCS extents of a block reference's *drawn* geometry, attributes excluded.
|
||||||
|
|
||||||
``GetBoundingBox`` on an INSERT takes its ATTRIBs with it, so a TAG lettered
|
``GetBoundingBox`` on an INSERT takes its ATTRIBs with it, so a TAG lettered
|
||||||
above a valve pushes the box past the body — a line ending on the body's
|
above a valve pushes the box past the body — a line ending on the body's
|
||||||
real edge then lies *inside* the box. The definition's members are read in
|
real edge then lies *inside* the box. The definition's members are read in
|
||||||
block space (skipping ``AcDbAttributeDefinition``), each member's box is
|
block space (skipping ``AcDbAttributeDefinition``) and carried through the
|
||||||
carried through the INSERT (origin, scale, rotation) corner by corner, and
|
INSERT (origin, scale, rotation).
|
||||||
the extents of those corners is the answer. None when there is nothing
|
|
||||||
drawn or ActiveX refuses any step — the caller then omits the key.
|
At a right-angle rotation (the P&ID case) each member's own box maps to
|
||||||
|
an axis-aligned box, so carrying its corners is exact. Off a right angle
|
||||||
|
the box of a rotated box is too large — a 45-degree-turned circle of
|
||||||
|
radius 5 would read as 7.07 — so each member's real geometry is measured
|
||||||
|
(`_com_member_extent_points`); a member that cannot be, or refuses, is
|
||||||
|
carried by its corners and the result is marked ``approximate: True`` so
|
||||||
|
no reader mistakes it for the extents the ezdxf engine reports. None when
|
||||||
|
there is nothing drawn or ActiveX refuses — the caller then omits the key.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
@@ -373,24 +470,46 @@ def _com_geometry_bbox(entity) -> dict | None:
|
|||||||
sx, sy = float(entity.XScaleFactor), float(entity.YScaleFactor)
|
sx, sy = float(entity.XScaleFactor), float(entity.YScaleFactor)
|
||||||
rot = float(entity.Rotation)
|
rot = float(entity.Rotation)
|
||||||
cos_r, sin_r = math.cos(rot), math.sin(rot)
|
cos_r, sin_r = math.cos(rot), math.sin(rot)
|
||||||
|
right_angle = _is_right_angle(rot)
|
||||||
|
|
||||||
|
def xform(bx, by):
|
||||||
|
lx = (float(bx) - float(origin[0])) * sx
|
||||||
|
ly = (float(by) - float(origin[1])) * sy
|
||||||
|
return (
|
||||||
|
float(ins[0]) + lx * cos_r - ly * sin_r,
|
||||||
|
float(ins[1]) + lx * sin_r + ly * cos_r,
|
||||||
|
)
|
||||||
|
|
||||||
xs: list[float] = []
|
xs: list[float] = []
|
||||||
ys: list[float] = []
|
ys: list[float] = []
|
||||||
|
approximate = False
|
||||||
for i in range(blk.Count):
|
for i in range(blk.Count):
|
||||||
member = blk.Item(i)
|
member = blk.Item(i)
|
||||||
if member.ObjectName == "AcDbAttributeDefinition":
|
if member.ObjectName == "AcDbAttributeDefinition":
|
||||||
continue
|
continue
|
||||||
|
pts = None
|
||||||
|
if not right_angle:
|
||||||
|
try:
|
||||||
|
pts = _com_member_extent_points(member, xform, sx, sy, rot)
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("exact extents of block member failed: %s", exc)
|
||||||
|
pts = None
|
||||||
|
if pts is None:
|
||||||
try:
|
try:
|
||||||
lo, hi = member.GetBoundingBox()
|
lo, hi = member.GetBoundingBox()
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
for bx, by in ((lo[0], lo[1]), (hi[0], lo[1]), (lo[0], hi[1]), (hi[0], hi[1])):
|
corners = ((lo[0], lo[1]), (hi[0], lo[1]), (lo[0], hi[1]), (hi[0], hi[1]))
|
||||||
lx = (float(bx) - float(origin[0])) * sx
|
pts = [xform(bx, by) for bx, by in corners]
|
||||||
ly = (float(by) - float(origin[1])) * sy
|
approximate = approximate or not right_angle
|
||||||
xs.append(float(ins[0]) + lx * cos_r - ly * sin_r)
|
xs.extend(x for x, _ in pts)
|
||||||
ys.append(float(ins[1]) + lx * sin_r + ly * cos_r)
|
ys.extend(y for _, y in pts)
|
||||||
if not xs:
|
if not xs:
|
||||||
return None
|
return None
|
||||||
return {"min": [min(xs), min(ys)], "max": [max(xs), max(ys)]}
|
box = {"min": [min(xs), min(ys)], "max": [max(xs), max(ys)]}
|
||||||
|
if approximate:
|
||||||
|
box["approximate"] = True
|
||||||
|
return box
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.debug("geometry bbox failed for block reference: %s", exc)
|
log.debug("geometry bbox failed for block reference: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|||||||
+43
-15
@@ -263,23 +263,51 @@ def _bbox(info: EntityInfo):
|
|||||||
edge on that side lies *inside* the box. Both engines report
|
edge on that side lies *inside* the box. Both engines report
|
||||||
``geometry_bbox`` (attributes excluded) for an INSERT that draws anything;
|
``geometry_bbox`` (attributes excluded) for an INSERT that draws anything;
|
||||||
the attribute-inclusive box is the fallback for a reader that lacks it.
|
the attribute-inclusive box is the fallback for a reader that lacks it.
|
||||||
|
|
||||||
|
An engine may mark ``geometry_bbox`` ``approximate`` (the COM engine does
|
||||||
|
for an INSERT rotated off a right angle whose members it cannot measure
|
||||||
|
exactly): the box then encloses the drawn geometry but may overshoot it,
|
||||||
|
and a line ending on the body's true edge would sit *inside* that box
|
||||||
|
and dangle. The drawn geometry lies inside the attribute-inclusive box
|
||||||
|
too, so the tighter wall of the two is taken on every side.
|
||||||
"""
|
"""
|
||||||
bb = info.properties.get("geometry_bbox") or info.properties.get("bounding_box")
|
geometry = info.properties.get("geometry_bbox")
|
||||||
if not bb:
|
outer = info.properties.get("bounding_box")
|
||||||
|
boxes = [
|
||||||
|
(float(bb["min"][0]), float(bb["min"][1]), float(bb["max"][0]), float(bb["max"][1]))
|
||||||
|
for bb in (geometry, outer)
|
||||||
|
if bb
|
||||||
|
]
|
||||||
|
if not boxes:
|
||||||
return None
|
return None
|
||||||
return (float(bb["min"][0]), float(bb["min"][1]), float(bb["max"][0]), float(bb["max"][1]))
|
if geometry and outer and geometry.get("approximate"):
|
||||||
|
return (
|
||||||
|
max(boxes[0][0], boxes[1][0]),
|
||||||
|
max(boxes[0][1], boxes[1][1]),
|
||||||
|
min(boxes[0][2], boxes[1][2]),
|
||||||
|
min(boxes[0][3], boxes[1][3]),
|
||||||
|
)
|
||||||
|
return boxes[0]
|
||||||
|
|
||||||
|
|
||||||
def _touches_bbox(p, bbox, tolerance) -> bool:
|
def _on_bbox_boundary(p, bbox, tolerance) -> bool:
|
||||||
"""Within ``tolerance`` of the box's boundary, or strictly inside it.
|
"""Within ``tolerance`` of the box's boundary (spec §9.2 step 3).
|
||||||
|
|
||||||
A line drawn into the symbol is a connection just as one that stops on
|
The boundary, not the interior: a foreign block's box is only a proxy for
|
||||||
its edge is: foreign P&IDs overshoot into the body as often as they meet
|
where its ports are, and its edges are the only place a port can be. An
|
||||||
it, and a box tested on its boundary alone called those ends dangling.
|
end strictly inside was once accepted as "drawn into the body" — but a
|
||||||
|
sheet border or title block is an INSERT too, its box encloses the whole
|
||||||
|
drawing, and every end that missed a port then attached to the border
|
||||||
|
instead of becoming the junction or dangling end the tool exists to
|
||||||
|
report. Overshoot into a body is the tolerance's business.
|
||||||
"""
|
"""
|
||||||
x, y = p
|
x, y = p
|
||||||
xmin, ymin, xmax, ymax = bbox
|
xmin, ymin, xmax, ymax = bbox
|
||||||
return xmin - tolerance <= x <= xmax + tolerance and ymin - tolerance <= y <= ymax + tolerance
|
inside_x = xmin - tolerance <= x <= xmax + tolerance
|
||||||
|
inside_y = ymin - tolerance <= y <= ymax + tolerance
|
||||||
|
near_vertical = min(abs(x - xmin), abs(x - xmax)) <= tolerance and inside_y
|
||||||
|
near_horizontal = min(abs(y - ymin), abs(y - ymax)) <= tolerance and inside_x
|
||||||
|
return near_vertical or near_horizontal
|
||||||
|
|
||||||
|
|
||||||
async def _collect(backend: AutoCADBackend, scope: str) -> list[tuple[EntityInfo, str]]:
|
async def _collect(backend: AutoCADBackend, scope: str) -> list[tuple[EntityInfo, str]]:
|
||||||
@@ -461,9 +489,9 @@ async def build_graph(
|
|||||||
return edge.vertices[0] if end == "from" else edge.vertices[-1]
|
return edge.vertices[0] if end == "from" else edge.vertices[-1]
|
||||||
|
|
||||||
def _attach(edge: _Edge, end: str) -> dict | None:
|
def _attach(edge: _Edge, end: str) -> dict | None:
|
||||||
"""``{node, port}`` when this end sits on a port, or on or inside a
|
"""``{node, port}`` when this end sits on a port, or on the boundary of
|
||||||
foreign block's drawn box; None otherwise. Only a successful match has
|
a foreign block's drawn box; None otherwise. Only a successful match
|
||||||
side effects."""
|
has side effects."""
|
||||||
x, y = _end(edge, end)
|
x, y = _end(edge, end)
|
||||||
for node_id, pname, cx, cy, r in radial_ports:
|
for node_id, pname, cx, cy, r in radial_ports:
|
||||||
if abs(math.hypot(x - cx, y - cy) - r) <= tolerance:
|
if abs(math.hypot(x - cx, y - cy) - r) <= tolerance:
|
||||||
@@ -477,15 +505,15 @@ async def build_graph(
|
|||||||
_, (_kind, node_id, pname) = best
|
_, (_kind, node_id, pname) = best
|
||||||
nodes[node_id].ports[pname]["edges"].append(edge.id)
|
nodes[node_id].ports[pname]["edges"].append(edge.id)
|
||||||
return {"node": node_id, "port": pname}
|
return {"node": node_id, "port": pname}
|
||||||
# Several foreign boxes can hold the same end (a valve drawn inside a
|
# Two foreign boundaries can share the end (a valve drawn flush with
|
||||||
# vessel's outline, a nested detail); the one inserted nearest wins.
|
# a vessel's outline, a nested detail); the one inserted nearest wins.
|
||||||
node = min(
|
node = min(
|
||||||
(
|
(
|
||||||
n
|
n
|
||||||
for n in list(nodes.values()) + list(candidates.values())
|
for n in list(nodes.values()) + list(candidates.values())
|
||||||
if n.source in ("heuristic", "inferred")
|
if n.source in ("heuristic", "inferred")
|
||||||
and n.bbox
|
and n.bbox
|
||||||
and _touches_bbox((x, y), n.bbox, tolerance)
|
and _on_bbox_boundary((x, y), n.bbox, tolerance)
|
||||||
),
|
),
|
||||||
key=lambda n: math.hypot(x - n.x, y - n.y),
|
key=lambda n: math.hypot(x - n.x, y - n.y),
|
||||||
default=None,
|
default=None,
|
||||||
|
|||||||
@@ -604,3 +604,171 @@ def test_com_geometry_bbox_is_omitted_when_the_block_draws_nothing_or_activex_re
|
|||||||
assert _com_geometry_bbox(ent) is None
|
assert _com_geometry_bbox(ent) is None
|
||||||
props = _entity_info(ent).properties
|
props = _entity_info(ent).properties
|
||||||
assert "geometry_bbox" not in props and props["rotation_deg"] == 0.0
|
assert "geometry_bbox" not in props and props["rotation_deg"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── geometry_bbox off a right angle: the geometry, not the box of a box ──────
|
||||||
|
#
|
||||||
|
# Rotating a member's axis-aligned box and taking the extents of the corners
|
||||||
|
# is exact only at multiples of 90 degrees. A circle of radius 5 turned 45
|
||||||
|
# degrees is still x in [-5, 5]; its box's corners reach 7.07. The ezdxf
|
||||||
|
# engine measures the real geometry, so the live engine must too — or say
|
||||||
|
# that it could not.
|
||||||
|
|
||||||
|
|
||||||
|
def _circle(cx, cy, r):
|
||||||
|
return _Entity(
|
||||||
|
"AcDbCircle",
|
||||||
|
Center=(cx, cy, 0.0),
|
||||||
|
Radius=r,
|
||||||
|
GetBoundingBox=lambda: ((cx - r, cy - r, 0.0), (cx + r, cy + r, 0.0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _line(x0, y0, x1, y1):
|
||||||
|
return _Entity(
|
||||||
|
"AcDbLine",
|
||||||
|
StartPoint=(x0, y0, 0.0),
|
||||||
|
EndPoint=(x1, y1, 0.0),
|
||||||
|
GetBoundingBox=lambda: ((min(x0, x1), min(y0, y1), 0.0), (max(x0, x1), max(y0, y1), 0.0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _arc(cx, cy, r, start_deg, end_deg):
|
||||||
|
import math
|
||||||
|
|
||||||
|
return _Entity(
|
||||||
|
"AcDbArc",
|
||||||
|
Center=(cx, cy, 0.0),
|
||||||
|
Radius=r,
|
||||||
|
StartAngle=math.radians(start_deg),
|
||||||
|
EndAngle=math.radians(end_deg),
|
||||||
|
GetBoundingBox=lambda: ((cx - r, cy - r, 0.0), (cx + r, cy + r, 0.0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _polyline(points, bulges, closed=False):
|
||||||
|
import math
|
||||||
|
|
||||||
|
coords = [c for p in points for c in p]
|
||||||
|
chord = sum(math.dist(a, b) for a, b in zip(points, points[1:], strict=False))
|
||||||
|
if closed:
|
||||||
|
chord += math.dist(points[-1], points[0])
|
||||||
|
length = chord + (1.0 if any(bulges) else 0.0) # "longer than its chord" is all that matters
|
||||||
|
xs = [p[0] for p in points]
|
||||||
|
ys = [p[1] for p in points]
|
||||||
|
return _Entity(
|
||||||
|
"AcDbPolyline",
|
||||||
|
Coordinates=coords,
|
||||||
|
Closed=closed,
|
||||||
|
Length=length,
|
||||||
|
GetBulge=lambda i: bulges[i],
|
||||||
|
GetBoundingBox=lambda: ((min(xs), min(ys), 0.0), (max(xs), max(ys), 0.0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_com_geometry_bbox_of_a_rotated_circle_and_diagonal_line_is_exact():
|
||||||
|
import math
|
||||||
|
|
||||||
|
from backends.com_backend import _com_geometry_bbox
|
||||||
|
|
||||||
|
ent = _blockref(
|
||||||
|
"PUMP_C",
|
||||||
|
{"PUMP_C": _Block([_circle(0.0, 0.0, 5.0)])},
|
||||||
|
insertion=(100.0, 100.0, 0.0),
|
||||||
|
rotation=math.radians(45.0),
|
||||||
|
)
|
||||||
|
box = _com_geometry_bbox(ent)
|
||||||
|
assert box["min"] == pytest.approx([95.0, 95.0]) and box["max"] == pytest.approx([105.0, 105.0])
|
||||||
|
assert "approximate" not in box
|
||||||
|
# a diagonal line turned 45 degrees stands upright: x is exactly 0
|
||||||
|
ent = _blockref(
|
||||||
|
"DIAG", {"DIAG": _Block([_line(0.0, 0.0, 10.0, 10.0)])}, rotation=math.radians(45.0)
|
||||||
|
)
|
||||||
|
box = _com_geometry_bbox(ent)
|
||||||
|
assert box["min"] == pytest.approx([0.0, 0.0], abs=1e-9)
|
||||||
|
assert box["max"] == pytest.approx([0.0, math.sqrt(200.0)], abs=1e-9)
|
||||||
|
assert "approximate" not in box
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("rotation_deg", "sx", "sy"),
|
||||||
|
[(37.0, 1.0, 1.0), (45.0, 1.5, 1.5), (-120.0, 1.0, -1.0), (37.0, 2.0, -2.0)],
|
||||||
|
)
|
||||||
|
def test_com_geometry_bbox_off_a_right_angle_matches_the_ezdxf_engine(rotation_deg, sx, sy):
|
||||||
|
"""Same block, same INSERT, both engines: a circle, an arc, a bulged
|
||||||
|
polyline and a diagonal line, turned and scaled and mirrored."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
import ezdxf
|
||||||
|
from ezdxf import bbox as _bbox
|
||||||
|
|
||||||
|
from backends.com_backend import _com_geometry_bbox
|
||||||
|
|
||||||
|
doc = ezdxf.new()
|
||||||
|
blk = doc.blocks.new("MIXED")
|
||||||
|
blk.add_circle((3.0, 0.0), 5.0)
|
||||||
|
blk.add_arc((0.0, 8.0), 4.0, 200.0, 330.0)
|
||||||
|
blk.add_lwpolyline([(-6, -6, 0.0), (0, -6, 0.8), (0, -12, 0.0)], format="xyb")
|
||||||
|
blk.add_line((-2.0, 1.0), (7.0, 9.0))
|
||||||
|
blk.add_attdef("TAG", insert=(0, 15), text="", dxfattribs={"height": 2.5})
|
||||||
|
ref = doc.modelspace().add_blockref(
|
||||||
|
"MIXED",
|
||||||
|
(50.0, 20.0),
|
||||||
|
dxfattribs={"rotation": rotation_deg, "xscale": sx, "yscale": sy},
|
||||||
|
)
|
||||||
|
expected = _bbox.extents(
|
||||||
|
e for e in ref.virtual_entities() if e.dxftype() not in ("ATTRIB", "ATTDEF")
|
||||||
|
)
|
||||||
|
|
||||||
|
members = [
|
||||||
|
_circle(3.0, 0.0, 5.0),
|
||||||
|
_arc(0.0, 8.0, 4.0, 200.0, 330.0),
|
||||||
|
_polyline([(-6.0, -6.0), (0.0, -6.0), (0.0, -12.0)], [0.0, 0.8, 0.0]),
|
||||||
|
_line(-2.0, 1.0, 7.0, 9.0),
|
||||||
|
_Entity(
|
||||||
|
"AcDbAttributeDefinition", GetBoundingBox=lambda: ((0.0, 15.0, 0.0), (8.0, 17.5, 0.0))
|
||||||
|
),
|
||||||
|
]
|
||||||
|
ent = _blockref(
|
||||||
|
"MIXED",
|
||||||
|
{"MIXED": _Block(members)},
|
||||||
|
insertion=(50.0, 20.0, 0.0),
|
||||||
|
sx=sx,
|
||||||
|
sy=sy,
|
||||||
|
rotation=math.radians(rotation_deg),
|
||||||
|
)
|
||||||
|
box = _com_geometry_bbox(ent)
|
||||||
|
assert "approximate" not in box
|
||||||
|
# ezdxf flattens the curves (default 0.01), so agreement is to that
|
||||||
|
assert box["min"] == pytest.approx([expected.extmin.x, expected.extmin.y], abs=0.02)
|
||||||
|
assert box["max"] == pytest.approx([expected.extmax.x, expected.extmax.y], abs=0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def test_com_geometry_bbox_is_marked_approximate_when_a_rotated_member_cannot_be_measured():
|
||||||
|
import math
|
||||||
|
|
||||||
|
from backends.com_backend import _com_geometry_bbox, _entity_info
|
||||||
|
|
||||||
|
hatch = _Entity("AcDbHatch", GetBoundingBox=lambda: ((-5.0, -5.0, 0.0), (5.0, 5.0, 0.0)))
|
||||||
|
blocks = {"FILLED": _Block([_circle(0.0, 0.0, 5.0), hatch])}
|
||||||
|
# at a right angle the corners are exact — no marker, no geometry reads
|
||||||
|
ent = _blockref("FILLED", blocks, insertion=(100.0, 100.0, 0.0), rotation=math.pi)
|
||||||
|
box = _com_geometry_bbox(ent)
|
||||||
|
assert box == {"min": [95.0, 95.0], "max": [105.0, 105.0]}
|
||||||
|
# off one, the hatch is carried by its corners and the box says so
|
||||||
|
ent = _blockref("FILLED", blocks, insertion=(100.0, 100.0, 0.0), rotation=math.radians(45.0))
|
||||||
|
props = _entity_info(ent).properties
|
||||||
|
box = props["geometry_bbox"]
|
||||||
|
assert box["approximate"] is True
|
||||||
|
half = 5.0 * math.sqrt(2.0)
|
||||||
|
assert box["min"] == pytest.approx([100.0 - half, 100.0 - half])
|
||||||
|
assert box["max"] == pytest.approx([100.0 + half, 100.0 + half])
|
||||||
|
# a non-uniformly scaled arc is an elliptical arc: not measured, said so
|
||||||
|
ent = _blockref(
|
||||||
|
"ARC",
|
||||||
|
{"ARC": _Block([_arc(0.0, 0.0, 4.0, 0.0, 90.0)])},
|
||||||
|
sx=2.0,
|
||||||
|
sy=1.0,
|
||||||
|
rotation=math.radians(30.0),
|
||||||
|
)
|
||||||
|
assert _com_geometry_bbox(ent)["approximate"] is True
|
||||||
|
|||||||
+113
-6
@@ -185,22 +185,94 @@ async def test_a_foreign_vessel_takes_lines_on_every_edge_regardless_of_where_it
|
|||||||
assert {(d["edge"], d["end"]) for d in graph["dangling"]} == {(top, "from"), (side, "from")}
|
assert {(d["edge"], d["end"]) for d in graph["dangling"]} == {(top, "from"), (side, "from")}
|
||||||
|
|
||||||
|
|
||||||
async def test_a_line_drawn_into_a_foreign_body_attaches_to_the_nearest_block(backend):
|
async def test_a_line_drawn_into_a_foreign_body_is_dangling_unless_tolerance_reaches_the_edge(
|
||||||
"""An end strictly inside the drawn symbol is a connection, and when two
|
backend,
|
||||||
foreign boxes hold it the one inserted nearest wins."""
|
):
|
||||||
|
"""Spec §9.2 step 3: a foreign port is an end within ``tolerance`` of the
|
||||||
|
block's bounding-box *boundary*. The interior is not a port — a border
|
||||||
|
INSERT's interior is the whole sheet."""
|
||||||
|
small = backend._doc.blocks.new(name="GATE_VALVE")
|
||||||
|
small.add_lwpolyline([(-4, -2), (4, -2), (4, 2), (-4, 2)], close=True)
|
||||||
|
valve = (await backend.block_insert("GATE_VALVE", 20, 0, attributes={"TAG": "HV-2"})).handle
|
||||||
|
# body x in [16, 24]; the line overshoots 2 mm into it
|
||||||
|
line = (await backend.entity_create_line(60, 0, 22, 0, layer="PROCESS-PIPING-MAIN")).handle
|
||||||
|
graph = await build_graph(backend, tolerance=0.5)
|
||||||
|
edge = next(e for e in graph["edges"] if e["id"] == line)
|
||||||
|
assert edge["to"] is None
|
||||||
|
assert {(d["edge"], d["end"]) for d in graph["dangling"]} == {(line, "from"), (line, "to")}
|
||||||
|
assert next(n for n in graph["nodes"] if n["id"] == valve)["ports"] == {}
|
||||||
|
graph = await build_graph(backend, tolerance=2.0)
|
||||||
|
edge = next(e for e in graph["edges"] if e["id"] == line)
|
||||||
|
assert edge["to"] == {"node": valve, "port": "p1"}
|
||||||
|
assert {(d["edge"], d["end"]) for d in graph["dangling"]} == {(line, "from")}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_foreign_boundaries_sharing_an_end_go_to_the_nearest_insertion(backend):
|
||||||
big = backend._doc.blocks.new(name="VESSEL_BIG")
|
big = backend._doc.blocks.new(name="VESSEL_BIG")
|
||||||
big.add_lwpolyline([(-30, -30), (30, -30), (30, 30), (-30, 30)], close=True)
|
big.add_lwpolyline([(-30, -30), (30, -30), (30, 30), (-30, 30)], close=True)
|
||||||
small = backend._doc.blocks.new(name="GATE_VALVE")
|
small = backend._doc.blocks.new(name="GATE_VALVE")
|
||||||
small.add_lwpolyline([(-4, -2), (4, -2), (4, 2), (-4, 2)], close=True)
|
small.add_lwpolyline([(-4, -2), (4, -2), (4, 2), (-4, 2)], close=True)
|
||||||
vessel = (await backend.block_insert("VESSEL_BIG", 0, 0, attributes={"TAG": "V-2"})).handle
|
vessel = (await backend.block_insert("VESSEL_BIG", 0, 0, attributes={"TAG": "V-2"})).handle
|
||||||
valve = (await backend.block_insert("GATE_VALVE", 20, 0, attributes={"TAG": "HV-2"})).handle
|
# the valve sits flush with the vessel's right wall: both boundaries hold (30, 0)
|
||||||
line = (await backend.entity_create_line(60, 0, 22, 0, layer="PROCESS-PIPING-MAIN")).handle
|
valve = (await backend.block_insert("GATE_VALVE", 26, 0, attributes={"TAG": "HV-2"})).handle
|
||||||
|
line = (await backend.entity_create_line(60, 0, 30, 0, layer="PROCESS-PIPING-MAIN")).handle
|
||||||
graph = await build_graph(backend)
|
graph = await build_graph(backend)
|
||||||
edge = next(e for e in graph["edges"] if e["id"] == line)
|
edge = next(e for e in graph["edges"] if e["id"] == line)
|
||||||
assert edge["to"]["node"] == valve
|
assert edge["to"] == {"node": valve, "port": "p1"}
|
||||||
assert next(n for n in graph["nodes"] if n["id"] == vessel)["ports"] == {}
|
assert next(n for n in graph["nodes"] if n["id"] == vessel)["ports"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_sheet_border_insert_never_swallows_junctions_or_dangling_ends(backend):
|
||||||
|
"""The border is an INSERT whose box encloses the whole sheet. Accepting
|
||||||
|
ends strictly inside a foreign box made every end that missed a port a
|
||||||
|
port of the border: no junction, no dangling end, the signal line
|
||||||
|
'connected' to A3_BORDER."""
|
||||||
|
border = backend._doc.blocks.new(name="A3_BORDER")
|
||||||
|
border.add_lwpolyline([(0, 0), (420, 0), (420, 297), (0, 297)], close=True)
|
||||||
|
sheet = (await backend.block_insert("A3_BORDER", 0, 0)).handle
|
||||||
|
ids = await _small_pid(backend)
|
||||||
|
branch = await draw_line(
|
||||||
|
backend, {"x": 130, "y": 111}, {"x": 130, "y": 60}, line_class="process_minor"
|
||||||
|
)
|
||||||
|
graph = await build_graph(backend)
|
||||||
|
assert graph["unclassified"] == [{"handle": sheet, "block_name": "A3_BORDER"}]
|
||||||
|
assert sheet not in {n["id"] for n in graph["nodes"]}
|
||||||
|
assert [(j["id"], j["x"], j["y"]) for j in graph["junctions"]] == [("J1", 130.0, 111.0)]
|
||||||
|
assert set(graph["junctions"][0]["edges"]) == {branch["handle"], ids["l1"]["handle"]}
|
||||||
|
dangling = {(d["edge"], d["end"]) for d in graph["dangling"]}
|
||||||
|
assert dangling == {(ids["sig"]["handle"], "to"), (branch["handle"], "to")}
|
||||||
|
assert graph["stats"]["dangling"] == 2 and graph["stats"]["unclassified"] == 1
|
||||||
|
edges = {e["id"]: e for e in graph["edges"]}
|
||||||
|
assert edges[ids["sig"]["handle"]]["to"] is None
|
||||||
|
assert edges[branch["handle"]]["from"] == {"junction": "J1"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_foreign_title_block_does_not_connect_the_valve_or_adopt_loose_lines(backend):
|
||||||
|
title = backend._doc.blocks.new(name="TITLEBLOCK_A3")
|
||||||
|
title.add_lwpolyline([(0, 0), (420, 0), (420, 297), (0, 297)], close=True)
|
||||||
|
title.add_lwpolyline([(240, 0), (240, 60), (420, 60)])
|
||||||
|
blk = backend._doc.blocks.new(name="GATE_VALVE")
|
||||||
|
blk.add_lwpolyline([(-4, -2), (-4, 2), (0, 0)], close=True)
|
||||||
|
blk.add_lwpolyline([(4, -2), (4, 2), (0, 0)], close=True)
|
||||||
|
blk.add_attdef("TAG", insert=(0, 4), text="", dxfattribs={"height": 2.5})
|
||||||
|
sheet = (await backend.block_insert("TITLEBLOCK_A3", 0, 0)).handle
|
||||||
|
valve = (await backend.block_insert("GATE_VALVE", 50, 50, attributes={"TAG": "HV-9"})).handle
|
||||||
|
left = (await backend.entity_create_line(10, 50, 46, 50, layer="PROCESS-PIPING-MAIN")).handle
|
||||||
|
right = (await backend.entity_create_line(54, 50, 120, 50, layer="PROCESS-PIPING-MAIN")).handle
|
||||||
|
loose = (await backend.entity_create_line(200, 200, 260, 200, layer="0")).handle
|
||||||
|
graph = await build_graph(backend)
|
||||||
|
edges = {e["id"]: e for e in graph["edges"]}
|
||||||
|
assert set(edges) == {left, right}
|
||||||
|
assert loose not in edges and "unknown" not in graph["stats"]["edges_by_class"]
|
||||||
|
assert edges[left]["to"] == {"node": valve, "port": "p1"}
|
||||||
|
assert edges[right]["from"] == {"node": valve, "port": "p2"}
|
||||||
|
assert edges[left]["from"] is None and edges[right]["to"] is None
|
||||||
|
assert {(d["edge"], d["end"]) for d in graph["dangling"]} == {(left, "from"), (right, "to")}
|
||||||
|
assert graph["stats"]["dangling"] == 2
|
||||||
|
assert graph["unclassified"] == [{"handle": sheet, "block_name": "TITLEBLOCK_A3"}]
|
||||||
|
assert {n["id"] for n in graph["nodes"]} == {valve}
|
||||||
|
|
||||||
|
|
||||||
async def test_include_foreign_false_ignores_untagged_blocks(backend):
|
async def test_include_foreign_false_ignores_untagged_blocks(backend):
|
||||||
blk = backend._doc.blocks.new(name="GATE_VALVE")
|
blk = backend._doc.blocks.new(name="GATE_VALVE")
|
||||||
blk.add_line((0, 0), (1, 0))
|
blk.add_line((0, 0), (1, 0))
|
||||||
@@ -499,3 +571,38 @@ def test_com_polyline_bulges_are_read_only_when_length_says_an_arc_exists():
|
|||||||
curved, calls = fake((0.0, 0.0, 10.0, 0.0, 10.0, 10.0), 10 + 5 * math.pi, [0.0, 1.0, 0.0])
|
curved, calls = fake((0.0, 0.0, 10.0, 0.0, 10.0, 10.0), 10 + 5 * math.pi, [0.0, 1.0, 0.0])
|
||||||
assert _entity_info(curved).properties["bulges"] == [0.0, 1.0, 0.0]
|
assert _entity_info(curved).properties["bulges"] == [0.0, 1.0, 0.0]
|
||||||
assert calls == [0, 1, 2]
|
assert calls == [0, 1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_approximate_geometry_bbox_is_tightened_by_the_attribute_inclusive_box():
|
||||||
|
"""The COM engine marks a rotated INSERT's box ``approximate`` when a
|
||||||
|
member could only be carried by its corners: the box then overshoots the
|
||||||
|
body, and a line ending on the body's true edge would sit *inside* it and
|
||||||
|
dangle. The drawn geometry lies inside ``bounding_box`` too, so every
|
||||||
|
wall takes the tighter of the two; an exact box is used as it is."""
|
||||||
|
from backends.base import EntityInfo
|
||||||
|
from engineering.pid.graph import _bbox, _on_bbox_boundary
|
||||||
|
|
||||||
|
def info(**props):
|
||||||
|
return EntityInfo(
|
||||||
|
handle="1",
|
||||||
|
type="INSERT",
|
||||||
|
layer="0",
|
||||||
|
color=256,
|
||||||
|
linetype="ByLayer",
|
||||||
|
visible=True,
|
||||||
|
properties=props,
|
||||||
|
)
|
||||||
|
|
||||||
|
exact = {"min": [95.0, 95.0], "max": [105.0, 105.0]}
|
||||||
|
outer = {"min": [95.0, 95.0], "max": [105.0, 118.0]} # the TAG above the body
|
||||||
|
assert _bbox(info(geometry_bbox=exact, bounding_box=outer)) == (95.0, 95.0, 105.0, 105.0)
|
||||||
|
loose = {"min": [92.93, 92.93], "max": [107.07, 107.07], "approximate": True}
|
||||||
|
assert _bbox(info(geometry_bbox=loose, bounding_box=outer)) == (95.0, 95.0, 105.0, 107.07)
|
||||||
|
assert _bbox(info(geometry_bbox=loose)) == (92.93, 92.93, 107.07, 107.07)
|
||||||
|
assert _bbox(info(bounding_box=outer)) == (95.0, 95.0, 105.0, 118.0)
|
||||||
|
assert _bbox(info()) is None
|
||||||
|
# a line ending on the body's true left edge attaches through the tightened box
|
||||||
|
assert _on_bbox_boundary(
|
||||||
|
(95.0, 100.0), _bbox(info(geometry_bbox=loose, bounding_box=outer)), 0.5
|
||||||
|
)
|
||||||
|
assert not _on_bbox_boundary((95.0, 100.0), _bbox(info(geometry_bbox=loose)), 0.5)
|
||||||
|
|||||||
Reference in New Issue
Block a user