Skip to content

Scene assembly

gutenberg_kg.scene

scene.py — Qt-free scene builder for the GutenbergKG 3-D knowledge tree forest.

Everything here composes a forest into a plain pv.Plotter: corpus scanning, glyph/edge geometry, :func:build_forest_scene and :func:build_tree_scene. No PyQt, no QApplication.processEvents — the interactive viewer (:mod:gutenberg_kg.viz3d) is one caller of this module and the off-screen light-field renderer (gutenkg quilt) is another.

The placement half — :class:ForestLayout, the seasons, and :func:grow_tree_geometry — lives in :mod:gutenberg_kg.treegeom, which imports no PyVista. That is what lets :mod:gutenberg_kg.povscene emit the same tree as analytic POV-Ray primitives without a VTK stack. Every public name from there is re-exported below, so importing it from this module keeps working.

Progress reporting is a plain Callable[[str], None] so a Qt caller can pump its event loop and a headless caller can print or ignore.

Author: Eric G. Suchanek, PhD

BookMeta(title, genre, book_dir) dataclass

Lightweight metadata for one corpus book.

db_path property

Path to this book's KG SQLite graph file (DocKG or DiaryKG).

has_kg property

Whether any of :data:KG_DIRS holds a non-trivial graph.

kg_dir property

First of :data:KG_DIRS holding a non-trivial graph, else None.

slug property

URL-safe identifier, used as a namespace prefix.

ForestLayout(grove_inner_radius=80.0, grove_outer_radius=240.0, book_inner_radius=5.0, book_outer_radius=18.0, trunk_scale=4.0, max_trunk_height=45.0, branch_radius=5.0, leaf_radius=1.5, canopy_lift=4.0, book_genre_map=None, entry_times=None)

Bases: Layout3D

3-D forest layout for a multi-book corpus.

Spatial structure
  • Genres are placed in a large Fibonacci annulus in the XY plane (each genre → a grove at a fixed XY centre).
  • Books within a genre are placed in a medium Fibonacci annulus around the genre centre.
  • Per book (tree):
    • document node sits at the book's XY position, Z = 0 (trunk base marker).
    • section nodes are distributed on a Fibonacci upper hemisphere centred at the trunk apex (bx, by, trunk_height). Trunk height scales as log2(1 + n_chunks).
    • chunk nodes cluster around their parent section node in a small Fibonacci sphere.
    • entity/topic nodes float in a loose cloud above the canopy at Z = trunk_height + canopy_lift.

Parameters:

Name Type Description Default
grove_inner_radius float

Inner radius for genre placement ring.

80.0
grove_outer_radius float

Minimum outer radius for genre ring.

240.0
book_inner_radius float

Inner radius for books within a genre grove.

5.0
book_outer_radius float

Minimum outer radius within a genre grove.

18.0
trunk_scale float

Multiplier for trunk height (trunk_height = trunk_scale * log2(1 + n_chunks)).

4.0
branch_radius float

Base radius of the Fibonacci hemisphere for sections.

5.0
leaf_radius float

Base radius of the leaf sphere per section.

1.5
canopy_lift float

Z offset above trunk apex for entity / topic nodes.

4.0
book_genre_map dict[str, str] | None

{book_slug: genre}; books not listed fall into the "unknown" grove.

None
entry_times dict[str, str] | None

{document id: ISO timestamp} from :func:load_entry_times, which turns an entry-structured book's limbs into calendar years.

None

Store layout radii/scale parameters; see class docstring for their roles.

Source code in src/gutenberg_kg/treegeom.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
def __init__(
    self,
    grove_inner_radius: float = 80.0,
    grove_outer_radius: float = 240.0,
    book_inner_radius: float = 5.0,
    book_outer_radius: float = 18.0,
    trunk_scale: float = 4.0,
    max_trunk_height: float = 45.0,
    branch_radius: float = 5.0,
    leaf_radius: float = 1.5,
    canopy_lift: float = 4.0,
    book_genre_map: dict[str, str] | None = None,
    entry_times: dict[str, str] | None = None,
) -> None:
    """Store layout radii/scale parameters; see class docstring for their roles."""
    self.grove_inner_radius = grove_inner_radius
    self.grove_outer_radius = grove_outer_radius
    self.book_inner_radius = book_inner_radius
    self.book_outer_radius = book_outer_radius
    self.trunk_scale = trunk_scale
    self.max_trunk_height = max_trunk_height
    self.branch_radius = branch_radius
    self.leaf_radius = leaf_radius
    self.canopy_lift = canopy_lift
    self._book_genre_map: dict[str, str] = book_genre_map or {}
    self.entry_times: dict[str, str] = entry_times or {}

    # Set during compute() so render helpers can read them
    self.trunk_positions: dict[str, np.ndarray] = {}  # doc_node_id → XYZ base
    self.trunk_heights: dict[str, float] = {}  # doc_node_id → height
    self.trunk_genres: dict[str, str] = {}  # doc_node_id → genre
    self.genre_color_map: dict[str, str] = {}  # genre → hex color
    # (trunk_axis_pt, section_tip_pt) for every section — drawn as branch lines
    self.branch_lines: list[tuple[np.ndarray, np.ndarray]] = []
    # book slug → ordered section positions, for the organic layout
    self.book_sections: dict[str, list[np.ndarray]] = {}
    # book slug → chunk positions (crown attractors for the organic layout)
    self.book_chunks: dict[str, list[np.ndarray]] = {}
    # book slug → (base XYZ, trunk height)
    self.book_trunks: dict[str, tuple[np.ndarray, float]] = {}
    # ``document`` node IDs promoted to branch stations (DiaryKG books)
    self.branch_documents: set[str] = set()
    # book slug → [(period label, entry count)] for entry-structured books
    self.book_periods: dict[str, list[tuple[str, int]]] = {}
    # book slug → growth habit its crown was placed with; grow with the same
    self.book_habits: dict[str, Habit] = {}

compute(nodes, edges)

Compute 3-D positions for every node in the combined forest.

Parameters:

Name Type Description Default
nodes list[LayoutNode]

All nodes across all loaded books (IDs already namespaced).

required
edges list[LayoutEdge]

All edges across all loaded books.

required

Returns:

Type Description
dict[str, ndarray]

Mapping from node ID to [x, y, z] position.

