Skip to content

Tree geometry

gutenberg_kg.treegeom

treegeom.py — where a book's tree goes, with nothing drawn yet.

The PyVista-free half of the scene layer. Corpus geometry is pure NumPy: the schematic :class:ForestLayout that places a crown, the space-colonization growth that reaches it, the foliage seasons, and the halo scatter. None of it needs VTK, and hoisting it out of :mod:gutenberg_kg.scene is what lets a second renderer exist — :mod:gutenberg_kg.povscene composes the same tree as analytic POV-Ray primitives without a plotter, an OpenGL context, or an installed PyVista.

:mod:gutenberg_kg.scene re-exports every public name here, so from gutenberg_kg.scene import ForestLayout keeps working; new code should import from whichever module matches what it needs, geometry or rendering.

Author: Eric G. Suchanek, PhD

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

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.

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

book_habit(slug, genre)

One book's growth habit: its genre's species, nudged per book so a grove is not a row of clones. Deterministic in slug.

Parameters:

Name Type Description Default
slug str

Book slug.

required
genre str

Genre name.

required

Returns:

Type Description
Habit

The varied :class:~kg_utils.viz3d.Habit.

Source code in src/gutenberg_kg/treegeom.py
235
236
237
238
239
240
241
242
243
244
def book_habit(slug: str, genre: str) -> Habit:
    """
    One book's growth habit: its genre's species, nudged per book so a grove
    is not a row of clones.  Deterministic in *slug*.

    :param slug: Book slug.
    :param genre: Genre name.
    :return: The varied :class:`~kg_utils.viz3d.Habit`.
    """
    return vary_habit(SPECIES[species_for(genre)], slug)

diary_periods(nodes, edges, entry_times=None, bins=PERIOD_BINS)

A diary's period limbs, compact enough to ship to the web forest.

Groups entries exactly as :meth:ForestLayout.compute does (calendar years when dated, equal runs of file order otherwise) and counts each period's chunks into bins equal slices of its limb, by each entry's fraction along it. The web forest rebuilds the same limbs from this without the graph.

Parameters:

Name Type Description Default
nodes list[LayoutNode]

Nodes of one book.

required
edges list[LayoutEdge]

Edges of the same book.

required
entry_times dict[str, str] | None

{document id: ISO timestamp} from load_entry_times.

None
bins int

Slices per limb.

PERIOD_BINS

Returns:

Type Description
list[dict[str, object]]

[{"label", "entries", "bins"}] earliest first, with trailing empty bins dropped; empty for a book that is not entry-structured.

Source code in src/gutenberg_kg/treegeom.py
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
def diary_periods(
    nodes: list[LayoutNode],
    edges: list[LayoutEdge],
    entry_times: dict[str, str] | None = None,
    bins: int = PERIOD_BINS,
) -> list[dict[str, object]]:
    """
    A diary's period limbs, compact enough to ship to the web forest.

    Groups entries exactly as :meth:`ForestLayout.compute` does (calendar years
    when dated, equal runs of file order otherwise) and counts each period's
    chunks into *bins* equal slices of its limb, by each entry's fraction along
    it.  The web forest rebuilds the same limbs from this without the graph.

    :param nodes: Nodes of one book.
    :param edges: Edges of the same book.
    :param entry_times: ``{document id: ISO timestamp}`` from ``load_entry_times``.
    :param bins: Slices per limb.
    :return: ``[{"label", "entries", "bins"}]`` earliest first, with trailing
        empty bins dropped; empty for a book that is not entry-structured.
    """
    docs = [n for n in nodes if n.kind == "document"]
    if any(n.kind == "section" for n in nodes) or len(docs) < 2:
        return []
    kinds = {n.id: n.kind for n in nodes}
    chunks: Counter[str] = Counter(
        e.src for e in edges if e.rel == "CONTAINS" and kinds.get(e.dst) == "chunk"
    )
    layout = ForestLayout(entry_times=entry_times)
    periods: list[dict[str, object]] = []
    for label, members in layout._period_groups(docs):
        counts = [0] * bins
        for node, fraction in zip(members, layout._entry_fractions(members, label.isdigit())):
            counts[min(int(fraction * bins), bins - 1)] += chunks[node.id]
        while counts and not counts[-1]:
            counts.pop()
        periods.append({"label": label, "entries": len(members), "bins": counts})
    return periods

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,
    )

