Skip to content

Commit 5e6d621

Browse files
committed
update lineset sorting
1 parent 8e13303 commit 5e6d621

File tree

1 file changed

+159
-28
lines changed

1 file changed

+159
-28
lines changed

openglider/lines/lineset.py

Lines changed: 159 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
import math
77
import os
88
import re
9-
from typing import TYPE_CHECKING, Any, Iterable, TypeAlias, TypeVar
9+
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
1010
from collections.abc import Callable
11+
from functools import cmp_to_key
1112

1213
import euklid
1314
from openglider.lines.node import Node
@@ -17,7 +18,6 @@
1718
from openglider.lines.knots import KnotCorrections
1819
from openglider.lines.line_types.linetype import LineType
1920
from openglider.mesh import Mesh
20-
from openglider.utils.cache import cached_function
2121
from openglider.utils.table import Table
2222
from openglider.vector.unit import Percentage
2323

@@ -532,6 +532,100 @@ def get_consumption(self) -> dict[LineType, float]:
532532

533533
return consumption
534534

535+
def sort_lines_by_attachment_points(self, lines: list[Line]) -> list[Line]:
536+
"""
537+
sort lines by their top-connected nodes.
538+
get the layer-prefixes for all nodes and the min/max index from their names.
539+
then sort by layer and index
540+
"""
541+
def parse_node_name(name: str) -> tuple[str, float]:
542+
"""Return (layer_prefix, index) parsed from a node name.
543+
544+
Examples: 'A3' -> ('A', 3), 'BR12' -> ('BR', 12).
545+
If no numeric index found the index returned is +inf so that nodes without
546+
numeric parts sort to the end for index comparisons.
547+
"""
548+
if not name:
549+
return "", float("inf")
550+
551+
m = re.match(r"^([A-Za-z]+)([0-9]+)$", name)
552+
if m:
553+
return m.group(1).upper(), int(m.group(2))
554+
555+
# fallback: extract letters and first number we find
556+
letters = "".join(re.findall(r"[A-Za-z]+", name)).upper()
557+
nums = re.findall(r"[0-9]+", name)
558+
idx = int(nums[0]) if nums else float("inf")
559+
return letters, idx
560+
561+
line_keys: list[tuple[tuple[dict[str, int], int, int], Line]] = []
562+
563+
for line in lines:
564+
nodes = self.get_upper_influence_nodes(line)
565+
layers: dict[str, int] = {}
566+
min_idx = float("inf")
567+
max_idx = -1
568+
569+
for node in nodes:
570+
layer, idx = parse_node_name(getattr(node, "name", "") or "")
571+
if layer:
572+
layers.setdefault(layer, 0)
573+
layers[layer] += 1
574+
if idx != float("inf"):
575+
min_idx = min(min_idx, idx)
576+
max_idx = max(max_idx, idx)
577+
578+
# normalize indices for missing values so they sort to the end
579+
if min_idx == float("inf"):
580+
min_idx_val = 10 ** 9
581+
else:
582+
min_idx_val = int(min_idx)
583+
584+
if max_idx == -1:
585+
max_idx_val = 10 ** 9
586+
else:
587+
max_idx_val = int(max_idx)
588+
589+
key = (layers, min_idx_val, max_idx_val)
590+
line_keys.append((key, line))
591+
592+
def cmp(a: tuple[tuple[dict[str, int], int, int], Line], b: tuple[tuple[dict[str, int], int, int], Line]) -> int:
593+
# compare layers (sets of strings)
594+
a_layers, a_min_idx, a_max_idx = a[0]
595+
b_layers, b_min_idx, b_max_idx = b[0]
596+
597+
def get_average_layer_key(layers: dict[str, int]) -> float:
598+
return sum([
599+
sum([ord(l) for l in layer.lower()]) / len(layer) * count
600+
for layer, count in layers.items()
601+
]) / sum(layers.values())
602+
603+
def compare_by_average_layer_key(a_layers: dict[str, int], b_layers: dict[str, int]) -> int:
604+
a_avg = get_average_layer_key(a_layers)
605+
b_avg = get_average_layer_key(b_layers)
606+
607+
if a_avg < b_avg:
608+
return -1
609+
elif a_avg > b_avg:
610+
return 1
611+
else:
612+
return 0
613+
614+
if not len(set(a_layers).intersection(b_layers)):
615+
return compare_by_average_layer_key(a_layers, b_layers)
616+
617+
# layers are equal, compare by min index
618+
if a_min_idx > b_max_idx:
619+
return 1
620+
elif b_min_idx > a_max_idx:
621+
return -1
622+
623+
# min indices do not overlap, compare by average layer key
624+
return compare_by_average_layer_key(a_layers, b_layers)
625+
626+
sorted_keys = sorted(line_keys, key=cmp_to_key(cmp))
627+
return [l for _k, l in sorted_keys]
628+
535629
def sort_lines(self, lines: list[Line] | None=None, x_factor: float=10., by_names: bool=False) -> list[Line]:
536630
if lines is None:
537631
lines = self.lines
@@ -589,11 +683,15 @@ def create_tree(self, start_nodes: list[Node] | None=None) -> list[LineTreePart]
589683
if start_nodes is None:
590684
start_nodes = self.lower_attachment_points
591685