Source code in src/gutenberg_kg/treegeom.py
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
def compute(
    self,
    nodes: list[LayoutNode],
    edges: list[LayoutEdge],
) -> dict[str, np.ndarray]:
    """
    Compute 3-D positions for every node in the combined forest.

    :param nodes: All nodes across all loaded books (IDs already namespaced).
    :param edges: All edges across all loaded books.
    :return: Mapping from node ID to ``[x, y, z]`` position.
    """
    # Reset per-compute state
    self.branch_lines = []
    self.book_sections = {}
    self.book_chunks = {}
    self.book_trunks = {}
    self.branch_documents = set()
    self.book_habits = {}

    # Build containment hierarchy
    children: dict[str, list[str]] = defaultdict(list)
    for e in edges:
        if e.rel == "CONTAINS":
            children[e.src].append(e.dst)

    node_by_id = {n.id: n for n in nodes}
    positions: dict[str, np.ndarray] = {}

    # Group nodes by book slug (= first token before first ":")
    books_nodes: dict[str, list[LayoutNode]] = defaultdict(list)
    for n in nodes:
        slug = n.id.split(":")[0]
        books_nodes[slug].append(n)

    # Group books by genre; books the caller did not map fall into "unknown"
    book_genre_map = self._book_genre_map

    genres_books: dict[str, list[str]] = defaultdict(list)
    for slug in books_nodes:
        genre = book_genre_map.get(slug, "unknown")
        genres_books[genre].append(slug)

    # Place genres in a large Fibonacci annulus
    genre_list = sorted(genres_books.keys())
    n_genres = max(len(genre_list), 1)
    genre_outer = max(self.grove_outer_radius, self.grove_inner_radius + n_genres * 12.0)
    grove_centers = fibonacci_annulus(
        n_genres,
        inner_radius=self.grove_inner_radius,
        outer_radius=genre_outer,
        center=np.zeros(3),
        z_thickness=0.0,
    )
    genre_center_map: dict[str, np.ndarray] = {
        g: np.array(p) for g, p in zip(genre_list, grove_centers)
    }
    self.genre_color_map = {
        g: GENRE_PALETTE[i % len(GENRE_PALETTE)] for i, g in enumerate(genre_list)
    }

    # For each genre, place books in a medium annulus around the grove centre
    for genre, grove_center in genre_center_map.items():
        slugs = genres_books[genre]
        n_books = max(len(slugs), 1)
        book_outer = max(self.book_outer_radius, self.book_inner_radius + n_books * 3.0)
        book_positions = fibonacci_annulus(
            n_books,
            inner_radius=self.book_inner_radius,
            outer_radius=book_outer,
            center=grove_center,
            z_thickness=0.0,
        )

        for slug, book_xy in zip(slugs, book_positions):
            bx, by = float(book_xy[0]), float(book_xy[1])
            book_nodes = books_nodes[slug]
            habit = book_habit(slug, genre)
            self.book_habits[slug] = habit

            # Count chunks to determine trunk height; cap so no book dominates
            n_chunks = sum(1 for n in book_nodes if n.kind == "chunk")
            trunk_height = min(
                self.trunk_scale * max(1.0, np.log2(1 + n_chunks)),
                self.max_trunk_height,
            )
            trunk_apex = np.array([bx, by, trunk_height])
            self.book_trunks[slug] = (np.array([bx, by, 0.0]), float(trunk_height))

            doc_nodes = [n for n in book_nodes if n.kind == "document"]
            section_nodes = [n for n in book_nodes if n.kind == "section"]

            # A DiaryKG graph has no sections and one ``document`` per dated
            # entry, so the entries are what branch: the book gets a single
            # synthetic trunk instead of one trunk per document stacked at
            # the same XY, and the entry documents take the branch stations.
            entry_structured = not section_nodes and len(doc_nodes) > 1
            branch_nodes = doc_nodes if entry_structured else section_nodes
            # Diary entries only: the limb point each leaf cluster faces
            # away from, and the radius that keeps it on its limb.
            entry_axis: dict[str, np.ndarray] = {}
            entry_leaf_r: dict[str, float] = {}

            if entry_structured:
                # The trunk is the diary's timeline, so it climbs to the
                # last period and every year forks from it.  A species'
                # shorter leader let colonization serve the top few years
                # from one side limb, and the crown leaned that way.
                self.book_habits[slug] = replace(habit, leader=_DIARY_LEADER)
                trunk_id = f"{slug}:__trunk__"
                self.trunk_positions[trunk_id] = np.array([bx, by, 0.0])
                self.trunk_heights[trunk_id] = trunk_height
                self.trunk_genres[trunk_id] = genre
                self.branch_documents.update(n.id for n in doc_nodes)
            else:
                # Document nodes mark the trunk base
                for doc in doc_nodes:
                    positions[doc.id] = np.array([bx, by, 0.0])
                    self.trunk_positions[doc.id] = np.array([bx, by, 0.0])
                    self.trunk_heights[doc.id] = trunk_height
                    self.trunk_genres[doc.id] = genre

            # Branch nodes — spiral up the trunk (B: real tree branching)
            n_branches = len(branch_nodes)
            branch_length = 0.0  # reused by canopy cloud below
            if n_branches and entry_structured:
                # A diary has no chapters — just thousands of dated entries
                # hanging off one trunk, which grows a lollipop however the
                # crown volume is shaped.  Its real hierarchy is time, so the
                # limbs are periods: trunk → period limb → entry cluster →
                # chunk leaves.  Each limb is a span of the diarist's life,
                # and because the pipe model sizes a limb by what it carries,
                # a prolific year grows visibly heavier wood than a quiet one.
                groups = self._period_groups(branch_nodes)
                n_limbs = len(groups)
                branch_length = self.branch_radius + np.sqrt(n_limbs) * 0.5
                mean_members = n_branches / n_limbs

                # Each limb's height and turn follow its year; the species'
                # envelope sets its reach.  Limbs reach further than a
                # book's section tips: the foliage runs along them instead
                # of bulging past the tip, so the wood itself has to carry
                # the crown's width.
                limb_tips = _diary_limb_tips(
                    _period_steps([label for label, _ in groups]),
                    trunk_height,
                    branch_length * _DIARY_LIMB_REACH,
                    habit,
                    (bx, by),
                )
                limb_tips = _size_limbs_by_entries(
                    limb_tips, [len(m) for _, m in groups], (bx, by)
                )
                # Size clusters to the room each limb actually has.  A fixed
                # fraction of the crown radius works for ten limbs and fails
                # for thirty: consecutive years end up closer together than
                # their clusters are wide, so the foliage interpenetrates and
                # leaves take their orientation from a neighbouring limb.
                room = _nearest_neighbour_gap(limb_tips)

                for limb, (label, members) in enumerate(groups):
                    tip = limb_tips[limb]
                    z = float(tip[2])
                    limb_base = np.array([bx, by, z])
                    self.book_periods.setdefault(slug, []).append((label, len(members)))
                    # Within that room, a busy year still fills more of it.
                    weight = min(np.sqrt(len(members) / mean_members), 1.6)
                    cluster_r = min(0.45 * room[limb] * weight, branch_length * 0.4 * weight)
                    # Entries hang along the limb at their point in the
                    # period rather than piling onto its tip, which for
                    # Pepys meant ~365 entries per tip.  Each sits a little
                    # off the limb on the golden angle, and its leaves face
                    # away from the limb, so a year reads as a leafy branch.
                    sleeve_r = 0.5 * cluster_r
                    u, v = _limb_frame(tip - limb_base)
                    stations = _LIMB_BARE + (1.0 - _LIMB_BARE) * self._entry_fractions(
                        members, label.isdigit()
                    )
                    for i, (sec, t) in enumerate(zip(members, stations)):
                        on_limb = limb_base + t * (tip - limb_base)
                        angle = i * _GOLDEN_ANGLE
                        offset = np.cos(angle) * u + np.sin(angle) * v
                        positions[sec.id] = on_limb + sleeve_r * offset
                        entry_axis[sec.id] = on_limb
                        entry_leaf_r[sec.id] = sleeve_r
                    # The drawn limb ends at its last entry, not at the tip.
                    reach = float(stations.max()) if len(stations) else 1.0
                    self.branch_lines.append((limb_base, limb_base + reach * (tip - limb_base)))
                self.book_sections[slug] = [positions[s.id] for s in branch_nodes]

            elif n_branches:
                branch_length = self.branch_radius + np.sqrt(n_branches) * 0.5
                # Sections climb the species' crown envelope on the golden
                # angle (or in whorls), each as far out as the crown is wide.
                tips = crown_sections(
                    n_branches, trunk_height, branch_length, habit, base=(bx, by)
                )
                for sec, sec_pos in zip(branch_nodes, tips):
                    z = float(sec_pos[2])
                    positions[sec.id] = sec_pos
                    # Branch line: point on trunk axis at section's Z → section tip
                    self.branch_lines.append((np.array([bx, by, z]), sec_pos))
                self.book_sections[slug] = [positions[s.id] for s in branch_nodes]

            # Chunk nodes — upper-hemisphere cone above each branch tip
            sec_chunks: dict[str, list[str]] = {
                n.id: children.get(n.id, []) for n in branch_nodes
            }
            book_chunk_pts: list[np.ndarray] = []
            for sec in branch_nodes:
                _pos = positions.get(sec.id)
                if _pos is None:
                    continue
                sec_pos = _pos
                chunk_ids = [
                    cid
                    for cid in sec_chunks.get(sec.id, [])
                    if node_by_id.get(cid) and node_by_id[cid].kind == "chunk"
                ]
                n_c = len(chunk_ids)
                if not n_c:
                    continue
                leaf_r = self.leaf_radius + np.sqrt(n_c) * 0.12
                # The cluster faces the way its limb points, not straight up:
                # a hemisphere filtered on world +Z gives every sub-canopy the
                # same vertical dome no matter which way its branch runs, which
                # is the giveaway that reads as wrong in parallax.  The species
                # spreads it and lifts or hangs it.  A diary entry along a limb
                # faces away from the limb instead, and stays within its sleeve.
                axis = entry_axis.get(sec.id, np.array([bx, by, float(sec_pos[2])]))
                if sec.id in entry_leaf_r:
                    leaf_r = min(leaf_r, entry_leaf_r[sec.id])
                for cid, cpos in zip(
                    chunk_ids, section_cluster(n_c, sec_pos, axis, leaf_r, habit)
                ):
                    positions[cid] = cpos
                    book_chunk_pts.append(cpos)

            # Chunks without a section parent (connected directly to document)
            doc_chunk_ids: list[str] = []
            for doc in doc_nodes:
                doc_chunk_ids.extend(
                    cid
                    for cid in children.get(doc.id, [])
                    if node_by_id.get(cid)
                    and node_by_id[cid].kind == "chunk"
                    and cid not in positions
                )
            if doc_chunk_ids:
                raw_pts = fibonacci_sphere(
                    len(doc_chunk_ids) * 2, radius=self.leaf_radius, center=trunk_apex
                )
                upper_pts = [p for p in raw_pts if p[2] >= float(trunk_apex[2])]
                if len(upper_pts) < len(doc_chunk_ids):
                    upper_pts = raw_pts[: len(doc_chunk_ids)]
                for cid, cpos in zip(doc_chunk_ids, upper_pts[: len(doc_chunk_ids)]):
                    positions[cid] = np.array(cpos)
                    book_chunk_pts.append(positions[cid])

            if book_chunk_pts:
                self.book_chunks[slug] = book_chunk_pts

            # Entity / topic / keyword → loose cloud above canopy
            canopy_z = trunk_height + self.canopy_lift
            canopy_center = np.array([bx, by, canopy_z])
            floaters = [
                n
                for n in book_nodes
                if n.kind in ("entity", "topic", "keyword") and n.id not in positions
            ]
            if floaters:
                cloud_r = branch_length * 1.1 if n_branches else self.branch_radius
                cloud_pts = fibonacci_sphere(
                    len(floaters), radius=cloud_r, center=canopy_center
                )
                for n, pos in zip(floaters, cloud_pts):
                    positions[n.id] = np.array(pos)

    # Orphans (anything still unplaced)
    orphans = [n for n in nodes if n.id not in positions]
    if orphans:
        orphan_pts = fibonacci_sphere(len(orphans), radius=5.0, center=np.zeros(3))
        for n, pos in zip(orphans, orphan_pts):
            positions[n.id] = np.array(pos)

    return positions