species_for(genre)

The tree species a genre grows.

Parameters:

Name Type Description Default
genre str

Genre name.

required

Returns:

Type Description
str

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

Source code in src/gutenberg_kg/treegeom.py
158
159
160
161
162
163
164
165
def species_for(genre: str) -> str:
    """
    The tree species a genre grows.

    :param genre: Genre name.
    :return: A key of :data:`kg_utils.viz3d.SPECIES`.
    """
    return GENRE_SPECIES.get(genre, DEFAULT_SPECIES)

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

Where each species leaf goes and how it lies, as the web forest draws it.

Placement is :func:kg_utils.viz3d.hang_leaves: stalk on the nearest twig, blade out and lifted, face to the sky. Size follows the web's emitLeaves: half a leaf's length is 1.78 * r * jitter, with r the web's leaf size and jitter from 0.78 to 1.28 by tint. A species outline (:mod:gutenberg_kg.leafshapes, stalk at y = -1, x across) lands in world space as centre + half * (x * across + y * blade).

Parameters:

Name Type Description Default
positions ndarray

(M, 3) leaf positions.

required
skeleton Skeleton

Grown skeleton.

required
size float

Leaf size after density scaling (TreeGeometry.leaf_radius).

required
tint ndarray | None

(M,) palette index per leaf, which sets the jitter; None for no jitter.

None
n_tints int

Palette length the tints index into.

1

Returns:

Type Description
tuple[ndarray, ndarray, ndarray, ndarray, ndarray]

(centres, half, across, blades, faces): (M, 3) leaf centres, (M,) half-lengths, and the (M, 3) unit frame.

Source code in src/gutenberg_kg/treegeom.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def species_leaf_frames(
    positions: np.ndarray,
    skeleton: Skeleton,
    *,
    size: float,
    tint: np.ndarray | None = None,
    n_tints: int = 1,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """
    Where each species leaf goes and how it lies, as the web forest draws it.

    Placement is :func:`kg_utils.viz3d.hang_leaves`: stalk on the nearest
    twig, blade out and lifted, face to the sky.  Size follows the web's
    ``emitLeaves``: half a leaf's length is ``1.78 * r * jitter``, with *r*
    the web's leaf size and ``jitter`` from ``0.78`` to ``1.28`` by tint.
    A species outline (:mod:`gutenberg_kg.leafshapes`, stalk at ``y = -1``,
    ``x`` across) lands in world space as
    ``centre + half * (x * across + y * blade)``.

    :param positions: ``(M, 3)`` leaf positions.
    :param skeleton: Grown skeleton.
    :param size: Leaf size after density scaling (``TreeGeometry.leaf_radius``).
    :param tint: ``(M,)`` palette index per leaf, which sets the jitter;
        ``None`` for no jitter.
    :param n_tints: Palette length the tints index into.
    :return: ``(centres, half, across, blades, faces)``: ``(M, 3)`` leaf
        centres, ``(M,)`` half-lengths, and the ``(M, 3)`` unit frame.
    """
    bases, blades, faces = hang_leaves(positions, skeleton)
    jitter = (
        np.ones(len(bases))
        if tint is None
        else 0.78 + 0.5 * np.asarray(tint, dtype=float) / max(n_tints, 1)
    )
    half = size * WEB_LEAF_RATIO * LEAF_HALF_LENGTH * jitter
    centres = bases + blades * half[:, None]
    return centres, half, np.cross(blades, faces), blades, faces