home · contact · privacy
Add -f ldraw_tree to visualize LDRAW file dependencies.
authorPlom Heller <plom@plomlompom.com>
Tue, 4 Aug 2026 16:45:01 +0000 (18:45 +0200)
committerPlom Heller <plom@plomlompom.com>
Tue, 4 Aug 2026 16:45:01 +0000 (18:45 +0200)
src/bricksplom/ldraw.py
src/bricksplom/misc.py

index 04572d998d89c87191c5a57e33870704d4b5e738..bef23b8155866b87c12489e545099943047d1ec8 100644 (file)
@@ -18,6 +18,10 @@ LDRAW_STUDS = {
 }
 
 
+LdrawRefsTree = tuple[Path, '_SubRefs']
+_SubRefs = dict[str, LdrawRefsTree]
+
+
 class LdrawDb:
     'Connection to local LDRAW archive.'
 
@@ -31,8 +35,29 @@ class LdrawDb:
             self,
             filename
             ) -> Path:
-        'Build {._root}/part/{filename}.'
-        return self._root.joinpath('parts', filename)
+        'Find actual path from filename in part/, p/ etc.'
+        for prefix in ('parts', 'p'):
+            path = self._root.joinpath(prefix, filename)
+            if path.exists():
+                break
+        if not path.exists():
+            raise Exception(f'invalid ldraw reference: {filename}')
+        return path
+
+    def tree(
+            self,
+            filename: str
+            ) -> LdrawRefsTree:
+        'Produce LdrawRefsTree for filename.'
+        path = self.path(filename)
+        refs: _SubRefs = {}
+        for line in path.read_text(encoding='utf8').split(CHAR_NEWLINE):
+            line = line.lstrip()
+            if not line.startswith('1'):
+                continue
+            ref = line.split(maxsplit=14)[-1].replace('\\', '/')
+            refs[ref] = self.tree(ref)
+        return path, refs
 
     def count_studs(
             self,
@@ -40,10 +65,6 @@ class LdrawDb:
             ) -> int:
         'On item at {._root}/p(art)/{filename} count studs.'
         path = self.path(filename)
-        if not path.exists():
-            path = self._root.joinpath('p', filename)
-            if not path.exists():
-                raise Exception(f'invalid ldraw reference: {filename}')
         studs_counted = 0
         for line in path.read_text(encoding='utf8').split(CHAR_NEWLINE):
             line = line.lstrip()
index 76d8417a26a63227c7815ab86ebee8da5222f424..90aa8ec6ca260a06b098237ce6e80776fd65da3f 100644 (file)
@@ -7,7 +7,7 @@ from pathlib import Path
 from typing import Callable, Optional, Self
 # ourselves
 from bricksplom.constants import CHAR_NEWLINE
-from bricksplom.ldraw import LdrawDb
+from bricksplom.ldraw import LdrawDb, LdrawRefsTree
 
 CHAR_SEP_TOKEN = ' '
 CHAR_COMMENT = '#'
@@ -104,14 +104,14 @@ class Textfiled(ABC):
 
 
 class WithDb:
-    'Add db:Optional[BricksDB] field to __init__, setting ._db.'
+    'Add db:Optional[BricksDB] field to __init__, setting .db.'
 
     def __init__(
             self,
             db: Optional['BricksDb'] = None,
             **kwargs
             ) -> None:
-        self._db = db
+        self.db = db
         super().__init__(**kwargs)
 
 
@@ -271,7 +271,7 @@ class BrickDesignData:
     ldraw: str = ''
 
 
-class BrickDesign(Textfiled, Lookupable):
+class BrickDesign(Textfiled, WithDb, Lookupable):
     'Shape and texture configurations with descriptions and equalities.'
     _id_indent = 6
     alternate_to: Optional[Self] = None
@@ -279,11 +279,13 @@ class BrickDesign(Textfiled, Lookupable):
     def __init__(
             self,
             id_: str,
-            attrs: Optional[BrickDesignData] = None
+            attrs: Optional[BrickDesignData] = None,
+            **kwargs
             ) -> None:
         self.id_ = id_
         self.direct_attrs = attrs
         self.alternate_ids: set[str] = set()
