Skip to content

POV-Ray scenes

gutenberg_kg.povscene

povscene.py — a book's knowledge tree as analytic POV-Ray primitives.

The PyVista path in :mod:gutenberg_kg.scene tessellates: a limb becomes a tubed spline of a few thousand triangles, a leaf becomes a glyphed ellipsoid. That is the right shape for a live viewport, and the wrong one for a ray-tracer. By the time geometry reaches pv.Plotter its silhouettes are already faceted, and a mesh dump re-parses those facets once per view — 48 times for a Portrait quilt.

This module describes the same tree analytically instead. A limb is a sphere_sweep through its smoothed path; a leaf is one instance of a single declared ellipsoid. The tree is not approximated at any zoom, and the file is one to two orders of magnitude smaller than the equivalent triangle dump.

The exception is bark. A sphere_sweep has no texture coordinates, so by default the wood is :func:kg_utils.viz3d.bark_sweep -- the same UV-mapped sweep the PyVista path draws -- written as a mesh2 wearing the species' bark photograph. species_look=False keeps the plain analytic sweeps.

Both backends start from :func:gutenberg_kg.treegeom.grow_tree_geometry, so the ray-traced tree is the same tree as the rasterised one — same skeleton, same clung leaves, same halo — not a second implementation that has to be kept in step by hand. Nothing here imports PyVista, so a headless render farm can write a scene with no VTK stack installed at all.

The scene carries no camera: quiltwright.povray.render_pov_quilt appends one off-axis camera per view, and POV-Ray honours the last camera it parses. Use :func:tree_pov_camera to frame the tree and hand the result to the renderer.

Author: Eric G. Suchanek, PhD

add_species_leaves(scene, geometry)

Hang the tree's leaves as the web forest does, in the species' outline.

Each leaf is placed and sized by :func:~gutenberg_kg.treegeom.species_leaf_frames -- the same frames the PyVista path uses -- and drawn as one flat polygon of the species' outline (:mod:gutenberg_kg.leafshapes), declared once and instanced per leaf.

Parameters:

Name Type Description Default
scene PovScene

Scene to add the leaves to.

required
geometry TreeGeometry

The placed tree.

required
Source code in src/gutenberg_kg/povscene.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def add_species_leaves(scene: PovScene, geometry: TreeGeometry) -> None:
    """
    Hang the tree's leaves as the web forest does, in the species' outline.

    Each leaf is placed and sized by
    :func:`~gutenberg_kg.treegeom.species_leaf_frames` -- the same frames the
    PyVista path uses -- and drawn as one flat ``polygon`` of the species'
    outline (:mod:`gutenberg_kg.leafshapes`), declared once and instanced per
    leaf.

    :param scene: Scene to add the leaves to.
    :param geometry: The placed tree.
    """
    palette = geometry.palette.foliage
    tint = np.asarray(geometry.leaf_tint, dtype=int)
    centres, half, across, blades, faces = species_leaf_frames(
        geometry.leaf_points,
        geometry.skeleton,
        size=geometry.leaf_radius,
        tint=tint,
        n_tints=len(palette),
    )
    if not len(centres):
        return
    outline = leaf_outline(LEAF_SHAPES.get(geometry.species, LEAF_SHAPES["chestnut"]))
    ring = [*outline, outline[0]]
    points = ", ".join(f"<{x:.5g}, {y:.5g}>" for x, y in ring)
    scene.declare("Leaf", f"polygon {{ {len(ring)}, {points} }}")

    for i, colour in enumerate(palette):
        members = [
            Instance(
                "Leaf",
                translate=centres[k],
                scale=float(half[k]),
                matrix=np.stack([across[k], blades[k], faces[k]]),
            )
            for k in np.flatnonzero(tint == i)
        ]
        if members:
            scene.declare_texture(f"Leaf{i}", Texture(color=colour, finish=LEAF_FINISH))
            scene.add(Union(members, texture=f"Leaf{i}"))

bark_tint(wood)

The season's wood colour as the web forest tints its bark with it.

three.js mixes colours in linear light, so the lift toward white is taken there and the result encoded back to sRGB hex.

Parameters:

Name Type Description Default
wood str

The season's wood colour, "#rrggbb".

required

Returns:

Type Description
str

The tint, "#rrggbb".