SceneFilters(show_sections=True, show_chunks=True, show_entities=False, show_topics=False, show_contains=False, show_similar=False, show_next=False) dataclass

Which node kinds and edge relations the scene should draw.

Parameters:

Name Type Description Default
show_sections bool

Draw section (branch) nodes.

True
show_chunks bool

Draw chunk (leaf) nodes.

True
show_entities bool

Draw entity / keyword nodes — the gold spores.

False
show_topics bool

Draw topic nodes — the blue pollen cloud. Separate from show_entities because the two answer different questions: what the book names, versus what it is about.

False
show_contains bool

Draw CONTAINS structural edges.

False
show_similar bool

Draw SIMILAR_TO semantic arcs.

False
show_next bool

Draw NEXT sequential arcs.

False

visible_kinds()

Node kinds this filter set admits (document is always drawn).

Source code in src/gutenberg_kg/treegeom.py
934
935
936
937
938
939
940
941
942
943
944
945
def visible_kinds(self) -> set[str]:
    """Node kinds this filter set admits (``document`` is always drawn)."""
    kinds: set[str] = {"document"}
    if self.show_sections:
        kinds.add("section")
    if self.show_chunks:
        kinds.add("chunk")
    if self.show_entities:
        kinds.update(("entity", "keyword"))
    if self.show_topics:
        kinds.add("topic")
    return kinds

visible_rels()

Edge relations this filter set admits.

Source code in src/gutenberg_kg/treegeom.py
947
948
949
950
951
952
953
954
955
956
def visible_rels(self) -> set[str]:
    """Edge relations this filter set admits."""
    rels: set[str] = set()
    if self.show_contains:
        rels.add("CONTAINS")
    if self.show_similar:
        rels.add("SIMILAR_TO")
    if self.show_next:
        rels.add("NEXT")
    return rels

SceneInfo(title, actor_to_node=dict(), positions=dict(), layout=None, lod='high', counts=Counter(), n_books=0, skeleton=None, trunk_height=0.0) dataclass

What a built scene contains, for callers that need to interrogate it.

Parameters:

Name Type Description Default
title str

One-line stats banner (window title / CLI report).

required
actor_to_node dict[str, dict]

{"<kind>_<index>": node metadata} for picking.

dict()
positions dict[str, ndarray]

Node ID → [x, y, z].

dict()
layout ForestLayout | None

The :class:ForestLayout that produced positions.

None
lod str

LOD tier actually used — "high", "low", "points".

'high'
counts Counter

Rendered node count per kind.

Counter()
n_books int

Number of distinct book slugs in the scene.

0
skeleton Skeleton | None

Grown tree skeleton, set only by :func:build_tree_scene.

None
trunk_height float

Schematic trunk height of the hero tree, in scene units — a useful focal-plane height for framing.

0.0

Season(foliage, density=1.0, wood=WOOD_COLOR, sky=(SKY_BOTTOM, SKY_TOP)) dataclass

A seasonal look for the foliage, wood, and sky.

Parameters:

Name Type Description Default
foliage tuple[str, ...]

Leaf colours, sampled per leaf so a canopy varies the way a real one does rather than reading as one flat green.

required
density float

Fraction of chunks that keep a leaf. Winter drops most of them, which is the point — bare wood is where the pipe model shows.

1.0
wood str

Branch colour.

WOOD_COLOR
sky tuple[str, str]

Background gradient, (bottom, top).

(SKY_BOTTOM, SKY_TOP)

TreeGeometry(skeleton, layout, positions, crown, palette, leaf_points, leaf_tint, leaf_radius, spores, title, counts, trunk_height, species=DEFAULT_SPECIES) dataclass

Where a book's wood, leaves and spores go, with nothing drawn yet.

The placement half of :func:build_tree_scene, separated out so a second renderer can reach it. Everything here is plain NumPy and hex colour strings: no pv.Plotter, no meshes, no tessellation. PyVista turns it into actors; :mod:gutenberg_kg.povscene turns the same numbers into POV-Ray primitives, and because both start here the two backends cannot quietly grow different trees.

Parameters:

Name Type Description Default
skeleton Skeleton

Grown skeleton, radii already assigned.

required
layout ForestLayout

The :class:ForestLayout that placed the crown.

required
positions dict[str, ndarray]

Node ID → position, shifted so the tree stands at the origin.

required
crown ndarray

(M, 3) chunk positions the skeleton grew toward.

required
palette Season

The :class:Season in force.

required
leaf_points ndarray

(L, 3) chunks that kept a leaf — the whole crown except in winter, which thins it.

required
leaf_tint ndarray

(L,) index into palette.foliage, one per leaf.

required
leaf_radius float

Leaf radius after density scaling, in scene units.

required
spores dict[str, tuple[ndarray, float]]

{kind: (points (S, 3), radius)} for the halo kinds actually enabled.

required
title str

One-line stats banner.

required
counts Counter

Node count per kind.

required
trunk_height float

Schematic trunk height, a useful focal-plane height.

required
species str

The tree species (:data:GENRE_SPECIES), which picks the bark and leaf shape a renderer draws.

DEFAULT_SPECIES

leaf_colors property

Returns:

Type Description
list[str]

One hex colour per leaf, resolved through the palette.

arc_points(p1, p2, n_pts=24, lift=0.35)

Quadratic Bézier arc from p1 to p2, apex lifted in Z.

Parameters:

Name Type Description Default
p1 ndarray

Start point.

required
p2 ndarray

End point.

required
n_pts int

Sample count.

24
lift float

Z lift as fraction of chord length.

0.35

Returns:

Type Description
ndarray

(n_pts, 3) array.