592-
lines = []
686+
lines: list[Line] = []
593687
for node in start_nodes:
594688
lines += self.get_upper_connected_lines(node)
595689

596-
return [(line, self.create_tree([line.upper_node])) for line in self.sort_lines(lines, by_names=True)]
690+
691+
692+
return [
693+
(line, self.create_tree([line.upper_node])) for line in self.sort_lines_by_attachment_points(lines)
694+
]
597695

598696
def _get_lines_table(self, callback: Callable[[Line], list[str]], start_nodes: list[Node] | None=None, insert_node_names: bool=True) -> Table:
599697
line_tree = self.create_tree(start_nodes=start_nodes)
@@ -631,6 +729,39 @@ def insert_block(line: Line, upper: list[Any], row: int, column: int) -> int:
631729
node_group_rex = re.compile(r"[^A-Za-z]*([A-Za-z]*)[^A-Za-z]*")
632730

633731
def rename_lines(self) -> LineSet:
732+
"""
733+
Assign hierarchical, human-readable names to all lines in this LineSet based on their
734+
connectivity to upper nodes.
735+
This method mutates the `name` attribute of each Line in `self.lines` and returns
736+
the LineSet (self) for chaining.
737+
Behavior and algorithm:
738+
- A recursive helper (get_floor) computes for each line:
739+
- floor: an integer depth measured as the number of edges from any line that has
740+
no upper-connected lines (these are floor 0).
741+
- prefix: a string composed by collecting and lexicographically sorting prefix
742+
fragments derived from ancestor upper nodes. If an upper node has no matching
743+
prefix, a default "--" is used for that branch.
744+
- Lines are grouped by (floor, prefix) into lines_by_floor[floor][prefix] -> list[Line].
745+
- Special handling for floor 0: each line in floor 0 is named "1_{upper_node.name}".
746+
- For each floor (from 0 up to the maximum computed floor) and for each prefix group:
747+
- The lines are sorted via self.sort_lines(lines, by_names=True) to obtain a stable
748+
ordering.
749+
- Lines are then assigned names of the form "{floor+1}_{prefix}{index}" where
750+
index is 1-based within the sorted group. The floor in the name is 1-based.
751+
- If this LineSet contains no lines, the method returns immediately without changes.
752+
Dependencies and side effects:
753+
- Uses self.get_upper_connected_lines(line.upper_node) to traverse connectivity.
754+
- Uses self.node_group_rex to extract prefix fragments from node names.
755+
- Uses self.sort_lines(...) to deterministically order lines inside each group.
756+
- Mutates each Line.name in-place.
757+
Returns:
758+
LineSet: the same LineSet instance (self) with updated line names.
759+
Notes:
760+
- The naming scheme guarantees deterministic names when the underlying sort and
761+
regex are deterministic.
762+
- The prefix for a group is the concatenation of unique, sorted prefix fragments
763+
found among upstream lines.
764+
"""
634765
def get_floor(line: Line) -> tuple[int, str]:
635766
upper_lines = self.get_upper_connected_lines(line.upper_node)
636767

@@ -644,7 +775,7 @@ def get_floor(line: Line) -> tuple[int, str]:
644775

645776
upper_lines_floors = [get_floor(l) for l in upper_lines]
646777
floor = max([x[0] for x in upper_lines_floors]) + 1
647-
prefixes = set()
778+
prefixes: set[str] = set()
648779
for upper in upper_lines_floors:
649780
for prefix in upper[1]:
650781
prefixes.add(prefix)
@@ -671,9 +802,9 @@ def get_floor(line: Line) -> tuple[int, str]:
671802
for line in lines:
672803
line.name = f"1_{line.upper_node.name}"
673804