Source code in src/gutenberg_kg/povscene.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def bark_tint(wood: str) -> str:
    """
    The season's wood colour as the web forest tints its bark with it.

    three.js mixes colours in linear light, so the lift toward white is taken
    there and the result encoded back to sRGB hex.

    :param wood: The season's wood colour, ``"#rrggbb"``.
    :return: The tint, ``"#rrggbb"``.
    """
    rgb = np.asarray(parse_color(wood))
    linear = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
    linear += (1.0 - linear) * BARK_TINT_LIFT
    srgb = np.where(linear <= 0.0031308, linear * 12.92, 1.055 * linear ** (1 / 2.4) - 0.055)
    return "#" + "".join(f"{round(float(c) * 255):02x}" for c in srgb)

build_tree_pov_scene(nodes, edges, *, slug, genre='unknown', entry_times=None, filters=None, season=DEFAULT_SEASON, tip_radius=0.05, leaf_size=0.32, subdivisions=4, ground_size=DEFAULT_GROUND, brightness=DEFAULT_BRIGHTNESS, sky=None, species_look=True, progress=None)

Grow one book and compose it as a POV-Ray scene, in one call.

The POV-Ray counterpart of :func:~gutenberg_kg.scene.build_tree_scene. Same arguments, same tree; the difference is that nothing is tessellated and no plotter is touched.

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 tropism silhouette.

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

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

None
filters SceneFilters | None

Only show_entities and show_topics are consulted.

None
season str

Key into :data:~gutenberg_kg.treegeom.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
subdivisions int

Spline samples per skeleton segment.

4
ground_size float

Ground slab edge as a multiple of crown width; 0 omits it. On by default — a tree with no contact shadow floats.

DEFAULT_GROUND
brightness float

Key-light multiplier.

DEFAULT_BRIGHTNESS
sky str | None

Background colour override, "#rrggbb".

None
species_look bool

Bark-textured wood; see :func:tree_pov_scene.

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

Optional fn(message) progress callback.

None

Returns:

Type Description
tuple[PovScene, TreeGeometry]

(scene, geometry) — the geometry is returned because framing the camera needs the crown, and regrowing the tree to get it would be both slow and a chance for the two to disagree.

Source code in src/gutenberg_kg/povscene.py
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
def build_tree_pov_scene(
    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,
    subdivisions: int = 4,
    ground_size: float = DEFAULT_GROUND,
    brightness: float = DEFAULT_BRIGHTNESS,
    sky: str | None = None,
    species_look: bool = True,
    progress: Callable[[str], None] | None = None,
) -> tuple[PovScene, TreeGeometry]:
    """
    Grow one book and compose it as a POV-Ray scene, in one call.

    The POV-Ray counterpart of
    :func:`~gutenberg_kg.scene.build_tree_scene`.  Same arguments, same tree;
    the difference is that nothing is tessellated and no plotter is touched.

    :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 tropism silhouette.
    :param entry_times: ``{document id: ISO timestamp}``; grows a dated book's
        limbs as calendar years.
    :param filters: Only ``show_entities`` and ``show_topics`` are consulted.
    :param season: Key into :data:`~gutenberg_kg.treegeom.SEASONS`.
    :param tip_radius: Radius of leaf-bearing twigs, in scene units.
    :param leaf_size: Leaf radius before density scaling.
    :param subdivisions: Spline samples per skeleton segment.
    :param ground_size: Ground slab edge as a multiple of crown width; ``0``
        omits it.  On by default — a tree with no contact shadow floats.
    :param brightness: Key-light multiplier.
    :param sky: Background colour override, ``"#rrggbb"``.
    :param species_look: Bark-textured wood; see :func:`tree_pov_scene`.
    :param progress: Optional ``fn(message)`` progress callback.
    :return: ``(scene, geometry)`` — the geometry is returned because framing
        the camera needs the crown, and regrowing the tree to get it would be
        both slow and a chance for the two to disagree.
    """
    report = progress or (lambda _msg: None)
    geometry = 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,
    )
    wood = "bark" if species_look else "sphere sweeps"
    report(f"Writing {geometry.skeleton.n_nodes:,} skeleton nodes as {wood}...")
    scene = tree_pov_scene(
        geometry,
        subdivisions=subdivisions,
        slug=slug,
        ground_size=ground_size,
        brightness=brightness,
        sky=sky,
        species_look=species_look,
    )
    report(f"Composed {len(scene):,} POV-Ray objects.")
    return scene, geometry

