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
+161 -61
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
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
ends is an inline fitting - valve, coupling, reducer - and those ends are
joined through it; the fitting 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.
ends is an inline fitting - valve, coupling, reducer - and every one of
those ends is joined to every other through it (a three- or four-way
fitting has no hub port, so nothing depends on record order); the fitting
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.
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
piece of that drawn entity (*direct*; when two labels reach one piece, the
nearer wins and the pair is listed in `stats.diameter_conflicts`). An
unlabelled piece takes the 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.
`label_search` (default 5 x the median text height) - unless an open line
on a layer that is not read stands strictly nearer, in which case the label
is that line's and is listed in `stats.labels_skipped` - and holds for
every piece of that drawn entity on the same side of every reducer
(*direct*; when two labels reach one piece, the nearer wins and the pair is
listed in `stats.diameter_conflicts`). An unlabelled piece takes the
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,
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
@@ -432,12 +441,15 @@ def _arc_segment(rec: EntityRecord):
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)
out = []
for rec in snap.records:
if rec.space != MODEL or rec.layer not in wanted or rec.type not in LINE_TYPES:
continue
if open_only and rec.closed and rec.type in ("LWPOLYLINE", "POLYLINE"):
continue
if rec.type == "LINE":
if len(rec.points) >= 2:
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
class _PieceIndex:
"""Grid of pieces for 'nearest piece within `search`' queries."""
class _ChordIndex:
"""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:
lengths = [p["length"] for p in pieces] or [search]
def __init__(self, chords: list[tuple[Pt, Pt]], search: float) -> None:
lengths = [math.dist(a, b) for a, b in chords] or [search]
self.cell = max(search, statistics.median(lengths), _EPS)
self.search = search
self.cells: dict[tuple[int, int], list[int]] = {}
for k, piece in enumerate(pieces):
a, b = points[piece["u"]], points[piece["v"]]
x0 = math.floor((min(a[0], b[0]) - 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 k, (a, b) in enumerate(chords):
box = (min(a[0], b[0]), min(a[1], b[1]), max(a[0], b[0]), max(a[1], b[1]))
x0, y0, x1, y1 = _cell_range(box, search, self.cell)
for cx in range(x0, x1 + 1):
for cy in range(y0, y1 + 1):
self.cells.setdefault((cx, cy), []).append(k)
@@ -477,6 +486,38 @@ class _PieceIndex:
def candidates(self, p: Pt) -> list[int]:
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:
"""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)
if u != v:
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
# 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["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)
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
]
free_grid = _PointGrid(
((vertex, pts[vertex]) for vertex in free),
_typical_size([rec.bbox for rec in inserts], search),
)
joined = sorted(vertex for vertex, ks in at_vertex.items() if len(ks) > 1)
cell = _typical_size([rec.bbox for rec in inserts], search)
free_grid = _PointGrid(((vertex, pts[vertex]) for vertex in free), cell)
joined_grid = _PointGrid(((vertex, pts[vertex]) for vertex in joined), cell)
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, []
for rec in inserts:
reducer = is_reducer(rec.block)
inside = free_grid.in_box(rec.bbox, tol)
if len(inside) < 2:
continue
through = joined_grid.in_box(rec.bbox, tol) if reducer else []
if len(inside) > MAX_FITTING_ENDS:
skipped.append(rec.handle)
continue
reducer = is_reducer(rec.block)
if len(inside) < 2 and not through:
continue
fittings += 1
reducers += int(reducer)
for vertex in inside[1:]:
bridges.append((inside[0], vertex, reducer, rec.handle))
reducer_vertices.update(through)
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
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:
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))}
for ks in at_vertex.values():
for vertex, ks in at_vertex.items():
across = vertex in reducer_vertices
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 k in at_vertex.get(u, ()):
neighbours[k].update((j, reducer) for j in at_vertex.get(v, ()))
for k in at_vertex.get(v, ()):
neighbours[k].update((j, reducer) for j in at_vertex.get(u, ()))
# labels: diameters and supply / return words go to the nearest piece
index = _PieceIndex(pieces, pts, search)
by_handle: dict[str, list[int]] = {}
for k, piece in enumerate(pieces):
for handle in piece["handles"]:
by_handle.setdefault(handle, []).append(k)
# labels: diameters and supply / return words go to the nearest piece, unless
# a line on a layer that is not read stands nearer - the label is that line's
index = _ChordIndex([(pts[piece["u"]], pts[piece["v"]]) for piece in pieces], search)
unread = set(_layer_names(snap)) - set(chosen)
others = [(a, b, layer) for a, b, _bulge, _h, layer in _segments(snap, unread, open_only=True)]
other_index = _ChordIndex([(a, b) for a, b, _layer in others], search)
def nearest(p: Pt) -> tuple[int, float] | None:
found = []
@@ -647,6 +715,29 @@ def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) ->
d, k = min(found)
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]]] = {}
votes: dict[int, list[tuple[float, str, str | None, str]]] = {}
labels_skipped = []
@@ -664,17 +755,26 @@ def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) ->
if hit is None:
continue
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:
labels_skipped.append(
{"handle": rec.handle, "text": rec.text, "reason": "more than one diameter"}
)
elif tokens:
for handle in pieces[k]["handles"]:
for j in by_handle[handle]:
q = pieces[j]
reaching.setdefault(j, []).append(
(_segment_distance(where, pts[q["u"]], pts[q["v"]]), tokens[0], rec.handle)
)
for j in entity_side(k):
q = pieces[j]
reaching.setdefault(j, []).append(
(_segment_distance(where, pts[q["u"]], pts[q["v"]]), tokens[0], rec.handle)
)
if direction is not None:
family = _family_of(rec.text)
votes.setdefault(run_of[k], []).append((dist, direction, family, rec.handle))