674-
for floor in range(max(lines_by_floor)):
805+
for floor in range(1, max(lines_by_floor) + 1):
675806
for prefix, lines in lines_by_floor.get(floor, {}).items():
676-
lines_sorted = self.sort_lines(lines, by_names=True)
807+
lines_sorted = self.sort_lines_by_attachment_points(lines)
677808

678809
for i, line in enumerate(lines_sorted):
679810
line.name = f"{floor+1}_{prefix}{i+1}"
@@ -804,19 +935,19 @@ def get_table_2(self, line_load: bool=False) -> Table:
804935
table[0, 0] = "Name"
805936
table[0, 1] = "Linetype"
806937
table[0, 2] = "Color"
807-
table[0, 3] = "Raw length"
808-
table[0, 4] = "Local Checking Length"
809-
table[0, 5] = "Seam Correction"
810-
table[0, 6] = "Loop Correction"
938+
table[0, 3] = "3D Length"
939+
table[0, 4] = "Loop Correction"
940+
table[0, 5] = "Manual Correction"
941+
table[0, 6] = "Raw Checking length"
811942
table[0, 7] = "Knot Correction"
812-
table[0, 8] = "Manual Correction"
813-
table[0, 9] = "Cutting Length"
943+
table[0, 8] = "Local Checking Length"
944+
table[0, 9] = "Seam Correction"
945+
table[0, 10] = "Cutting Length"
814946

815947
if line_load:
816-
table[0, 10] = "Force"
817-
table[0, 11] = "Min Break Load"
818-
table[0, 12] = "Percentage"
819-
948+
table[0, 11] = "Force"
949+
table[0, 12] = "Min Break Load"
950+
table[0, 13] = "Percentage"
820951

821952
lines = self.sort_lines(by_names=True)
822953
for i, line in enumerate(lines):
@@ -825,19 +956,21 @@ def get_table_2(self, line_load: bool=False) -> Table:
825956
table[i+2, 0] = line.name
826957
table[i+2, 1] = f"{line.line_type}"
827958
table[i+2, 2] = line.color
828-
table[i+2, 3] = round(line_length.get_checklength() * 1000)
829-
table[i+2, 4] = round(line_length.get_length() * 1000)
830-
table[i+2, 5] = round(line_length.seam_correction * 1000)
831-
table[i+2, 6] = round(line_length.loop_correction * 1000)
959+
table[i+2, 3] = round(line_length.length * 1000) # 3d-length
960+
table[i+2, 4] = round(line_length.loop_correction * 1000)
961+
table[i+2, 5] = round(line_length.manual_correction * 1000)
962+
table[i+2, 6] = round(line_length.get_checklength() * 1000) # raw check-length
832963
table[i+2, 7] = round(line_length.knot_correction * 1000)
833-
table[i+2, 8] = round(line_length.manual_correction * 1000)
834-
table[i+2, 9] = round(line_length.get_cutting_length() * 1000)
964+
table[i+2, 8] = round(line_length.get_length() * 1000) # local checking-length
965+
table[i+2, 9] = round(line_length.seam_correction * 1000)
966+
table[i+2, 10] = round(line_length.get_cutting_length() * 1000)
967+
835968
if line_load:
836969
if line.force is None or line.line_type.min_break_load is None:
837970
raise ValueError()
838-
table[i+2, 10] = round(line.force)
839-
table[i+2, 11] = round(line.line_type.min_break_load)
840-
table[i+2, 12] = f"{100*line.force/line.line_type.min_break_load:.1f}%"
971+
table[i+2, 11] = round(line.force)
972+
table[i+2, 12] = round(line.line_type.min_break_load)
973+
table[i+2, 13] = f"{100*line.force/line.line_type.min_break_load:.1f}%"
841974

842975

843976
return table
@@ -885,8 +1018,6 @@ def copy(self) -> LineSet:
8851018
return copy.deepcopy(self)
8861019

8871020
def __getitem__(self, name: str) -> Line:
888-
if isinstance(name, list):
889-
return [self[n] for n in name]
8901021
for line in self.lines:
8911022
if name == line.name:
8921023
return line

0 commit comments

Comments
 (0)