preview_spec(width=1200, height=900)

A one-view :class:~quiltwright.QuiltSpec — a plain ray-traced image.

render_pov_quilt is the only entry point that drives POV-Ray and assembles the result, and it is parameterised entirely by the spec. A 1x1 grid with a zero view cone therefore gives a single straight-on frame through exactly the code path a quilt uses, rather than a second render function that could drift from it. What the preview shows is what a tile of the quilt will contain.

Parameters:

Name Type Description Default
width int

Image width in pixels.

1200
height int

Image height in pixels.

900

Returns:

Type Description
QuiltSpec

A spec whose n_views is 1.

Source code in src/gutenberg_kg/povscene.py
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
def preview_spec(width: int = 1200, height: int = 900) -> QuiltSpec:
    """
    A one-view :class:`~quiltwright.QuiltSpec` — a plain ray-traced image.

    ``render_pov_quilt`` is the only entry point that drives POV-Ray and
    assembles the result, and it is parameterised entirely by the spec.  A
    1x1 grid with a zero view cone therefore gives a single straight-on frame
    through exactly the code path a quilt uses, rather than a second
    render function that could drift from it.  What the preview shows is what
    a tile of the quilt will contain.

    :param width: Image width in pixels.
    :param height: Image height in pixels.
    :return: A spec whose ``n_views`` is 1.
    """
    return QuiltSpec(
        columns=1,
        rows=1,
        quilt_width=int(width),
        quilt_height=int(height),
        aspect=float(width) / float(height),
        view_cone=0.0,
    )

tree_camera_frame(geometry=None, *, bounds=None, fov=14.0)

The framing rule, before either renderer's coordinate conventions.

:func:tree_pov_camera converts this into POV-Ray's left-handed world; a PyVista caller can assign (position, focal_point, up) straight to plotter.camera_position. Exposing the frame itself is what lets the interactive viewport show the same composition the ray-tracer will use, instead of two framings that agree only by eye.

Parameters:

Name Type Description Default
geometry TreeGeometry | None

The placed tree. Pass this — see :func:tree_pov_camera for why framing the crown beats framing the scene.

None
bounds tuple[ndarray, ndarray] | None

(lo, hi) fallback when no geometry is available.

None
fov float

Vertical field of view in degrees.

14.0

Returns:

Type Description

A kg_utils.viz3d.CameraFrame.

Raises:

Type Description
ValueError

If neither geometry nor bounds is usable.

Source code in src/gutenberg_kg/povscene.py
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
def tree_camera_frame(
    geometry: TreeGeometry | None = None,
    *,
    bounds: tuple[np.ndarray, np.ndarray] | None = None,
    fov: float = 14.0,
):
    """
    The framing rule, before either renderer's coordinate conventions.

    :func:`tree_pov_camera` converts this into POV-Ray's left-handed world;
    a PyVista caller can assign ``(position, focal_point, up)`` straight to
    ``plotter.camera_position``.  Exposing the frame itself is what lets the
    interactive viewport show the *same* composition the ray-tracer will use,
    instead of two framings that agree only by eye.

    :param geometry: The placed tree.  **Pass this** — see
        :func:`tree_pov_camera` for why framing the crown beats framing the
        scene.
    :param bounds: ``(lo, hi)`` fallback when no geometry is available.
    :param fov: Vertical field of view in degrees.
    :return: A ``kg_utils.viz3d.CameraFrame``.
    :raises ValueError: If neither *geometry* nor *bounds* is usable.
    """
    if geometry is not None:
        return frame_tree(geometry.crown, fov=fov)
    if bounds is None:
        raise ValueError("nothing measurable to frame: pass geometry or bounds")
    lo, hi = bounds
    return frame_tree(np.vstack([lo, hi]), fov=fov, include_root=False)

tree_pov_camera(scene, *, geometry=None, fov=14.0, zoom=1.0)

Frame the tree for a hero shot, in POV-Ray coordinates.

Both halves now come from upstream: :func:kg_utils.viz3d.frame_tree is the framing rule — one copy, shared with gutenkg quilt and every other consumer — and :func:quiltwright.povgen.pov_camera_from_frame performs the conversion into POV-Ray's left-handed world.

