From 7bfba02ef0b0d2ddde2402e86641f442fa593328 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 24 Sep 2026 12:08:47 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/understand/network.py | 222 ++++++++++++++++++++++-------- tests/test_understand_network.py | 99 +++++++++++++ 2 files changed, 260 insertions(+), 61 deletions(-) diff --git a/engineering/understand/network.py b/engineering/understand/network.py index fe98db0..459d19f 100644 --- a/engineering/understand/network.py +++ b/engineering/understand/network.py @@ -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)) diff --git a/tests/test_understand_network.py b/tests/test_understand_network.py index cc70d77..282d576 100644 --- a/tests/test_understand_network.py +++ b/tests/test_understand_network.py @@ -245,6 +245,105 @@ def test_continuity_stops_at_a_reducer(): 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(): result = net( line("1", (0.0, 0.0), (1000.0, 0.0)),