+        super().__init__(**kwargs)
 
     def __getattribute__(self, key: str):
         if key in BrickDesignData.__annotations__:
@@ -303,6 +305,34 @@ class BrickDesign(Textfiled, Lookupable):
         'Own .id_ plus (sorted) .alternate_ids.'
         return tuple([self.id_] + sorted(list(self.alternate_ids)))
 
+    @classmethod
+    def formatters(
+            cls
+            ) -> dict[str, tuple[str, Callable[[Self], str]]]:
+        def ldraw_tree(
+                item: Self
+                ) -> str:
+            def walk(
+                    lines: list[str],
+                    filename: str,
+                    refstree: LdrawRefsTree,
+                    depth: int = 0
+                    ) -> None:
+                indent = depth * 4 * ' '
+                path, subrefs = refstree
+                lines += [f'{indent}"{filename}": {path}']
+                for refname, subrefstree in subrefs.items():
+                    walk(lines, refname, subrefstree, depth+1)
+            if not item.ldraw:
+                return 'no LDRAW file associated'
+            lines: list[str] = []
+            assert item.db is not None
+            walk(lines, item.ldraw, item.db.ldraw.tree(item.ldraw))
+            return CHAR_NEWLINE.join(lines)
+
+        return super().formatters() | {
+                'ldraw_tree': ('tree of LDRAW references', ldraw_tree)}
+
     @classmethod
     def matchers(
             cls
@@ -348,6 +378,7 @@ class BrickDesign(Textfiled, Lookupable):
     def from_textfile(
             cls,
             path: tuple[str, str],
+            db: Optional['BricksDb'] = None,
             **_
             ) -> dict[str, Self]:
         collected = {}
@@ -360,7 +391,7 @@ class BrickDesign(Textfiled, Lookupable):
                 alt_id = body[1:]
                 alts[alt_id] = alts.get(alt_id, set())
                 alts[alt_id].add(design_id)
-                collected[design_id] = cls(design_id)
+                collected[design_id] = cls(design_id, db=db)
             else:
                 assert SEP_DESIGN_DESC in body
                 metadata, desc = body.split(SEP_DESIGN_DESC, maxsplit=1)
@@ -375,7 +406,7 @@ class BrickDesign(Textfiled, Lookupable):
                         setattr(attrs, a_key, int(a_val_str))
                     else:
                         setattr(attrs, a_key, a_val_str)
-                collected[design_id] = cls(design_id, attrs)
+                collected[design_id] = cls(design_id, attrs, db=db)
         for id_, alternate_ids in alts.items():
             collected[id_].alternate_ids = alternate_ids
             for alt_id in alternate_ids:
@@ -450,9 +481,9 @@ class Brick(Textfiled, WithDb, Lookupable):
     def __str__(
             self
             ) -> str:
-        assert self._db
-        design = self._db.designs[self.design_id]
-        color = str(self._db.colors[self.color_id]).strip()
+        assert self.db
+        design = self.db.designs[self.design_id]
+        color = str(self.db.colors[self.color_id]).strip()
         comment = f' # {self.comment}' if self.comment else ''
         return (f'{self.id_indented()} '
                 f'{BrickDesign.indent_id(self.design_id)} '
@@ -567,14 +598,14 @@ class BrickSet(Textfiled, WithDb, Lookupable):
             self
             ) -> str:
         def format_line(count, brick_id, comment) -> str:
-            assert self._db
-            brick = self._db.bricks[brick_id]
+            assert self.db
+            brick = self.db.bricks[brick_id]
             design_id = brick.design_id
             tail_comment = f' # {comment}' if comment else ''
-            color = str(self._db.colors[self._db.bricks[brick_id].color_id]
+            color = str(self.db.colors[self.db.bricks[brick_id].color_id]
                         ).lstrip().split(CHAR_SEP_TOKEN, maxsplit=1)[1]
             box: Optional[Box] = None
-            for i_box in self._db.boxes.values():
+            for i_box in self.db.boxes.values():
                 for idx_in_box in [
                         idx for idx, d_to_ls
                         in enumerate(i_box.designs_to_listings)
@@ -584,7 +615,7 @@ class BrickSet(Textfiled, WithDb, Lookupable):
                 if box:
                     break
             if not box:
-                for i_box in self._db.boxes.values():
+                for i_box in self.db.boxes.values():
                     for idx_in_box in [
                             idx for idx, t
                             in enumerate(i_box.designs_to_listings)
@@ -598,7 +629,7 @@ class BrickSet(Textfiled, WithDb, Lookupable):
             return (
                 f'{count:>2}× {Brick.indent_id(brick_id)}:'
                 f'{BrickDesign.indent_id(design_id)} {box_listing} {color} '
-                + self._db.designs[design_id].description + tail_comment)
+                + self.db.designs[design_id].description + tail_comment)
 
         return f'{self}{CHAR_NEWLINE}{self._format_paginated(format_line)}'
 
@@ -614,12 +645,12 @@ class Box(WithDb, Lookupable):
             **kwargs
             ) -> None:
         super().__init__(**kwargs)
-        assert self._db
+        assert self.db
         self.id_ = id_
         self._set = bricks_set
         designed_listings: list[tuple[BrickDesign, list[BrickListing]]] = []
         for listing in self.brick_listings_flat():
-            design = self._db.designs[self._db.bricks[listing[1]].design_id]
+            design = self.db.designs[self.db.bricks[listing[1]].design_id]
             design = design.alternate_to or design
             if not (designed_listings and designed_listings[-1][0] == design):
                 designed_listings += [(design, []), ]
@@ -644,13 +675,13 @@ class Box(WithDb, Lookupable):
     def show(
             self,
             ) -> str:
-        assert self._db
+        assert self.db
         lines = []
         for design, listings in self.designs_to_listings:
             lines += [
                 f'=== {" / ".join(design.all_ids)}: {design.description} ===']
             for count, brick_id, comment in listings:
-                color = self._db.colors[self._db.bricks[brick_id].color_id]
+                color = self.db.colors[self.db.bricks[brick_id].color_id]
                 lines += [f'{count:>2}× {Brick.indent_id(brick_id)} / {color} '
                           f'# {comment}']
         return CHAR_NEWLINE.join(lines)
@@ -681,13 +712,13 @@ class BricksDb:
             kwargs = {'db': self} if issubclass(cls, WithDb) else {}
             setattr(self, name, cls.from_textfile((path_tables, f'{name}.txt'),
                                                   **kwargs))
-        self._ldraw = LdrawDb(Path(path_ldraw))
+        self.ldraw = LdrawDb(Path(path_ldraw))
         self._ldraw_modes = ldraw_modes.split(CHAR_SEP_LDRAW_MODES)
         if LDRAW_MODE_FILL_PATHS in self._ldraw_modes:
             for design in [design for design in self.designs.values()
                            if not (design.alternate_to or design.ldraw)]:
                 filename = f'{design.id_}.dat'
-                if self._ldraw.path(filename).exists():
+                if self.ldraw.path(filename).exists():
                     assert design.direct_attrs is not None
                     design.direct_attrs.ldraw = filename
         self._check_consistencies()
@@ -696,7 +727,7 @@ class BricksDb:
                            if d.n_studs != 1]:
                 assert design.direct_attrs is not None
                 design.direct_attrs.n_studs\
-                    = self._ldraw.count_studs(design.ldraw)
+                    = self.ldraw.count_studs(design.ldraw)
 
     @property
     def boxes(
@@ -758,7 +789,7 @@ class BricksDb:
         for design in self._designs_ldrawed():
             # hunt for dangling Design.ldraw references
             if LDRAW_MODE_VERIFY_PATHS in self._ldraw_modes:
-                if not self._ldraw.path(design.ldraw).exists():
+                if not self.ldraw.path(design.ldraw).exists():
                     fails += ['unresolved ldraw reference for design of ID: '
                               f'{design.id_} ({design.ldraw})']
                     continue
@@ -766,7 +797,7 @@ class BricksDb:
             if LDRAW_MODE_VERIFY_N_STUDS in self._ldraw_modes:
                 if design.n_studs <= -1:
                     continue
-                studs_counted = self._ldraw.count_studs(design.ldraw)
+                studs_counted = self.ldraw.count_studs(design.ldraw)
                 if design.n_studs != studs_counted:
                     fails += ['n_studs count differs between record and ldraw '
                               f'calculation for design of ID: {design.id_} '