Parameters:

Name Type Description Default
scene PovScene

A composed scene, used only when geometry is absent.

required
geometry TreeGeometry | None

The placed tree. Pass this. Framing from the scene means framing whatever is in it, and the ground slab is three crown-widths across.

None
fov float

Vertical field of view in degrees. It sets the standoff as well as the lens: POV-Ray has no reset_camera(), so the distance that fits the crown has to be computed up front or the tree overflows a narrow lens and swims in a wide one.

14.0
zoom float

Dolly toward the focal point after framing.

1.0

Returns:

Type Description
PovCamera

The camera, in POV-Ray coordinates.

Raises:

Type Description
ValueError

If there is nothing measurable to frame.

Source code in src/gutenberg_kg/povscene.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
def tree_pov_camera(
    scene: PovScene,
    *,
    geometry: TreeGeometry | None = None,
    fov: float = 14.0,
    zoom: float = 1.0,
) -> PovCamera:
    """
    Frame the tree for a hero shot, in POV-Ray coordinates.

    Both halves now come from upstream: :func:`kg_utils.viz3d.frame_tree` is
    the framing rule — one copy, shared with ``gutenkg quilt`` and every other
    consumer — and :func:`quiltwright.povgen.pov_camera_from_frame` performs
    the conversion into POV-Ray's left-handed world.

    :param scene: A composed scene, used only when *geometry* is absent.
    :param geometry: The placed tree.  **Pass this.**  Framing from the scene
        means framing whatever is in it, and the ground slab is three
        crown-widths across.
    :param fov: Vertical field of view in degrees.  It sets the standoff as
        well as the lens: POV-Ray has no ``reset_camera()``, so the distance
        that fits the crown has to be computed up front or the tree overflows
        a narrow lens and swims in a wide one.
    :param zoom: Dolly toward the focal point after framing.
    :return: The camera, in POV-Ray coordinates.
    :raises ValueError: If there is nothing measurable to frame.
    """
    frame = tree_camera_frame(
        geometry,
        bounds=None if geometry is not None else scene.bounds(),
        fov=fov,
    )
    return pov_camera_from_frame(frame, fov=fov, zoom=zoom, handedness=scene.handedness)

tree_pov_scene(geometry, *, subdivisions=4, slug='tree', cling=0.7, ground_size=DEFAULT_GROUND, lights=True, brightness=DEFAULT_BRIGHTNESS, sky=None, species_look=True)

Turn a placed :class:~gutenberg_kg.treegeom.TreeGeometry into POV-Ray SDL.

The composition is :func:quiltwright.povgen.swept_scene: limbs are its swept paths, leaves its oriented instances, spores its point clouds. What is left here is the part that is actually about books — the season's palette, which node kinds become spores, and the finishes — which is the only part another KG module would not share.

The scene is authored right-handed (+z up, as everything else in this repo is) and emitted left-handed; povgen negates z on the way out, cameras included, so the ray-traced image matches the PyVista render rather than mirroring it.

Parameters:

Name Type Description Default
geometry TreeGeometry

Placed tree from :func:~gutenberg_kg.treegeom.grow_tree_geometry.

required
subdivisions int

Spline samples per skeleton segment. This is the one dial that trades file size for limb smoothness; 4 matches the PyVista path.

4
slug str

Book slug, written into the file header and used to seed the leaf roll jitter so the canopy matches the rasterised one.

'tree'
cling float

How far each leaf is drawn toward its nearest twig, 0 to 1. Must match the PyVista path's default for the two renders to agree.

0.7
ground_size float

Edge length of the ground slab, as a multiple of the crown's own width; 0 omits it. On by default, unlike :func:~gutenberg_kg.scene.build_tree_scene. That path omits its ground because it draws an effectively infinite plane, which guarantees off-budget disparity at the horizon; this slab is finite and sized to the crown, so it carries no such cost — and a ray-traced tree with no contact shadow reads as floating, which the rasterised one does not, since VTK's headlight casts nothing anyway. Sized relative to the crown rather than in scene units, so one value works for a sonnet and for a nine-year diary.

DEFAULT_GROUND
lights bool

Place a three-point rig from the scene bounds. POV-Ray has no equivalent of VTK's headlight, so a scene with no lights at all renders black.

True
brightness float

Key-light multiplier. See :data:DEFAULT_BRIGHTNESS for why this is not 1.0.

