66import math
77import os
88import re
9- from typing import TYPE_CHECKING , Any , Iterable , TypeAlias , TypeVar
9+ from typing import TYPE_CHECKING , Any , TypeAlias , TypeVar
1010from collections .abc import Callable
11+ from functools import cmp_to_key
1112
1213import euklid
1314from openglider .lines .node import Node
1718from openglider .lines .knots import KnotCorrections
1819from openglider .lines .line_types .linetype import LineType
1920from openglider .mesh import Mesh
20- from openglider .utils .cache import cached_function
2121from openglider .utils .table import Table
2222from 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