fix(task-5): fittings bridge every port pair, reducers found however the pipe meets them, unread-layer lines keep their labels

- A three- or four-way fitting joined its ports as a star around the port
  seen first, so the diameter carried into an unlabelled branch depended on
  record order. Every port pair is now bridged; two sizes equally near stay
  unassigned whatever the order (six-order parametrised test).
- A reducer was seen only through free ends in its box. It is now also found
  where pieces meet at a vertex inside its box (a line broken at the
  reducer) and where a straight segment is drawn through the middle of its
  box (cut at the foot of the box centre). A direct label holds for its
  entity only on its side of every reducer.
- A diameter or direction label nearer to an open line on a layer that is
  not read belongs to that line: it is dropped and listed in
  stats.labels_skipped instead of landing on the nearest read pipe.

Headless engine only: no new ActiveX call, so no live probe applies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-24 12:08:47 +03:00
co-authored by Claude Opus 5.5
parent 1426c53fd3
commit 7bfba02ef0
2 changed files with 260 additions and 61 deletions
+157 -57
View File
@@ -21,19 +21,28 @@ The algorithm, in the order it runs:
drawn over a polyline, into identical pieces; each piece is kept once and drawn over a polyline, into identical pieces; each piece is kept once and
the length the copies would have added is `stats.overlap_length`. the length the copies would have added is `stats.overlap_length`.
5. **Fittings.** An INSERT whose box (grown by `tol`) holds two to four free 5. **Fittings.** An INSERT whose box (grown by `tol`) holds two to four free
ends is an inline fitting - valve, coupling, reducer - and those ends are ends is an inline fitting - valve, coupling, reducer - and every one of
joined through it; the fitting adds no length. An INSERT that a tag labels those ends is joined to every other through it (a three- or four-way
is equipment and never a fitting. A block whose name contains reducer / fitting has no hub port, so nothing depends on record order); the fitting
переход / redüksiyon / verloop / Reduzier(stück) is a reducer. adds no length. An INSERT that a tag labels is equipment and never a
fitting. A block whose name contains reducer / переход / redüksiyon /
verloop / Reduzier(stück) is a reducer, and a reducer is found however the
pipe meets it: free ends in its box, pieces meeting at a vertex inside its
box (a line broken at the reducer), or a straight segment drawn through
the middle of its box (its line within a quarter of the box's smaller side
of the centre), which is cut at the foot of the centre before step 3.
6. **Runs.** A connected component of pieces and fittings is a run. 6. **Runs.** A connected component of pieces and fittings is a run.
7. **Diameter.** Each diameter label goes to the piece nearest to it within 7. **Diameter.** Each diameter label goes to the piece nearest to it within
`label_search` (default 5 x the median text height) and holds for every `label_search` (default 5 x the median text height) - unless an open line
piece of that drawn entity (*direct*; when two labels reach one piece, the on a layer that is not read stands strictly nearer, in which case the label
nearer wins and the pair is listed in `stats.diameter_conflicts`). An is that line's and is listed in `stats.labels_skipped` - and holds for
unlabelled piece takes the diameter of the nearest direct piece along the every piece of that drawn entity on the same side of every reducer
run, measured along the pipe and never across a reducer (*continuity*); two (*direct*; when two labels reach one piece, the nearer wins and the pair is
different diameters equally near, or none, leave it *unassigned*. Labels are listed in `stats.diameter_conflicts`). An unlabelled piece takes the
kept as drawn: Ø51, SMS51 and DN20 are different sizes. diameter of the nearest direct piece along the run, measured along the
pipe and never across a reducer (*continuity*); two different diameters
equally near, or none, leave it *unassigned*. Labels are kept as drawn:
Ø51, SMS51 and DN20 are different sizes.
8. **Service.** A run's service is the length-weighted service of its layers, 8. **Service.** A run's service is the length-weighted service of its layers,
overridden for the whole run by a supply / return word within overridden for the whole run by a supply / return word within
`label_search` of one of its pieces - a layer alone never splits CIP supply `label_search` of one of its pieces - a layer alone never splits CIP supply
@@ -432,12 +441,15 @@ def _arc_segment(rec: EntityRecord):
return a, b, math.tan(math.radians(sweep) / 4.0) return a, b, math.tan(math.radians(sweep) / 4.0)
def _segments(snap: Snapshot, layers) -> list[tuple[Pt, Pt, float, str, str]]: def _segments(snap: Snapshot, layers, *, open_only=False) -> list[tuple[Pt, Pt, float, str, str]]:
"""Every LINE_TYPES segment in model space on `layers`; `open_only` skips closed polylines."""
wanted = set(layers) wanted = set(layers)
out = [] out = []
for rec in snap.records: for rec in snap.records:
if rec.space != MODEL or rec.layer not in wanted or rec.type not in LINE_TYPES: if rec.space != MODEL or rec.layer not in wanted or rec.type not in LINE_TYPES:
continue continue
if open_only and rec.closed and rec.type in ("LWPOLYLINE", "POLYLINE"):
continue
if rec.type == "LINE": if rec.type == "LINE":
if len(rec.points) >= 2: if len(rec.points) >= 2:
out.append((rec.points[0], rec.points[1], 0.0, rec.handle, rec.layer)) out.append((rec.points[0], rec.points[1], 0.0, rec.handle, rec.layer))
@@ -456,20 +468,17 @@ def _segments(snap: Snapshot, layers) -> list[tuple[Pt, Pt, float, str, str]]:
return out return out
class _PieceIndex: class _ChordIndex:
"""Grid of pieces for 'nearest piece within `search`' queries.""" """Grid of chords (a, b) for 'which chords lie within `search` of p, or cross this box'."""
def __init__(self, pieces: list[dict], points: list[Pt], search: float) -> None: def __init__(self, chords: list[tuple[Pt, Pt]], search: float) -> None:
lengths = [p["length"] for p in pieces] or [search] lengths = [math.dist(a, b) for a, b in chords] or [search]
self.cell = max(search, statistics.median(lengths), _EPS) self.cell = max(search, statistics.median(lengths), _EPS)
self.search = search self.search = search
self.cells: dict[tuple[int, int], list[int]] = {} self.cells: dict[tuple[int, int], list[int]] = {}
for k, piece in enumerate(pieces): for k, (a, b) in enumerate(chords):
a, b = points[piece["u"]], points[piece["v"]] box = (min(a[0], b[0]), min(a[1], b[1]), max(a[0], b[0]), max(a[1], b[1]))
x0 = math.floor((min(a[0], b[0]) - search) / self.cell) x0, y0, x1, y1 = _cell_range(box, search, self.cell)
x1 = math.floor((max(a[0], b[0]) + search) / self.cell)
y0 = math.floor((min(a[1], b[1]) - search) / self.cell)
y1 = math.floor((max(a[1], b[1]) + search) / self.cell)
for cx in range(x0, x1 + 1): for cx in range(x0, x1 + 1):
for cy in range(y0, y1 + 1): for cy in range(y0, y1 + 1):
self.cells.setdefault((cx, cy), []).append(k) self.cells.setdefault((cx, cy), []).append(k)
@@ -477,6 +486,38 @@ class _PieceIndex:
def candidates(self, p: Pt) -> list[int]: def candidates(self, p: Pt) -> list[int]:
return self.cells.get((math.floor(p[0] / self.cell), math.floor(p[1] / self.cell)), []) return self.cells.get((math.floor(p[0] / self.cell), math.floor(p[1] / self.cell)), [])
def in_box(self, box) -> list[int]:
"""The chords filed in any cell `box` covers, each once, in index order."""
x0, y0, x1, y1 = _cell_range(box, 0.0, self.cell)
return sorted(
{
k
for cx in range(x0, x1 + 1)
for cy in range(y0, y1 + 1)
for k in self.cells.get((cx, cy), ())
}
)
def _through_middle(a: Pt, b: Pt, box, tol: float) -> Pt | None:
"""Where a straight segment passing through the middle of `box` is cut, else None.
The middle: the segment's line runs within a quarter of the box's smaller
side (plus `tol`) of the box centre - a pipe drawn through a symbol, not a
parallel pipe grazing its edge. The cut is the foot of the centre on the
segment, which must lie strictly between the segment's ends.
"""
length = math.dist(a, b)
if length <= _EPS:
return None
centre = ((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0)
s, off = _along(centre, a, b)
reach = min(box[2] - box[0], box[3] - box[1]) / 4.0 + tol
if off > reach or not tol < s < length - tol:
return None
t = s / length
return (a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]))
def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) -> dict: def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) -> dict:
"""Runs of the line network on `layers` - see the module docstring for every rule. """Runs of the line network on `layers` - see the module docstring for every rule.
@@ -502,6 +543,37 @@ def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) ->
u, v = verts.index(a), verts.index(b) u, v = verts.index(a), verts.index(b)
if u != v: if u != v:
snapped.append((u, v, bulge, handle, layer)) snapped.append((u, v, bulge, handle, layer))
# equipment first: an INSERT a tag labels is never a fitting
occurrences = tag_occurrences(snap)
equipment = equipment_boxes(
snap, occurrences, exclude_layers=chosen, search=EQUIPMENT_SEARCH_SHARE * search
)
equipment_handles = {box["handle"] for boxes in equipment.values() for box in boxes}
inserts = [
rec
for rec in snap.records
if rec.space == MODEL
and rec.type == "INSERT"
and rec.bbox is not None
and rec.handle not in equipment_handles
]
reducer_boxes = [rec.bbox for rec in inserts if is_reducer(rec.block)]
# a straight segment drawn through the middle of a reducer is cut there, so
# the reducer separates its two sides (the split below makes the cut)
if reducer_boxes:
chords = [(verts.points[u], verts.points[v]) for u, v, _b, _h, _l in snapped]
chord_index = _ChordIndex(chords, tol)
for box in reducer_boxes:
for n in chord_index.in_box((box[0] - tol, box[1] - tol, box[2] + tol, box[3] + tol)):
u, v, bulge, _handle, _layer = snapped[n]
a, b = verts.points[u], verts.points[v]
if bulge != 0.0 or _in_box(a, box, tol) or _in_box(b, box, tol):
continue
cut = _through_middle(a, b, box, tol)
if cut is not None:
verts.index(cut)
pts = verts.points pts = verts.points
# split straight segments at every vertex lying on them (T-junctions, overlaps) # split straight segments at every vertex lying on them (T-junctions, overlaps)
@@ -556,40 +628,34 @@ def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) ->
at_vertex.setdefault(piece["u"], []).append(k) at_vertex.setdefault(piece["u"], []).append(k)
at_vertex.setdefault(piece["v"], []).append(k) at_vertex.setdefault(piece["v"], []).append(k)
# equipment first: an INSERT a tag labels is never a fitting
occurrences = tag_occurrences(snap)
equipment = equipment_boxes(
snap, occurrences, exclude_layers=chosen, search=EQUIPMENT_SEARCH_SHARE * search
)
equipment_handles = {box["handle"] for boxes in equipment.values() for box in boxes}
free = sorted(vertex for vertex, ks in at_vertex.items() if len(ks) == 1) free = sorted(vertex for vertex, ks in at_vertex.items() if len(ks) == 1)
inserts = [ joined = sorted(vertex for vertex, ks in at_vertex.items() if len(ks) > 1)
rec cell = _typical_size([rec.bbox for rec in inserts], search)
for rec in snap.records free_grid = _PointGrid(((vertex, pts[vertex]) for vertex in free), cell)
if rec.space == MODEL joined_grid = _PointGrid(((vertex, pts[vertex]) for vertex in joined), cell)
and rec.type == "INSERT"
and rec.bbox is not None
and rec.handle not in equipment_handles
]
free_grid = _PointGrid(
((vertex, pts[vertex]) for vertex in free),
_typical_size([rec.bbox for rec in inserts], search),
)
bridges: list[tuple[int, int, bool, str]] = [] bridges: list[tuple[int, int, bool, str]] = []
# vertices where pieces meet inside a reducer's box: a line broken at the
# reducer, or cut above where it was drawn straight through it
reducer_vertices: set[int] = set()
fittings, reducers, skipped = 0, 0, [] fittings, reducers, skipped = 0, 0, []
for rec in inserts: for rec in inserts:
reducer = is_reducer(rec.block)
inside = free_grid.in_box(rec.bbox, tol) inside = free_grid.in_box(rec.bbox, tol)
if len(inside) < 2: through = joined_grid.in_box(rec.bbox, tol) if reducer else []
continue
if len(inside) > MAX_FITTING_ENDS: if len(inside) > MAX_FITTING_ENDS:
skipped.append(rec.handle) skipped.append(rec.handle)
continue continue
reducer = is_reducer(rec.block) if len(inside) < 2 and not through:
continue
fittings += 1 fittings += 1
reducers += int(reducer) reducers += int(reducer)
for vertex in inside[1:]: reducer_vertices.update(through)
bridges.append((inside[0], vertex, reducer, rec.handle)) if len(inside) < 2:
continue
# every port joins every other: a three- or four-way fitting has no hub
for n, u in enumerate(inside):
for v in inside[n + 1 :]:
bridges.append((u, v, reducer, rec.handle))
# runs: union-find over pieces and bridges # runs: union-find over pieces and bridges
parent = list(range(len(pts))) parent = list(range(len(pts)))
@@ -617,23 +683,25 @@ def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) ->
for k in ks: for k in ks:
run_of[k] = r run_of[k] = r
# neighbours for continuity: shared vertices, and bridges that are not reducers # neighbours for continuity: shared vertices and bridges, each flagged when a
# reducer stands between the two pieces
neighbours: dict[int, set[tuple[int, bool]]] = {k: set() for k in range(len(pieces))} neighbours: dict[int, set[tuple[int, bool]]] = {k: set() for k in range(len(pieces))}
for ks in at_vertex.values(): for vertex, ks in at_vertex.items():
across = vertex in reducer_vertices
for k in ks: for k in ks:
neighbours[k].update((j, False) for j in ks if j != k) neighbours[k].update((j, across) for j in ks if j != k)
for u, v, reducer, _handle in bridges: for u, v, reducer, _handle in bridges:
for k in at_vertex.get(u, ()): for k in at_vertex.get(u, ()):
neighbours[k].update((j, reducer) for j in at_vertex.get(v, ())) neighbours[k].update((j, reducer) for j in at_vertex.get(v, ()))
for k in at_vertex.get(v, ()): for k in at_vertex.get(v, ()):
neighbours[k].update((j, reducer) for j in at_vertex.get(u, ())) neighbours[k].update((j, reducer) for j in at_vertex.get(u, ()))
# labels: diameters and supply / return words go to the nearest piece # labels: diameters and supply / return words go to the nearest piece, unless
index = _PieceIndex(pieces, pts, search) # a line on a layer that is not read stands nearer - the label is that line's
by_handle: dict[str, list[int]] = {} index = _ChordIndex([(pts[piece["u"]], pts[piece["v"]]) for piece in pieces], search)
for k, piece in enumerate(pieces): unread = set(_layer_names(snap)) - set(chosen)
for handle in piece["handles"]: others = [(a, b, layer) for a, b, _bulge, _h, layer in _segments(snap, unread, open_only=True)]
by_handle.setdefault(handle, []).append(k) other_index = _ChordIndex([(a, b) for a, b, _layer in others], search)
def nearest(p: Pt) -> tuple[int, float] | None: def nearest(p: Pt) -> tuple[int, float] | None:
found = [] found = []
@@ -647,6 +715,29 @@ def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) ->
d, k = min(found) d, k = min(found)
return k, d return k, d
def nearer_unread(p: Pt, dist: float) -> str | None:
"""The layer of the nearest unread line strictly nearer to p than `dist`, else None."""
found = []
for n in other_index.candidates(p):
a, b, layer = others[n]
d = _segment_distance(p, a, b)
if d < dist - _EPS * max(1.0, dist):
found.append((d, layer))
return min(found)[1] if found else None
def entity_side(k: int) -> list[int]:
"""The pieces of k's drawn entities reached from k without crossing a reducer."""
handles = pieces[k]["handles"]
seen, stack = {k}, [k]
while stack:
i = stack.pop()
for j, via_reducer in neighbours[i]:
if via_reducer or j in seen or not pieces[j]["handles"] & handles:
continue
seen.add(j)
stack.append(j)
return sorted(seen)
reaching: dict[int, list[tuple[float, str, str]]] = {} reaching: dict[int, list[tuple[float, str, str]]] = {}
votes: dict[int, list[tuple[float, str, str | None, str]]] = {} votes: dict[int, list[tuple[float, str, str | None, str]]] = {}
labels_skipped = [] labels_skipped = []
@@ -664,13 +755,22 @@ def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) ->
if hit is None: if hit is None:
continue continue
k, dist = hit k, dist = hit
owner = nearer_unread(where, dist)
if owner is not None:
labels_skipped.append(
{
"handle": rec.handle,
"text": rec.text,
"reason": f"nearer to a line on layer {owner}, which is not read",
}
)
continue
if len(tokens) > 1: if len(tokens) > 1:
labels_skipped.append( labels_skipped.append(
{"handle": rec.handle, "text": rec.text, "reason": "more than one diameter"} {"handle": rec.handle, "text": rec.text, "reason": "more than one diameter"}
) )
elif tokens: elif tokens:
for handle in pieces[k]["handles"]: for j in entity_side(k):
for j in by_handle[handle]:
q = pieces[j] q = pieces[j]
reaching.setdefault(j, []).append( reaching.setdefault(j, []).append(
(_segment_distance(where, pts[q["u"]], pts[q["v"]]), tokens[0], rec.handle) (_segment_distance(where, pts[q["u"]], pts[q["v"]]), tokens[0], rec.handle)
+99
View File
@@ -245,6 +245,105 @@ def test_continuity_stops_at_a_reducer():
assert result["stats"]["reducers"] == 1 assert result["stats"]["reducers"] == 1
def test_continuity_stops_at_a_reducer_the_line_is_broken_at():
# BREAK-at-point: two LINEs share a vertex inside the reducer's box, no free end there
result = net(
line("1", (0.0, 0.0), (1030.0, 0.0)),
line("2", (1030.0, 0.0), (2000.0, 0.0)),
insert("R", "REDUCER_51_38", (1000.0, -30.0, 1060.0, 30.0)),
text("D", "Ø51", (400.0, 5.0)),
)
by_handle = {edge["handles"]: edge for edge in result["runs"][0].edges}
assert by_handle[("1",)]["diameter"] == "Ø51"
assert by_handle[("2",)]["diameter_source"] == "unassigned"
assert (result["stats"]["fittings"], result["stats"]["reducers"]) == (1, 1)
def test_a_polyline_drawn_through_a_reducer_is_cut_there():
poly = rec("P", "LWPOLYLINE", PIPE, ((0.0, 0.0), (2000.0, 0.0)))
result = net(
poly,
line("BR", (1500.0, 0.0), (1500.0, 800.0)),
insert("R", "REDUCER_51_38", (1000.0, -30.0, 1060.0, 30.0)),
text("D", "Ø51", (400.0, 5.0)),
)
(run,) = result["runs"]
# cut at the foot of the box centre (1030, 0): 1030 + 470 + 500 + 800 branch
assert abs(total(run) - 2800.0) < EPS
by_span = {(edge["a"][0], edge["b"][0], edge["handles"]): edge for edge in run.edges}
labelled = by_span[(0.0, 1030.0, ("P",))]
assert (labelled["diameter"], labelled["diameter_source"]) == ("Ø51", "direct")
# the label holds for the polyline only up to the reducer; nothing crosses it
others = [edge for edge in run.edges if edge is not labelled]
assert len(others) == 3
assert {edge["diameter_source"] for edge in others} == {"unassigned"}
assert result["stats"]["reducers"] == 1
def test_a_pipe_running_beside_a_reducer_is_not_cut():
# a parallel pipe grazing the reducer's box edge does not pass through its middle
result = net(
line("1", (0.0, 30.0), (2000.0, 30.0)),
insert("R", "REDUCER_51_38", (1000.0, -30.0, 1060.0, 30.0)),
text("D", "Ø51", (400.0, 35.0)),
)
(run,) = result["runs"]
assert len(run.edges) == 1
assert run.edges[0]["diameter_source"] == "direct"
assert result["stats"]["reducers"] == 0
@pytest.mark.parametrize("order", ["ABC", "BAC", "CAB", "ACB", "BCA", "CBA"])
def test_a_three_way_valve_guesses_nothing_whatever_the_record_order(order):
# C leaves the valve 1000 from A (Ø51) and 1000 from B (DN20): equally near
records = {
"A": line("A", (0.0, 0.0), (1000.0, 0.0)),
"B": line("B", (1100.0, 0.0), (2100.0, 0.0)),
"C": line("C", (1050.0, 50.0), (1050.0, 1050.0)),
}
result = net(
*[records[name] for name in order],
insert("V", "3WAY_VALVE", (1000.0, -50.0, 1100.0, 50.0)),
text("TA", "Ø51", (400.0, 5.0)),
text("TB", "DN20", (1500.0, 5.0)),
)
(run,) = result["runs"]
c = next(edge for edge in run.edges if edge["handles"] == ("C",))
assert (c["diameter"], c["diameter_source"]) == (None, "unassigned")
assert [u["reason"] for u in result["unassigned"]] == ["two different diameters equally near"]
assert run.ends == ((0.0, 0.0), (1050.0, 1050.0), (2100.0, 0.0))
def test_a_label_nearer_to_a_pipe_on_an_unread_layer_is_not_taken():
# the label stands 5 below the WATER line and 22.5 above PRODUCT (search 25)
drawing = snap(
line("1", (0.0, 0.0), (1000.0, 0.0), PIPE),
line("W", (0.0, 30.0), (1000.0, 30.0), "WATER"),
text("D", "DN20", (400.0, 20.0)),
)
assert "WATER" not in default_layers(drawing)
result = build_network(drawing)
assert result["runs"][0].edges[0]["diameter_source"] == "unassigned"
assert result["stats"]["labels_skipped"] == [
{
"handle": "D",
"text": "DN20",
"reason": "nearer to a line on layer WATER, which is not read",
}
]
def test_an_equipment_outline_does_not_take_a_pipe_label():
# closed outlines are shapes, not pipes: a label beside the pipe end stays the pipe's
result = net(
tank("A", "T4100", (-500.0, -500.0, 0.0, 500.0)),
line("1", (0.0, 0.0), (1000.0, 0.0)),
text("D", "Ø51", (2.0, 5.0)),
)
edge = result["runs"][0].edges[0]
assert (edge["diameter"], edge["diameter_source"]) == ("Ø51", "direct")
def test_continuity_crosses_a_valve(): def test_continuity_crosses_a_valve():
result = net( result = net(
line("1", (0.0, 0.0), (1000.0, 0.0)), line("1", (0.0, 0.0), (1000.0, 0.0)),