DEFAULT_BRIGHTNESS
sky str | None

Background colour override, "#rrggbb". None keeps the season's own sky, which is chosen for a dark hero shot.

None
species_look bool

The web forest's look. The wood wears the species' bark photograph, tinted by the season (:func:bark_tint), as a mesh2 from :func:kg_utils.viz3d.bark_sweep; the leaves hang as the web hangs them, in the species' outline (:func:add_species_leaves); and the scene declares #version 3.7 so the photograph keeps its contrast. False draws plain sphere_sweep limbs in the season's wood colour and ellipsoid leaves from :func:kg_utils.viz3d.leaf_frames, undeclared as before. The .pov names the photograph by file name, and PovScene.write copies it alongside.

True

Returns:

Type Description
PovScene

The composed :class:~quiltwright.povgen.PovScene.

Source code in src/gutenberg_kg/povscene.py
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
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
def tree_pov_scene(
    geometry: TreeGeometry,
    *,
    subdivisions: int = 4,
    slug: str = "tree",
    cling: float = 0.7,
    ground_size: float = DEFAULT_GROUND,
    lights: bool = True,
    brightness: float = DEFAULT_BRIGHTNESS,
    sky: str | None = None,
    species_look: bool = True,
) -> PovScene:
    """
    Turn a placed :class:`~gutenberg_kg.treegeom.TreeGeometry` into POV-Ray SDL.

    The composition is :func:`quiltwright.povgen.swept_scene`: limbs are its
    swept paths, leaves its oriented instances, spores its point clouds.  What
    is left here is the part that is actually about books — the season's
    palette, which node kinds become spores, and the finishes — which is the
    only part another KG module would not share.

    The scene is authored **right-handed** (``+z`` up, as everything else in
    this repo is) and emitted left-handed; ``povgen`` negates ``z`` on the way
    out, cameras included, so the ray-traced image matches the PyVista render
    rather than mirroring it.

    :param geometry: Placed tree from
        :func:`~gutenberg_kg.treegeom.grow_tree_geometry`.
    :param subdivisions: Spline samples per skeleton segment.  This is the one
        dial that trades file size for limb smoothness; 4 matches the PyVista
        path.
    :param slug: Book slug, written into the file header and used to seed the
        leaf roll jitter so the canopy matches the rasterised one.
    :param cling: How far each leaf is drawn toward its nearest twig, ``0`` to
        ``1``.  Must match the PyVista path's default for the two renders to
        agree.
    :param ground_size: Edge length of the ground slab, as a multiple of the
        crown's own width; ``0`` omits it.  **On by default, unlike
        :func:`~gutenberg_kg.scene.build_tree_scene`.**  That path omits its
        ground because it draws an effectively infinite plane, which guarantees
        off-budget disparity at the horizon; this slab is finite and sized to
        the crown, so it carries no such cost — and a ray-traced tree with no
        contact shadow reads as floating, which the rasterised one does not,
        since VTK's headlight casts nothing anyway.  Sized relative to the
        crown rather than in scene units, so one value works for a sonnet and
        for a nine-year diary.
    :param lights: Place a three-point rig from the scene bounds.  POV-Ray has
        no equivalent of VTK's headlight, so a scene with no lights at all
        renders black.
    :param brightness: Key-light multiplier.  See :data:`DEFAULT_BRIGHTNESS`
        for why this is not ``1.0``.
    :param sky: Background colour override, ``"#rrggbb"``.  ``None`` keeps the
        season's own sky, which is chosen for a dark hero shot.
    :param species_look: The web forest's look.  The wood wears the species'
        bark photograph, tinted by the season (:func:`bark_tint`), as a
        ``mesh2`` from :func:`kg_utils.viz3d.bark_sweep`; the leaves hang as
        the web hangs them, in the species' outline
        (:func:`add_species_leaves`); and the scene declares ``#version 3.7``
        so the photograph keeps its contrast.  ``False`` draws plain
        ``sphere_sweep`` limbs in the season's wood colour and ellipsoid
        leaves from :func:`kg_utils.viz3d.leaf_frames`, undeclared as before.
        The ``.pov`` names the photograph by file name, and
        ``PovScene.write`` copies it alongside.
    :return: The composed :class:`~quiltwright.povgen.PovScene`.
    """
    palette = geometry.palette
    bark_path = bark_texture_path(geometry.species) if species_look else None
    if bark_path is not None:
        with Image.open(bark_path) as image:
            width, height = image.size
        wood = bark_sweep(
            geometry.skeleton, aspect=height / width, tile=BARK_TILE, subdivisions=subdivisions
        )
        sweeps: Mesh2 | list[tuple[np.ndarray, np.ndarray]] = Mesh2(
            vertices=wood.points, faces=wood.faces, normals=wood.normals, uv=wood.uv
        )
        bark: ImageTexture | None = ImageTexture(
            bark_path, tint=bark_tint(palette.wood), bump=BARK_BUMP, finish=BARK_IMAGE_FINISH
        )
    else:
        sweeps = limb_paths(geometry.skeleton, subdivisions=subdivisions)
        bark = None
    clouds = [
        (points, radius, KIND_COLOR[kind], SPORE_OPACITY)
        for kind, (points, radius) in geometry.spores.items()
    ]
    scene = swept_scene(
        sweeps,
        sweep_color=palette.wood,
        sweep_finish=BARK_FINISH,
        sweep_texture=bark,
        instances=None
        if species_look
        else leaf_frames(
            geometry.leaf_points,
            geometry.skeleton,
            size=geometry.leaf_radius,
            cling=cling,
            seed=seed_from_key(slug + ":leaves"),
        ),
        instance_shape=LEAF_ASPECT,
        instance_radius=geometry.leaf_radius,
        instance_palette=palette.foliage,
        # .tolist(): swept_scene takes Sequence[int], and an ndarray is not one
        # as far as the type checker is concerned.
        instance_index=np.asarray(geometry.leaf_tint, dtype=int).tolist(),
        instance_finish=LEAF_FINISH,
        clouds=clouds,
        cloud_finish=SPORE_FINISH,
        up=(0.0, 0.0, 1.0),
        sky=sky or palette.sky[0],
        ambient=AMBIENT_LIGHT,
        lights=lights,
        ground=ground_size,
        # The trunk's root is at z = 0; the swept bounds are padded by its
        # radius, so left to infer the floor would sit a trunk-radius low.
        ground_base=0.0,
        ground_color=GROUND_COLOR,
        ground_finish=GROUND_FINISH,
        brightness=brightness,
        key_side=CAMERA_SIDE,
        rim_light=True,
        comment=(
            f"{slug} — GutenbergKG knowledge tree\n"
            f"{geometry.title}\n"
            f"Grown by kg_utils.viz3d, composed by quiltwright.povgen.swept_scene."
        ),
    )
    if species_look:
        add_species_leaves(scene, geometry)
        scene.version = SPECIES_POV_VERSION
    return scene