Source code in src/gutenberg_kg/scene.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def arc_points(p1: np.ndarray, p2: np.ndarray, n_pts: int = 24, lift: float = 0.35) -> np.ndarray:
    """
    Quadratic Bézier arc from *p1* to *p2*, apex lifted in Z.

    :param p1: Start point.
    :param p2: End point.
    :param n_pts: Sample count.
    :param lift: Z lift as fraction of chord length.
    :return: ``(n_pts, 3)`` array.
    """
    p1, p2 = np.asarray(p1, float), np.asarray(p2, float)
    mid = (p1 + p2) / 2.0
    mid[2] += lift * np.linalg.norm(p2 - p1)
    t = np.linspace(0.0, 1.0, n_pts)[:, None]
    return (1 - t) ** 2 * p1 + 2 * t * (1 - t) * mid + t**2 * p2

bark_texture_path(species)

The species' bark colour map, shipped in gutenberg_kg/assets/bark/.

CC0 textures from ambientCG, the same files the Knowledge Press web forest uses; see that directory's CREDITS.md.

Parameters:

Name Type Description Default
species str

A key of :data:kg_utils.viz3d.SPECIES.

required

Returns:

Type Description
Path | None

The image path, or None if the species has no bark image.

Source code in src/gutenberg_kg/treegeom.py
221
222
223
224
225
226
227
228
229
230
231
232
def bark_texture_path(species: str) -> Path | None:
    """
    The species' bark colour map, shipped in ``gutenberg_kg/assets/bark/``.

    CC0 textures from ambientCG, the same files the Knowledge Press web
    forest uses; see that directory's ``CREDITS.md``.

    :param species: A key of :data:`kg_utils.viz3d.SPECIES`.
    :return: The image path, or ``None`` if the species has no bark image.
    """
    path = resources.files("gutenberg_kg") / "assets" / "bark" / f"{species}_color.jpg"
    return Path(str(path)) if path.is_file() else None

build_forest_scene(nodes, edges, plotter, *, book_genre_map=None, entry_times=None, layout=None, filters=None, lod=None, ground_size=1000.0, frame_camera=True, progress=None)

Compose the knowledge tree forest into plotter.

Draws the forest floor, per-book trunk cylinders, branch lines, node glyphs (LOD-aware, one draw call per kind), and the selected structural edges. Positions come from layout, computed over all nodes so the geometry is stable regardless of which kinds filters admits.

Parameters:

Name Type Description Default
nodes list[LayoutNode]

All loaded nodes (IDs namespaced by book slug).

required
edges list[LayoutEdge]

All loaded edges.

required
plotter Plotter

Any pv.Plotter — interactive QtInteractor or pv.Plotter(off_screen=True).

required
book_genre_map dict[str, str] | None

{book_slug: genre} for grove placement.

None
entry_times dict[str, str] | None

{document id: ISO timestamp} from :func:load_entry_times, merged across books.

None
layout ForestLayout | None

Layout instance to reuse; a default :class:ForestLayout is built when omitted.

None
filters SceneFilters | None

Node/edge visibility; defaults to sections + chunks.

None
lod str | None

Force an LOD tier; derived from visible node count when omitted.

None
ground_size float

Edge length of the ground plane in scene units; 0 omits the ground entirely (the light-field path wants this — an effectively infinite plane guarantees off-budget disparity at the horizon).

1000.0
frame_camera bool

Reset the camera to the isometric framing the interactive viewer expects. Pass False when the caller frames the shot itself, as the quilt renderer does.

True
progress Callable[[str], None] | None

Optional fn(message) progress callback.

None

Returns:

Type Description
SceneInfo

A :class:SceneInfo describing what was drawn.