write_tree_pov(nodes, edges, path, *, slug, progress=None, **kwargs)

Grow a book and write it to a .pov file.

Parameters:

Name Type Description Default
nodes list[LayoutNode]

Nodes of one book.

required
edges list[LayoutEdge]

Edges of the same book.

required
path str | Path

Destination .pov file; parent directories are created.

required
slug str

Book slug.

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

Optional fn(message) progress callback.

None
kwargs

Passed through to :func:build_tree_pov_scene.

{}

Returns:

Type Description
tuple[Path, TreeGeometry]

(written path, geometry).

Source code in src/gutenberg_kg/povscene.py
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
def write_tree_pov(
    nodes: list[LayoutNode],
    edges: list[LayoutEdge],
    path: str | Path,
    *,
    slug: str,
    progress: Callable[[str], None] | None = None,
    **kwargs,
) -> tuple[Path, TreeGeometry]:
    """
    Grow a book and write it to a ``.pov`` file.

    :param nodes: Nodes of one book.
    :param edges: Edges of the same book.
    :param path: Destination ``.pov`` file; parent directories are created.
    :param slug: Book slug.
    :param progress: Optional ``fn(message)`` progress callback.
    :param kwargs: Passed through to :func:`build_tree_pov_scene`.
    :return: ``(written path, geometry)``.
    """
    scene, geometry = build_tree_pov_scene(nodes, edges, slug=slug, progress=progress, **kwargs)
    written = scene.write(path)
    if progress:
        progress(f"Wrote {written} ({written.stat().st_size / 1024:.0f} KB)")
    return written, geometry