Source code in src/gutenberg_kg/scene.py
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
def build_forest_scene(
    nodes: list[LayoutNode],
    edges: list[LayoutEdge],
    plotter: pv.Plotter,
    *,
    book_genre_map: dict[str, str] | None = None,
    entry_times: dict[str, str] | None = None,
    layout: ForestLayout | None = None,
    filters: SceneFilters | None = None,
    lod: str | None = None,
    ground_size: float = 1000.0,
    frame_camera: bool = True,
    progress: Callable[[str], None] | None = None,
) -> SceneInfo:
    """
    Compose the knowledge tree forest into *plotter*.

    Draws the forest floor, per-book trunk cylinders, branch lines, node
    glyphs (LOD-aware, one draw call per kind), and the selected structural
    edges.  Positions come from *layout*, computed over **all** *nodes* so the
    geometry is stable regardless of which kinds *filters* admits.

    :param nodes: All loaded nodes (IDs namespaced by book slug).
    :param edges: All loaded edges.
    :param plotter: Any ``pv.Plotter`` — interactive ``QtInteractor`` or
        ``pv.Plotter(off_screen=True)``.
    :param book_genre_map: ``{book_slug: genre}`` for grove placement.
    :param entry_times: ``{document id: ISO timestamp}`` from
        :func:`load_entry_times`, merged across books.
    :param layout: Layout instance to reuse; a default
        :class:`ForestLayout` is built when omitted.
    :param filters: Node/edge visibility; defaults to sections + chunks.
    :param lod: Force an LOD tier; derived from visible node count when
        omitted.
    :param ground_size: Edge length of the ground plane in scene units;
        ``0`` omits the ground entirely (the light-field path wants this —
        an effectively infinite plane guarantees off-budget disparity at the
        horizon).
    :param frame_camera: Reset the camera to the isometric framing the
        interactive viewer expects.  Pass ``False`` when the caller frames
        the shot itself, as the quilt renderer does.
    :param progress: Optional ``fn(message)`` progress callback.
    :return: A :class:`SceneInfo` describing what was drawn.
    """
    filters = filters or SceneFilters()
    report = progress or (lambda _msg: None)

    report("Building forest scene...")
    plotter.clear_actors()
    plotter.enable_anti_aliasing("msaa")
    plotter.set_background(SKY_BOTTOM, top=SKY_TOP)  # ty: ignore[invalid-argument-type]

    if ground_size > 0:
        ground = pv.Plane(
            center=(0, 0, -0.2), direction=(0, 0, 1), i_size=ground_size, j_size=ground_size
        )
        plotter.add_mesh(ground, color=GROUND_COLOR, opacity=1.0, name="ground")

    # -- Compute layout over the full node set
    if layout is None:
        layout = ForestLayout(book_genre_map=book_genre_map, entry_times=entry_times)
    else:
        if book_genre_map is not None:
            layout._book_genre_map = book_genre_map
        if entry_times is not None:
            layout.entry_times = entry_times
    all_positions = layout.compute(nodes, edges)

    # -- Draw trunk cylinders — single merged mesh with genre_idx scalar
    # One add_mesh call regardless of genre count keeps actor/texture count low.
    report("Drawing trunks...")
    _trunk_meshes: list[pv.PolyData] = []
    _genre_list_ord = sorted(layout.genre_color_map.keys())
    _genre_to_idx: dict[str, float] = {g: float(i) for i, g in enumerate(_genre_list_ord)}
    for doc_id, base in layout.trunk_positions.items():
        height = layout.trunk_heights.get(doc_id, 8.0)
        genre = layout.trunk_genres.get(doc_id, "unknown")
        cyl = pv.Cylinder(
            center=(base[0], base[1], height / 2),
            direction=(0, 0, 1),
            radius=0.4,
            height=height,
            resolution=12,
        )
        cyl.cell_data["genre_idx"] = np.full(cyl.n_cells, _genre_to_idx.get(genre, 0.0))
        _trunk_meshes.append(cyl)
    if _trunk_meshes:
        from matplotlib.colors import ListedColormap  # pyvista dependency, always present

        _combined_trunks = pv.merge(_trunk_meshes)
        _genre_colors = [layout.genre_color_map.get(g, TRUNK_COLOR) for g in _genre_list_ord]
        _trunk_cmap = ListedColormap(_genre_colors)
        _n_genres = max(1, len(_genre_list_ord))
        plotter.add_mesh(
            _combined_trunks,
            scalars="genre_idx",
            cmap=_trunk_cmap,
            clim=[-0.5, _n_genres - 0.5],
            show_scalar_bar=False,
            name="trunks",
        )

    # -- Branch lines: trunk-axis point → section tip (flat numpy, zero per-line objects)
    if layout.branch_lines:
        _n_bl = len(layout.branch_lines)
        _bl_pts = np.empty((_n_bl * 2, 3), dtype=float)
        _bl_pts[0::2] = [bl[0] for bl in layout.branch_lines]
        _bl_pts[1::2] = [bl[1] for bl in layout.branch_lines]
        _bl_cells = np.empty(_n_bl * 3, dtype=np.intp)
        _bl_cells[0::3] = 2
        _bl_cells[1::3] = np.arange(0, _n_bl * 2, 2)
        _bl_cells[2::3] = np.arange(1, _n_bl * 2 + 1, 2)
        _branch_mesh = pv.PolyData()
        _branch_mesh.points = _bl_pts
        _branch_mesh.lines = _bl_cells
        plotter.add_mesh(
            _branch_mesh, color=BRANCH_COLOR, line_width=2.0, opacity=0.85, name="branches"
        )

    # -- Visible node set and LOD tier
    # DiaryKG entry documents occupy branch stations, so they filter and draw
    # as sections rather than as one trunk marker per dated entry.
    promoted = layout.branch_documents

    def effective_kind(node: LayoutNode) -> str:
        return "section" if node.id in promoted else node.kind

    visible_kinds = filters.visible_kinds()
    visible = [n for n in nodes if effective_kind(n) in visible_kinds]
    n_visible = len(visible)
    if lod is None:
        lod = "high" if n_visible <= LOD_HIGH else "low" if n_visible <= LOD_LOW else "points"

    # -- Glyph rendering: O(kinds) Python work, not O(nodes)
    # Bucket positions and metadata by kind in one pass, then glyph each kind.
    kind_pts: dict[str, list[np.ndarray]] = {k: [] for k in KIND_SIZE}
    kind_meta: dict[str, list] = {k: [] for k in KIND_SIZE}
    node_id_set: set[str] = set()

    for node in visible:
        pos = all_positions.get(node.id)
        if pos is None:
            continue
        ekind = effective_kind(node)
        kind = ekind if ekind in KIND_SIZE else "chunk"
        kind_pts[kind].append(pos)
        kind_meta[kind].append(node)
        node_id_set.add(node.id)

    actor_to_node: dict[str, dict] = {}
    report("Rendering nodes...")

    for kind in KIND_SIZE:
        pts = kind_pts[kind]
        if not pts:
            continue
        arr = np.array(pts, dtype=float)
        cloud = pv.PolyData(arr)
        proto = glyph_proto(kind, KIND_SIZE[kind], lod)
        glyphed = cloud.glyph(geom=proto, orient=False, scale=False)
        plotter.add_mesh(glyphed, color=KIND_COLOR[kind], show_edges=False, name=f"{kind}_nodes")
        for i, (node, pos) in enumerate(zip(kind_meta[kind], pts)):
            actor_to_node[f"{kind}_{i}"] = {
                "kind": kind,
                "id": node.id,
                "name": node.name,
                "docstring": node.docstring,
                "position": np.asarray(pos, float),
            }

    # -- Edge rendering (CONTAINS structural lines + optional SIMILAR_TO arcs)
    report("Rendering edges...")

    rel_to_show = filters.visible_rels()
    rel_blocks: dict[str, pv.MultiBlock] = {r: pv.MultiBlock() for r in rel_to_show}
    edge_counts: dict[str, int] = {r: 0 for r in rel_to_show}

    for edge in edges:
        if edge.rel not in rel_to_show:
            continue
        if edge.src not in node_id_set or edge.dst not in node_id_set:
            continue
        if edge_counts[edge.rel] >= MAX_EDGES:
            continue
        p1, p2 = all_positions.get(edge.src), all_positions.get(edge.dst)
        if p1 is None or p2 is None:
            continue
        if edge.rel == "CONTAINS":
            rel_blocks["CONTAINS"].append(pv.Line(p1, p2))
        else:
            rel_blocks[edge.rel].append(pv.Spline(arc_points(p1, p2), n_points=24))
        edge_counts[edge.rel] += 1

    for rel, block in rel_blocks.items():
        if block.n_blocks > 0:
            is_contains = rel == "CONTAINS"
            plotter.add_mesh(
                block,
                color=REL_COLOR[rel],
                line_width=3.0 if is_contains else 2.0,
                opacity=0.45 if is_contains else 0.8,
                name=f"{rel.lower()}_edges",
            )

    # -- Stats
    counts = Counter(effective_kind(n) for n in visible)
    n_books = len({n.id.split(":")[0] for n in nodes})
    title = (
        f"Gutenberg KG Forest | {n_books} books | "
        f"docs={counts.get('document', 0)}  "
        f"sections={counts.get('section', 0)}  "
        f"chunks={counts.get('chunk', 0)}  "
        f"entities={counts.get('entity', 0)}"
    )

    if frame_camera:
        plotter.reset_camera()  # ty: ignore[missing-argument]
        plotter.view_isometric()  # ty: ignore[missing-argument]
        plotter.render()
        plotter.camera.zoom(2)

    report("Forest rendered.")
    return SceneInfo(
        title=title,
        actor_to_node=actor_to_node,
        positions=all_positions,
        layout=layout,
        lod=lod,
        counts=counts,
        n_books=n_books,
    )

build_tree_scene(nodes, edges, plotter, *, slug, genre='unknown', entry_times=None, filters=None, season=DEFAULT_SEASON, tip_radius=0.05, leaf_size=0.32, ground_size=0.0, species_look=True, progress=None)

Compose a single book as an organically grown tree.

The schematic layout places the crown; space colonization then grows a skeleton to reach it, so every limb is a real structural path and the canopy's shape is the book's shape. This is the Tier-1 light-field subject: one tree, standing at the origin, framed by the caller.

Parameters:

Name Type Description Default
nodes list[LayoutNode]

Nodes of one book (IDs namespaced by slug).

required
edges list[LayoutEdge]

Edges of the same book.

required
plotter Plotter

Off-screen or interactive plotter.

required
slug str

Book slug; seeds growth so the tree is reproducible.

required
genre str

Genre name, which selects the tropism silhouette.

'unknown'
entry_times dict[str, str] | None

{document id: ISO timestamp} from :func:load_entry_times; grows a dated book's limbs as calendar years.

None
filters SceneFilters | None

Only show_entities (gold spores) and show_topics (blue pollen) are consulted. Wood and leaves are the point of this scene.

None
season str

Key into :data:SEASONS — foliage palette, leaf density, wood tone, and sky.

DEFAULT_SEASON
tip_radius float

Radius of leaf-bearing twigs, in scene units.

0.05
leaf_size float

Leaf glyph radius.

0.32
ground_size float

Ground plane edge length; 0 (the default here) omits it, since an effectively infinite plane guarantees off-budget disparity at the horizon.

0.0
species_look bool

Draw the wood as one continuous sweep textured with the species' bark (:func:kg_utils.viz3d.bark_mesh) and the leaves in the species' outline (:mod:gutenberg_kg.leafshapes). False keeps the plain look: tubes in the season's wood colour and ellipsoid leaves.

True
progress Callable[[str], None] | None

Optional fn(message) progress callback.

None

Returns:

Type Description
SceneInfo

:class:SceneInfo, with layout set and the grown skeleton reachable through :attr:SceneInfo.skeleton.

Source code in src/gutenberg_kg/scene.py
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
def build_tree_scene(
    nodes: list[LayoutNode],
    edges: list[LayoutEdge],
    plotter: pv.Plotter,
    *,
    slug: str,
    genre: str = "unknown",
    entry_times: dict[str, str] | None = None,
    filters: SceneFilters | None = None,
    season: str = DEFAULT_SEASON,
    tip_radius: float = 0.05,
    leaf_size: float = 0.32,
    ground_size: float = 0.0,
    species_look: bool = True,
    progress: Callable[[str], None] | None = None,
) -> SceneInfo:
    """
    Compose a single book as an organically grown tree.

    The schematic layout places the crown; space colonization then grows a
    skeleton *to reach it*, so every limb is a real structural path and the
    canopy's shape is the book's shape.  This is the Tier-1 light-field
    subject: one tree, standing at the origin, framed by the caller.

    :param nodes: Nodes of one book (IDs namespaced by *slug*).
    :param edges: Edges of the same book.
    :param plotter: Off-screen or interactive plotter.
    :param slug: Book slug; seeds growth so the tree is reproducible.
    :param genre: Genre name, which selects the tropism silhouette.
    :param entry_times: ``{document id: ISO timestamp}`` from
        :func:`load_entry_times`; grows a dated book's limbs as calendar years.
    :param filters: Only ``show_entities`` (gold spores) and ``show_topics``
        (blue pollen) are consulted.  Wood and leaves are the point of this
        scene.
    :param season: Key into :data:`SEASONS` — foliage palette, leaf density,
        wood tone, and sky.
    :param tip_radius: Radius of leaf-bearing twigs, in scene units.
    :param leaf_size: Leaf glyph radius.
    :param ground_size: Ground plane edge length; ``0`` (the default here)
        omits it, since an effectively infinite plane guarantees off-budget
        disparity at the horizon.
    :param species_look: Draw the wood as one continuous sweep textured with
        the species' bark (:func:`kg_utils.viz3d.bark_mesh`) and the leaves in
        the species' outline (:mod:`gutenberg_kg.leafshapes`).  ``False``
        keeps the plain look: tubes in the season's wood colour and
        ellipsoid leaves.
    :param progress: Optional ``fn(message)`` progress callback.
    :return: :class:`SceneInfo`, with ``layout`` set and the grown skeleton
        reachable through :attr:`SceneInfo.skeleton`.
    """
    report = progress or (lambda _msg: None)
    tree = grow_tree_geometry(
        nodes,
        edges,
        slug=slug,
        genre=genre,
        entry_times=entry_times,
        filters=filters,
        season=season,
        tip_radius=tip_radius,
        leaf_size=leaf_size,
        progress=progress,
    )
    palette, skeleton = tree.palette, tree.skeleton

    plotter.clear_actors()
    plotter.enable_anti_aliasing("msaa")
    plotter.set_background(palette.sky[0], top=palette.sky[1])  # ty: ignore[invalid-argument-type]

    report(f"Sweeping {skeleton.n_nodes:,} skeleton nodes into wood...")
    bark_path = bark_texture_path(tree.species) if species_look else None
    if bark_path is not None:
        # One continuous sweep with UVs, so the species' bark wraps each limb
        # in whole tiles and keeps the image's own aspect along it.
        bark = pv.read_texture(str(bark_path))
        width, height = bark.dimensions[:2]
        wood = bark_mesh(skeleton, aspect=height / width, tile=BARK_TILE)
        if wood.n_points:
            plotter.add_mesh(wood, texture=bark, smooth_shading=True, name="wood")
    else:
        wood = tree_mesh(skeleton)
        if wood.n_points:
            plotter.add_mesh(wood, color=palette.wood, smooth_shading=True, name="wood")

    report(f"Placing leaves ({season})...")
    leaf_seed = seed_from_key(slug + ":leaves")
    if species_look:
        leaves = species_leaf_glyphs(
            tree.leaf_points,
            skeleton,
            species=tree.species,
            size=tree.leaf_radius,
            tint=tree.leaf_tint,
            n_tints=len(palette.foliage),
        )
    else:
        leaves = leaf_glyphs(
            tree.leaf_points,
            skeleton,
            size=tree.leaf_radius,
            tint=tree.leaf_tint,
            seed=leaf_seed,
        )
    if leaves.n_points:
        from matplotlib.colors import ListedColormap  # pyvista dependency, always present

        plotter.add_mesh(
            leaves,
            scalars="tint",
            cmap=ListedColormap(list(palette.foliage)),
            clim=[-0.5, len(palette.foliage) - 0.5],
            show_scalar_bar=False,
            name="leaves",
        )

    for kind, (spore_pts, spore_size) in tree.spores.items():
        spores = pv.PolyData(spore_pts).glyph(
            geom=pv.Tetrahedron(radius=spore_size), orient=False, scale=False
        )
        plotter.add_mesh(
            spores, color=KIND_COLOR[kind], opacity=SPORE_OPACITY, name=f"{kind}-spores"
        )

    if ground_size > 0:
        ground = pv.Plane(
            center=(0, 0, -0.2), direction=(0, 0, 1), i_size=ground_size, j_size=ground_size
        )
        plotter.add_mesh(ground, color=GROUND_COLOR, name="ground")

    report("Tree grown.")
    return SceneInfo(
        title=tree.title,
        positions=tree.positions,
        layout=tree.layout,
        counts=tree.counts,
        n_books=1,
        skeleton=skeleton,
        trunk_height=tree.trunk_height,
    )

glyph_proto(kind, size, lod)

Return a glyph prototype mesh centred at the origin for kind nodes.

Used with pv.PolyData.glyph() so all nodes of one kind are rendered in a single VTK draw call instead of one mesh per node.

Parameters:

Name Type Description Default
kind str

Node kind string.

required
size float

Node radius.

required
lod str

LOD tier — "high", "low", or "points".

required

Returns:

Type Description
PolyData

PyVista PolyData centred at origin.

Source code in src/gutenberg_kg/scene.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def glyph_proto(kind: str, size: float, lod: str) -> pv.PolyData:
    """Return a glyph prototype mesh centred at the origin for *kind* nodes.

    Used with ``pv.PolyData.glyph()`` so all nodes of one kind are rendered
    in a single VTK draw call instead of one mesh per node.

    :param kind: Node kind string.
    :param size: Node radius.
    :param lod: LOD tier — ``"high"``, ``"low"``, or ``"points"``.
    :return: PyVista PolyData centred at origin.
    """
    if lod == "points":
        return pv.Tetrahedron(radius=size * 0.5)
    if lod == "low":
        if kind in ("chunk", "entity", "keyword", "topic"):
            return pv.Octahedron(radius=size * 0.8)
        return pv.Icosahedron(radius=size * 0.8)
    return pv.Icosahedron(radius=size)

grow_tree_geometry(nodes, edges, *, slug, genre='unknown', entry_times=None, filters=None, season=DEFAULT_SEASON, tip_radius=0.05, leaf_size=0.32, progress=None)

Lay out one book and grow a skeleton to reach it, without drawing anything.

The schematic layout places the crown; space colonization then grows a skeleton to reach it, so every limb is a real structural path and the canopy's shape is the book's shape.

Parameters:

Name Type Description Default
nodes list[LayoutNode]

Nodes of one book (IDs namespaced by slug).

required
edges list[LayoutEdge]

Edges of the same book.

required
slug str

Book slug; seeds growth so the tree is reproducible.

required
genre str

Genre name, which selects the tree species (:data:GENRE_SPECIES) and so the crown's envelope and growth habit.

'unknown'
entry_times dict[str, str] | None

{document id: ISO timestamp} from :func:load_entry_times; grows a dated book's limbs as calendar years.

None
filters SceneFilters | None

Only show_entities and show_topics are consulted; they decide which spore halos are placed at all.

None
season str

Key into :data:SEASONS.

DEFAULT_SEASON
tip_radius float

Radius of leaf-bearing twigs, in scene units.

0.05
leaf_size float

Leaf radius before density scaling.

0.32
progress Callable[[str], None] | None

Optional fn(message) progress callback.

None

Returns:

Type Description
TreeGeometry

The placed :class:TreeGeometry.

Raises:

Type Description
ValueError

If season is unknown, or the book has no chunks to grow toward.

Source code in src/gutenberg_kg/treegeom.py
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
def grow_tree_geometry(
    nodes: list[LayoutNode],
    edges: list[LayoutEdge],
    *,
    slug: str,
    genre: str = "unknown",
    entry_times: dict[str, str] | None = None,
    filters: SceneFilters | None = None,
    season: str = DEFAULT_SEASON,
    tip_radius: float = 0.05,
    leaf_size: float = 0.32,
    progress: Callable[[str], None] | None = None,
) -> TreeGeometry:
    """
    Lay out one book and grow a skeleton to reach it, without drawing anything.

    The schematic layout places the crown; space colonization then grows a
    skeleton *to reach it*, so every limb is a real structural path and the
    canopy's shape is the book's shape.

    :param nodes: Nodes of one book (IDs namespaced by *slug*).
    :param edges: Edges of the same book.
    :param slug: Book slug; seeds growth so the tree is reproducible.
    :param genre: Genre name, which selects the tree species
        (:data:`GENRE_SPECIES`) and so the crown's envelope and growth habit.
    :param entry_times: ``{document id: ISO timestamp}`` from
        :func:`load_entry_times`; grows a dated book's limbs as calendar years.
    :param filters: Only ``show_entities`` and ``show_topics`` are consulted;
        they decide which spore halos are placed at all.
    :param season: Key into :data:`SEASONS`.
    :param tip_radius: Radius of leaf-bearing twigs, in scene units.
    :param leaf_size: Leaf radius before density scaling.
    :param progress: Optional ``fn(message)`` progress callback.
    :return: The placed :class:`TreeGeometry`.
    :raises ValueError: If *season* is unknown, or the book has no chunks to
        grow toward.
    """
    filters = filters or SceneFilters()
    report = progress or (lambda _msg: None)
    if season not in SEASONS:
        raise ValueError(f"Unknown season {season!r}; choose from {', '.join(SEASONS)}")
    palette = SEASONS[season]

    report("Laying out crown...")
    layout = ForestLayout(book_genre_map={slug: genre}, entry_times=entry_times)
    positions = layout.compute(nodes, edges)
    for label, count in layout.book_periods.get(slug, [])[:1]:
        report(f"{len(layout.book_periods[slug])} period limbs (first: {label}, {count} entries)")

    base, trunk_height = layout.book_trunks.get(slug, (np.zeros(3), 10.0))
    # Stand the tree at the origin: a hero shot frames one subject, and the
    # grove annulus would otherwise park it hundreds of units off-axis.
    shift = np.array([base[0], base[1], 0.0])
    positions = {nid: p - shift for nid, p in positions.items()}
    root = np.zeros(3)

    crown = np.asarray([positions[n.id] for n in nodes if n.kind == "chunk" and n.id in positions])
    if crown.size == 0:
        raise ValueError(f"{slug}: no chunk positions to grow toward")

    species = species_for(genre)
    habit = layout.book_habits.get(slug) or book_habit(slug, genre)
    report(f"Growing a {species} toward {len(crown):,} chunks...")
    skeleton = grow_tree(crown, root, key=slug, tip_radius=tip_radius, habit=habit)
    # Droop carries each chunk with its twig: leaves and chunk nodes follow.
    if skeleton.crown is not None:
        crown = skeleton.crown
        chunk_ids = [n.id for n in nodes if n.kind == "chunk" and n.id in positions]
        positions.update(zip(chunk_ids, crown))
    if skeleton.attractors_used < skeleton.attractors_total:
        report(
            f"Grew toward {skeleton.attractors_used:,} of {skeleton.attractors_total:,} "
            f"chunks (crown subsampled); every chunk still gets a leaf."
        )

    # A fixed leaf size tiles a dense crown into an opaque shell that hides the
    # wood entirely.  Shrink with count so a 19,000-chunk diary reads as foliage
    # with branches visible through it, the way a canopy actually does.
    leaf_scale = min(1.0, (LEAF_REFERENCE_COUNT / max(len(crown), 1)) ** (1.0 / 3.0))
    leaf_rng = np.random.default_rng(seed_from_key(f"{slug}:{season}"))
    kept = crown
    if palette.density < 1.0:
        n_kept = max(1, int(round(len(crown) * palette.density)))
        kept = crown[leaf_rng.choice(len(crown), size=n_kept, replace=False)]
    tint = leaf_rng.integers(0, len(palette.foliage), len(kept)).astype(float)
    leaf_radius = leaf_size * leaf_scale

    # Spores: bright off-plane points, the best depth cue in the scene and the
    # hook for query illumination later.  The schematic layout parks them in a
    # tight ball above the canopy, which at tree scale reads as a golf ball on
    # a stick; here they surround the crown as a halo.
    #
    # Entities and topics get their own halo, since one gold cloud conflated
    # what a book *names* with what it is *about* and wasted the blue the topic
    # kind already carried.  Topics sit further out so the two stay separable.
    #
    # Both are capped at SPORE_CAP.  Drawing one glyph per node cannot work at
    # corpus scale — Pepys has 7,065 entities and 7,287 topics against 18,757
    # leaves, so the halo simply buries the tree it is meant to annotate.  A
    # halo reads as a cloud, not as a count, so a deterministic sample carries
    # the same meaning at a fraction of the ink.
    spores: dict[str, tuple[np.ndarray, float]] = {}
    for kind, enabled, spread in (
        ("entity", filters.show_entities, 1.0),
        ("topic", filters.show_topics, 1.3),
    ):
        if not enabled:
            continue
        n_spores = sum(1 for n in nodes if n.kind == kind)
        if not n_spores:
            continue
        n_drawn = min(n_spores, SPORE_CAP)
        spore_pts = _crown_halo(
            n_drawn, crown * spread, seed=seed_from_key(f"{slug}:{kind}-spores")
        )
        # Sized against the leaves rather than against their own count, so the
        # halo always reads as finer than the foliage it surrounds.
        spore_size = leaf_radius * SPORE_LEAF_RATIO * (KIND_SIZE[kind] / KIND_SIZE["entity"])
        spores[kind] = (spore_pts, spore_size)

    counts = Counter(n.kind for n in nodes)
    trunk_r = float(skeleton.radii[0]) if skeleton.radii is not None else 0.0
    title = (
        f"{slug} | {genre} ({species}) | chunks={counts.get('chunk', 0)}  "
        f"limbs={skeleton.n_nodes}  trunk r={trunk_r:.2f}"
    )
    return TreeGeometry(
        skeleton=skeleton,
        layout=layout,
        positions=positions,
        crown=crown,
        palette=palette,
        leaf_points=kept,
        leaf_tint=tint,
        leaf_radius=leaf_radius,
        spores=spores,
        title=title,
        counts=counts,
        trunk_height=float(trunk_height),
        species=species,
    )

load_book_graph(meta)

Load nodes and edges from a book's DocKG SQLite, prefixing all IDs with the book slug to prevent collisions when merging multiple books.

Parameters:

Name Type Description Default
meta BookMeta

Book metadata including slug and db_path.

required

Returns:

Type Description
tuple[list[LayoutNode], list[LayoutEdge]]

(nodes, edges) with namespaced IDs.

Source code in src/gutenberg_kg/bookgraph.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def load_book_graph(meta: BookMeta) -> tuple[list[LayoutNode], list[LayoutEdge]]:
    """
    Load nodes and edges from a book's DocKG SQLite, prefixing all IDs with
    the book slug to prevent collisions when merging multiple books.

    :param meta: Book metadata including slug and db_path.
    :return: ``(nodes, edges)`` with namespaced IDs.
    """
    prefix = f"{meta.slug}:"
    nodes: list[LayoutNode] = []
    edges: list[LayoutEdge] = []

    with sqlite3.connect(meta.db_path) as con:
        for row in con.execute("SELECT id, kind, name, title, file_path, text FROM nodes"):
            nid, kind, name, title, file_path, text = row
            display_name = title or name or nid
            docstring = text[:500] if text else None
            nodes.append(
                LayoutNode(
                    id=prefix + nid,
                    kind=kind,
                    name=display_name,
                    module_path=file_path,
                    docstring=docstring,
                )
            )
        for row in con.execute("SELECT src, rel, dst FROM edges"):
            src, rel, dst = row
            edges.append(LayoutEdge(src=prefix + src, rel=rel, dst=prefix + dst))

    return nodes, edges

load_entry_times(meta)

Earliest chunk timestamp per document, for books that carry dates.

DiaryKG stamps every chunk with the entry's date but leaves the document row's timestamp null, so the entry's date has to come up through its chunks. The result lets :class:ForestLayout grow a diary's limbs from its chronology — one limb per year — rather than from position in the file.

Parameters:

Name Type Description Default
meta BookMeta

Book metadata.

required

Returns:

Type Description
dict[str, str]

{namespaced document id: ISO timestamp}; empty for books whose graph carries no timestamps at all.

Source code in src/gutenberg_kg/bookgraph.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def load_entry_times(meta: BookMeta) -> dict[str, str]:
    """
    Earliest chunk timestamp per document, for books that carry dates.

    DiaryKG stamps every chunk with the entry's date but leaves the document
    row's ``timestamp`` null, so the entry's date has to come up through its
    chunks.  The result lets :class:`ForestLayout` grow a diary's limbs from
    its chronology — one limb per year — rather than from position in the file.

    :param meta: Book metadata.
    :return: ``{namespaced document id: ISO timestamp}``; empty for books
        whose graph carries no timestamps at all.
    """
    prefix = f"{meta.slug}:"
    with sqlite3.connect(meta.db_path) as con:
        try:
            rows = con.execute(
                "SELECT e.src, MIN(n.timestamp) FROM edges e "
                "JOIN nodes n ON n.id = e.dst "
                "WHERE e.rel = 'CONTAINS' AND n.kind = 'chunk' AND n.timestamp IS NOT NULL "
                "GROUP BY e.src"
            ).fetchall()
        except sqlite3.OperationalError:
            # DocKG graphs predate the timestamp column; they simply have no dates.
            return {}
    return {prefix + src: ts for src, ts in rows if ts}

make_node_mesh(kind, center, size, lod)

Return a PyVista mesh for a single node, shape-coded by kind.

All tiers use icosahedra — fast to build, good shading, no sphere overhead. LOD only varies the radius scale: - high → full size - low → 80 % size (octahedron for chunks to save faces) - points → tetrahedron (4 faces, minimal cost)

Parameters:

Name Type Description Default
kind str

Node kind string.

required
center ndarray

3-D centre position.

required
size float

Node radius.

required
lod str

LOD tier — "high", "low", or "points".

required

Returns:

Type Description
DataSet

PyVista PolyData mesh.

Source code in src/gutenberg_kg/scene.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def make_node_mesh(kind: str, center: np.ndarray, size: float, lod: str) -> pv.DataSet:
    """
    Return a PyVista mesh for a single node, shape-coded by kind.

    All tiers use icosahedra — fast to build, good shading, no sphere overhead.
    LOD only varies the radius scale:
    - high  → full size
    - low   → 80 % size (octahedron for chunks to save faces)
    - points → tetrahedron (4 faces, minimal cost)

    :param kind: Node kind string.
    :param center: 3-D centre position.
    :param size: Node radius.
    :param lod: LOD tier — ``"high"``, ``"low"``, or ``"points"``.
    :return: PyVista PolyData mesh.
    """
    if lod == "points":
        return pv.Tetrahedron(radius=size * 0.5, center=center)
    if lod == "low":
        if kind in ("chunk", "entity", "keyword", "topic"):
            return pv.Octahedron(radius=size * 0.8, center=center)
        return pv.Icosahedron(radius=size * 0.8, center=center)
    # high — full icosahedra for everything
    return pv.Icosahedron(radius=size, center=center)

scan_corpus(corpus_root)

Walk corpus_root and return {genre: [BookMeta, ...]} for every book directory that carries a graph in one of :data:KG_DIRS.

Parameters:

Name Type Description Default
corpus_root Path

Root of the corpus directory tree.

required

Returns:

Type Description
dict[str, list[BookMeta]]

Dict mapping genre name to list of book metadata objects.

Source code in src/gutenberg_kg/bookgraph.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def scan_corpus(corpus_root: Path) -> dict[str, list[BookMeta]]:
    """
    Walk ``corpus_root`` and return ``{genre: [BookMeta, ...]}`` for every
    book directory that carries a graph in one of :data:`KG_DIRS`.

    :param corpus_root: Root of the corpus directory tree.
    :return: Dict mapping genre name to list of book metadata objects.
    """
    result: dict[str, list[BookMeta]] = defaultdict(list)
    for genre_dir in sorted(corpus_root.iterdir()):
        if not genre_dir.is_dir() or genre_dir.name.startswith("."):
            continue
        genre = genre_dir.name
        for book_dir in sorted(genre_dir.iterdir()):
            if not book_dir.is_dir() or book_dir.name.startswith("."):
                continue
            meta = BookMeta(
                title=book_dir.name,
                genre=genre,
                book_dir=book_dir,
            )
            if meta.has_kg:
                result[genre].append(meta)
    return dict(result)

species_leaf_glyphs(positions, skeleton, *, species, size, tint=None, n_tints=1)

Leaves in the species' own outline, hung as the Knowledge Press web forest hangs them.

Each leaf's frame comes from :func:~gutenberg_kg.treegeom.species_leaf_frames (stalk on its nearest twig, blade out and lifted, face to the sky; the web's size and jitter), the same frames the POV-Ray path instances. The outline (:func:gutenberg_kg.leafshapes.leaf_outline) is placed in each frame directly rather than glyphed, because a glyph call aims one axis and leaves the roll to chance.

Parameters:

Name Type Description Default
positions ndarray

(M, 3) leaf positions.

required
skeleton Skeleton

Grown skeleton.

required
species str

Species name; picks the outline.

required
size float

Leaf size after density scaling (TreeGeometry.leaf_radius).

required
tint ndarray | None

Optional (M,) palette index per leaf, carried as "tint" and setting the size jitter.

None
n_tints int

Palette length the tints index into.

1

Returns:

Type Description
PolyData

One PolyData of every leaf.

Source code in src/gutenberg_kg/scene.py
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
def species_leaf_glyphs(
    positions: np.ndarray,
    skeleton: Skeleton,
    *,
    species: str,
    size: float,
    tint: np.ndarray | None = None,
    n_tints: int = 1,
) -> pv.PolyData:
    """
    Leaves in the species' own outline, hung as the Knowledge Press web forest hangs them.

    Each leaf's frame comes from :func:`~gutenberg_kg.treegeom.species_leaf_frames`
    (stalk on its nearest twig, blade out and lifted, face to the sky; the web's
    size and jitter), the same frames the POV-Ray path instances.  The outline
    (:func:`gutenberg_kg.leafshapes.leaf_outline`) is placed in each frame
    directly rather than glyphed, because a glyph call aims one axis and leaves
    the roll to chance.

    :param positions: ``(M, 3)`` leaf positions.
    :param skeleton: Grown skeleton.
    :param species: Species name; picks the outline.
    :param size: Leaf size after density scaling (``TreeGeometry.leaf_radius``).
    :param tint: Optional ``(M,)`` palette index per leaf, carried as ``"tint"``
        and setting the size jitter.
    :param n_tints: Palette length the tints index into.
    :return: One ``PolyData`` of every leaf.
    """
    centres, half, across, blades, _ = species_leaf_frames(
        positions, skeleton, size=size, tint=tint, n_tints=n_tints
    )
    if not len(centres):
        return pv.PolyData()
    outline = leaf_outline(LEAF_SHAPES.get(species, LEAF_SHAPES["chestnut"]))
    k = len(outline)
    # Ear-clip the outline once; it adds no points, so its triangles index the
    # outline and repeat at every leaf.
    flat = np.column_stack([outline, np.zeros(k)])
    triangles = (
        pv.PolyData(flat, np.concatenate([[k], np.arange(k)])).triangulate().faces.reshape(-1, 4)
    )
    ox, oy = outline[:, 0], outline[:, 1]
    points = centres[:, None, :] + half[:, None, None] * (
        ox[None, :, None] * across[:, None, :] + oy[None, :, None] * blades[:, None, :]
    )
    offsets = (np.arange(len(centres)) * k)[:, None, None]
    cells = triangles[None, :, 1:] + offsets
    faces = np.concatenate([np.full(cells.shape[:2] + (1,), 3), cells], axis=2).ravel()
    leaves = pv.PolyData(points.reshape(-1, 3), faces)
    if tint is not None:
        leaves["tint"] = np.repeat(np.asarray(tint, dtype=float), k)
    return leaves