From 68b9ce5b5eb1878b29ae52f6d57853286a00d606 Mon Sep 17 00:00:00 2001 From: zolizoli Date: Wed, 1 Jul 2026 00:20:06 +0200 Subject: [PATCH] Phase 3: the 3-D corkscrew walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the 2-D walk into a corkscrew and render it in matplotlib 3-D. - layout/walk3d.py: walk3d_layout — the 2-D walk in xy with a constant z lift per sentence; reuses (and stays consistent with) walk_layout. - render/mpl3d.py: render_path_3d — an mplot3d Line3DCollection path with per-segment colours and per-segment line widths (so the size channel carries into 3-D); true-proportion box aspect, clean hidden axes. - presets/text_walk.py: helix=True / z_step wind the walk into a corkscrew (path mode; glyph mode rejected in 3-D with a clear error). - Exports, example (text_walk_3d_demo.py), docs, CHANGELOG. make ci green (117 tests, incl. z-monotonicity and xy-equals-2D-walk properties); mkdocs build --strict green; corkscrew renders headlessly. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++ docs/api/render.md | 4 + docs/api/walk3d.md | 5 ++ docs/examples/text_walk_3d.md | 18 ++++ examples/text_walk_3d_demo.py | 27 ++++++ mkdocs.yml | 2 + src/lexograph/__init__.py | 5 +- src/lexograph/layout/__init__.py | 2 + src/lexograph/layout/walk3d.py | 64 ++++++++++++++ src/lexograph/presets/text_walk.py | 44 ++++++++-- src/lexograph/render/__init__.py | 3 +- src/lexograph/render/mpl3d.py | 135 +++++++++++++++++++++++++++++ tests/test_presets.py | 9 ++ tests/test_render3d.py | 42 +++++++++ tests/test_walk3d.py | 43 +++++++++ 15 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 docs/api/walk3d.md create mode 100644 docs/examples/text_walk_3d.md create mode 100644 examples/text_walk_3d_demo.py create mode 100644 src/lexograph/layout/walk3d.py create mode 100644 src/lexograph/render/mpl3d.py create mode 100644 tests/test_render3d.py create mode 100644 tests/test_walk3d.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 60140f6..becd332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ semantic versioning. ### Added +- Phase 3 corkscrew: the 3-D walk. + - `layout.walk3d_layout` — the 2-D walk lifted by a constant step in z. + - `render.render_path_3d` — a matplotlib 3-D path renderer with per-segment + colours and line widths. + - `text_walk(..., helix=True, z_step=...)` winds the walk into a corkscrew; + `examples/text_walk_3d_demo.py` and docs. - Phase 2 walk + spiral: the first two presets and their layouts. - `layout.walk_layout` / `heading_angles` — the 2-D turtle walk (step forward, turn 90°), ported from the Wittgenstein piece's `compute_path`. diff --git a/docs/api/render.md b/docs/api/render.md index 26c7d07..1f7151e 100644 --- a/docs/api/render.md +++ b/docs/api/render.md @@ -5,3 +5,7 @@ Draw laid-out, encoded units as a matplotlib `Figure`. ::: lexograph.render.mpl.render_points ::: lexograph.render.mpl.render_path + +::: lexograph.render.mpl3d.render_path_3d + +::: lexograph.render.mpl.frame_axes diff --git a/docs/api/walk3d.md b/docs/api/walk3d.md new file mode 100644 index 0000000..a0a3242 --- /dev/null +++ b/docs/api/walk3d.md @@ -0,0 +1,5 @@ +# 3-D walk layout + +The corkscrew: the 2-D walk lifted by a constant step in z. + +::: lexograph.layout.walk3d.walk3d_layout diff --git a/docs/examples/text_walk_3d.md b/docs/examples/text_walk_3d.md new file mode 100644 index 0000000..02d9a7f --- /dev/null +++ b/docs/examples/text_walk_3d.md @@ -0,0 +1,18 @@ +# 3-D text walk + +The bundled chapter as a 3-D corkscrew: the same space-filling turtle walk, but +each sentence also climbs a constant step in z, so the rectangular walk winds +upward. Rendered with matplotlib's `mplot3d`. + +```python +--8<-- "examples/text_walk_3d_demo.py" +``` + +Run it with: + +```bash +uv run python examples/text_walk_3d_demo.py +``` + +It is the same `text_walk` preset as the 2-D walk, with `helix=True`. Raise +`z_step` for a taller, more tightly wound corkscrew. diff --git a/examples/text_walk_3d_demo.py b/examples/text_walk_3d_demo.py new file mode 100644 index 0000000..a3f37bd --- /dev/null +++ b/examples/text_walk_3d_demo.py @@ -0,0 +1,27 @@ +"""3-D text-walk demo: the chapter lifted into a corkscrew. + +Run with: uv run python examples/text_walk_3d_demo.py +Writes text_walk_3d.png to the current directory. +""" + +from lexograph import load_demo_text, segment, text_walk + + +def main() -> None: + """Walk the bundled chapter as a 3-D corkscrew, coloured by position.""" + text = load_demo_text() + units = segment(text, unit="sentences") + print(f"Winding {len(units)} sentences into a corkscrew.") + + fig = text_walk( + text, + colour=list(range(len(units))), + helix=True, + z_step=4.0, + ) + fig.savefig("text_walk_3d.png", dpi=150) + print("Saved text_walk_3d.png") + + +if __name__ == "__main__": + main() diff --git a/mkdocs.yml b/mkdocs.yml index 2490e94..87cb533 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,6 +51,7 @@ nav: - Examples: - examples/punctuation_spiral.md - examples/text_walk.md + - examples/text_walk_3d.md - examples/spine_demo.md - Troubleshooting: troubleshooting.md - API Reference: @@ -61,6 +62,7 @@ nav: - api/segment.md - api/layout.md - api/walk.md + - api/walk3d.md - api/spiral.md - api/widths.md - api/channels.md diff --git a/src/lexograph/__init__.py b/src/lexograph/__init__.py index ba69d3e..7d57c28 100644 --- a/src/lexograph/__init__.py +++ b/src/lexograph/__init__.py @@ -21,16 +21,18 @@ linear_layout, rendered_widths, spiral_layout, + walk3d_layout, walk_layout, ) from lexograph.presets import punctuation_spiral, text_walk -from lexograph.render import frame_axes, render_path, render_points +from lexograph.render import frame_axes, render_path, render_path_3d, render_points from lexograph.segment import segment __all__ = [ "segment", "linear_layout", "walk_layout", + "walk3d_layout", "spiral_layout", "rendered_widths", "normalize_size", @@ -39,6 +41,7 @@ "Channels", "render_points", "render_path", + "render_path_3d", "frame_axes", "punctuation_spiral", "text_walk", diff --git a/src/lexograph/layout/__init__.py b/src/lexograph/layout/__init__.py index 30f7752..418957c 100644 --- a/src/lexograph/layout/__init__.py +++ b/src/lexograph/layout/__init__.py @@ -3,11 +3,13 @@ from lexograph.layout.linear import linear_layout from lexograph.layout.spiral import spiral_layout, tangent_angles from lexograph.layout.walk import heading_angles, walk_layout +from lexograph.layout.walk3d import walk3d_layout from lexograph.layout.widths import rendered_widths __all__ = [ "linear_layout", "walk_layout", + "walk3d_layout", "heading_angles", "spiral_layout", "tangent_angles", diff --git a/src/lexograph/layout/walk3d.py b/src/lexograph/layout/walk3d.py new file mode 100644 index 0000000..b326e47 --- /dev/null +++ b/src/lexograph/layout/walk3d.py @@ -0,0 +1,64 @@ +"""The 3-D corkscrew layout: the 2-D walk lifted by a constant step in z. + +This is the 2-D turtle walk (:func:`lexograph.layout.walk.walk_layout`) with a +fixed vertical lift added per unit, so the rectangular walk winds upward into a +corkscrew. It mirrors the Wittgenstein piece's helical handwriting layout, where +each sentence both advances the in-plane turtle and climbs a constant ``z_step``. +The geometry is plain coordinate generation; the renderer is matplotlib 3-D. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +import numpy as np + +from lexograph._types import Coords +from lexograph.layout.walk import walk_layout + +__all__ = ["walk3d_layout"] + + +def walk3d_layout( + steps: Iterable[float], + *, + turn: float = -90.0, + z_step: float = 1.0, + scale: float = 1.0, + start: tuple[float, float, float] = (0.0, 0.0, 0.0), + heading: tuple[float, float] = (1.0, 0.0), +) -> Coords: + """Walk the 2-D turtle in the xy-plane while climbing a constant step in z. + + Args: + steps: One forward (in-plane) step length per unit. + turn: Degrees to turn the in-plane heading after each step. + z_step: The constant vertical lift added per unit (the corkscrew pitch). + scale: A multiplier applied to every in-plane step length. + start: The starting ``(x, y, z)`` position. + heading: The initial in-plane heading; normalised internally. + + Returns: + An ``(N + 1, 3)`` float array of vertices. Row 0 is ``start``; the + ``z`` coordinate of row ``i`` is ``start[2] + i * z_step``. + + Contract: + - The result has exactly ``N + 1`` rows for ``N`` steps. + - The ``z`` column increases by exactly ``z_step`` between consecutive + rows. + - The ``xy`` columns equal the 2-D walk with the same parameters. + + Examples: + >>> walk3d_layout([1.0, 1.0], z_step=2.0).round(6).tolist() + [[0.0, 0.0, 0.0], [1.0, 0.0, 2.0], [1.0, -1.0, 4.0]] + """ + lengths = list(steps) + xy = walk_layout( + lengths, + turn=turn, + scale=scale, + start=(start[0], start[1]), + heading=heading, + ) + z = start[2] + np.arange(len(lengths) + 1, dtype=float) * z_step + return np.column_stack([xy, z]) diff --git a/src/lexograph/presets/text_walk.py b/src/lexograph/presets/text_walk.py index 1cb81f6..bfc44e3 100644 --- a/src/lexograph/presets/text_walk.py +++ b/src/lexograph/presets/text_walk.py @@ -29,8 +29,10 @@ normalize_size, ) from lexograph.layout.walk import heading_angles, walk_layout +from lexograph.layout.walk3d import walk3d_layout from lexograph.layout.widths import rendered_widths from lexograph.render.mpl import frame_axes +from lexograph.render.mpl3d import render_path_3d from lexograph.segment.units import sentences as split_sentences if TYPE_CHECKING: @@ -75,12 +77,14 @@ def text_walk( colour_kind: ColourKind = "auto", size: Sequence[float] | None = None, mode: WalkMode = "path", + helix: bool = False, + z_step: float = 1.0, font: FontProperties | str | Path | None = None, font_size: float = 12.0, width_step: bool = True, turn: float = -90.0, background: str = "white", - figsize: tuple[float, float] = (10.0, 10.0), + figsize: tuple[float, float] | None = None, ) -> Figure: """Draw a text as a space-filling turtle walk over its sentences. @@ -93,21 +97,29 @@ def text_walk( size: One scalar per sentence for the size channel. ``None`` sizes by sentence length. mode: ``"path"`` draws a multi-coloured ribbon; ``"glyphs"`` sets each - sentence's text along its segment. + sentence's text along its segment. Only ``"path"`` is supported when + ``helix`` is set. + helix: If ``True``, lift the walk into a 3-D corkscrew (each sentence also + climbs ``z_step``) and render it in matplotlib 3-D. + z_step: The vertical lift per sentence for the corkscrew (used when + ``helix`` is set). font: Font to measure widths and (in glyph mode) draw with. font_size: Base font size in points for width measurement and glyphs. width_step: If ``True``, step by each sentence's rendered width; if ``False``, step by its character count. turn: Degrees to turn between sentences (``-90`` is the rectangular walk). background: Figure and axes background colour. - figsize: Figure size in inches. + figsize: Figure size in inches. Defaults to ``(10, 10)`` flat, or + ``(8, 10)`` for the helix. Returns: - A :class:`matplotlib.figure.Figure` with one axes. Never calls ``show()``. + A :class:`matplotlib.figure.Figure` with one axes (2-D, or 3-D for the + helix). Never calls ``show()``. Raises: - ValueError: If the text has fewer than two sentences, or a channel length - does not match the sentence count. + ValueError: If the text has fewer than two sentences, a channel length + does not match the sentence count, or ``helix`` is combined with + glyph mode. Contract: - Returns a Figure with exactly one axes. @@ -118,7 +130,13 @@ def text_walk( >>> fig = text_walk(load_demo_text()) >>> type(fig).__name__ 'Figure' + >>> helix = text_walk(load_demo_text(), helix=True) + >>> type(helix).__name__ + 'Figure' """ + if helix and mode == "glyphs": + msg = "glyph mode is not supported for the 3-D helix walk" + raise ValueError(msg) units = split_sentences(text) n = len(units) if n < 2: @@ -129,7 +147,6 @@ def text_walk( steps = rendered_widths(units, prop=font, size=font_size) else: steps = np.array([len(u) for u in units], dtype=float) - coords = walk_layout(steps, turn=turn) colours = _resolve_colours(colour, n, colour_kind) raw_size = [float(s) for s in size] if size is not None else [len(u) for u in units] @@ -138,7 +155,18 @@ def text_walk( raise ValueError(msg) weight = normalize_size(raw_size, lo=0.0, hi=1.0) - fig = Figure(figsize=figsize, facecolor=background) + if helix: + coords3d = walk3d_layout(steps, turn=turn, z_step=z_step) + return render_path_3d( + coords3d, + colors=colours, + linewidths=[0.6 + 6.0 * float(w) for w in weight], + background=background, + figsize=figsize or (8.0, 10.0), + ) + + coords = walk_layout(steps, turn=turn) + fig = Figure(figsize=figsize or (10.0, 10.0), facecolor=background) ax = fig.subplots() ax.set_facecolor(background) diff --git a/src/lexograph/render/__init__.py b/src/lexograph/render/__init__.py index 6f6eae2..69c35e4 100644 --- a/src/lexograph/render/__init__.py +++ b/src/lexograph/render/__init__.py @@ -1,5 +1,6 @@ """Render: draw a laid-out, encoded text as a matplotlib Figure.""" from lexograph.render.mpl import frame_axes, render_path, render_points +from lexograph.render.mpl3d import render_path_3d -__all__ = ["render_points", "render_path", "frame_axes"] +__all__ = ["render_points", "render_path", "render_path_3d", "frame_axes"] diff --git a/src/lexograph/render/mpl3d.py b/src/lexograph/render/mpl3d.py new file mode 100644 index 0000000..2092b4a --- /dev/null +++ b/src/lexograph/render/mpl3d.py @@ -0,0 +1,135 @@ +"""Render a 3-D layout (the corkscrew walk) as a matplotlib ``Figure``. + +Matplotlib's ``mplot3d`` toolkit draws the path as a 3-D line collection. As in +the 2-D renderer, the figure is built directly (no ``pyplot``) and never shown, +so it displays inline in Jupyter and saves with ``fig.savefig(...)``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np +from matplotlib.figure import Figure +from mpl_toolkits.mplot3d.art3d import Line3DCollection + +if TYPE_CHECKING: + from collections.abc import Sequence + + from lexograph._types import Coords + from lexograph.encode.channels import RGBA + +__all__ = ["render_path_3d"] + +_DEFAULT_LINE_COLOR = "#333333" + + +def _coords_3d(coords: Coords) -> np.ndarray: + """Validate and return a 3-D ``(N, 3)`` float coordinate array.""" + array = np.asarray(coords, dtype=float) + if array.ndim != 2 or array.shape[1] != 3: + msg = f"coords must have shape (N, 3), got {array.shape}" + raise ValueError(msg) + return array + + +def render_path_3d( + coords: Coords, + *, + colors: list[RGBA] | None = None, + color: str = _DEFAULT_LINE_COLOR, + linewidth: float = 1.5, + linewidths: Sequence[float] | None = None, + background: str = "white", + figsize: tuple[float, float] = (8.0, 10.0), + elev: float = 18.0, + azim: float = -60.0, + axes_off: bool = True, +) -> Figure: + """Draw a 3-D path through the layout coordinates as a corkscrew. + + Args: + coords: An ``(N, 3)`` array of vertices in path order. + colors: Per-segment RGBA colours (length ``N - 1``). ``None`` draws a + single-colour path. + color: The path colour when ``colors`` is ``None``. + linewidth: Line width in points, used when ``linewidths`` is ``None``. + linewidths: Per-segment line widths (length ``N - 1``); overrides + ``linewidth`` so the size channel can vary the stroke. + background: Figure and axes background colour. + figsize: Figure size in inches. + elev: Camera elevation angle in degrees. + azim: Camera azimuth angle in degrees. + axes_off: Hide the 3-D axes, panes, and ticks for a clean plate. + + Returns: + A :class:`matplotlib.figure.Figure` with one 3-D axes. Never calls + ``show()``. + + Raises: + ValueError: If ``coords`` is not ``(N, 3)`` or ``colors`` has the wrong + length. + + Contract: + - Returns a Figure with exactly one axes. + - Inputs are never mutated. + + Examples: + >>> import numpy as np + >>> coords = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 1.0], [1.0, 1.0, 2.0]]) + >>> fig = render_path_3d(coords) + >>> type(fig).__name__ + 'Figure' + """ + array = _coords_3d(coords) + n = array.shape[0] + + fig = Figure(figsize=figsize, facecolor=background) + # Axes3D exposes 3-D-only methods (view_init, set_zlim, add_collection3d) + # that the 2-D-focused matplotlib stubs do not model; treat it as ``Any``. + ax: Any = fig.add_subplot(projection="3d") + ax.set_facecolor(background) + + if n >= 2: + segments = list(np.stack([array[:-1], array[1:]], axis=1)) + seg_colors = colors if colors is not None else [color] * (n - 1) + if len(seg_colors) != n - 1: + msg = f"colors must have length N-1 ({n - 1}), got {len(seg_colors)}" + raise ValueError(msg) + widths: float | list[float] = linewidth + if linewidths is not None: + if len(linewidths) != n - 1: + msg = ( + f"linewidths must have length N-1 ({n - 1}), got {len(linewidths)}" + ) + raise ValueError(msg) + widths = [float(w) for w in linewidths] + ax.add_collection3d( + Line3DCollection(segments, colors=seg_colors, linewidths=widths) + ) + + _frame_3d(ax, array, elev=elev, azim=azim, axes_off=axes_off) + return fig + + +def _frame_3d( + ax: Any, # noqa: ANN401 # an mplot3d Axes3D, untyped by the 2-D-focused stubs + array: np.ndarray, + *, + elev: float, + azim: float, + axes_off: bool, +) -> None: + """Fit the 3-D axes to the data, set the camera, and tidy the frame.""" + ax.view_init(elev=elev, azim=azim) + if array.shape[0]: + mins = array.min(axis=0) + maxs = array.max(axis=0) + spans = np.where(maxs > mins, maxs - mins, 1.0) + ax.set_xlim(mins[0], mins[0] + spans[0]) + ax.set_ylim(mins[1], mins[1] + spans[1]) + ax.set_zlim(mins[2], mins[2] + spans[2]) + # Keep the corkscrew's true proportions (it is usually tall in z). + ax.set_box_aspect(tuple(float(s) for s in spans)) + if axes_off: + ax.set_axis_off() diff --git a/tests/test_presets.py b/tests/test_presets.py index 4f9685f..ea13539 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -51,6 +51,15 @@ def test_custom_categorical_colour(self, demo_text: str) -> None: fig = text_walk(demo_text, colour=labels, colour_kind="categorical") assert isinstance(fig, Figure) + def test_helix_returns_3d_figure(self, demo_text: str) -> None: + fig = text_walk(demo_text, helix=True) + assert isinstance(fig, Figure) + assert fig.axes[0].name == "3d" + + def test_helix_with_glyphs_raises(self, demo_text: str) -> None: + with pytest.raises(ValueError, match="helix"): + text_walk(demo_text, helix=True, mode="glyphs") + def test_too_few_sentences_raises(self) -> None: with pytest.raises(ValueError, match="at least two sentences"): text_walk("Only one sentence here.") diff --git a/tests/test_render3d.py b/tests/test_render3d.py new file mode 100644 index 0000000..f06d4c5 --- /dev/null +++ b/tests/test_render3d.py @@ -0,0 +1,42 @@ +import numpy as np +import pytest +from matplotlib.figure import Figure + +from lexograph.encode import categorical_colors +from lexograph.layout.walk3d import walk3d_layout +from lexograph.render.mpl3d import render_path_3d + + +class TestRenderPath3D: + """render_path_3d draws the corkscrew and returns a 3-D Figure.""" + + def test_returns_figure(self) -> None: + coords = walk3d_layout([1.0, 2.0, 1.5, 3.0]) + fig = render_path_3d(coords) + assert isinstance(fig, Figure) + assert len(fig.axes) == 1 + + def test_per_segment_colours(self) -> None: + coords = walk3d_layout([1.0, 2.0, 1.5]) # 4 vertices, 3 segments + colours = categorical_colors([0, 1, 0]) + fig = render_path_3d(coords, colors=colours) + assert isinstance(fig, Figure) + + def test_per_segment_linewidths(self) -> None: + coords = walk3d_layout([1.0, 2.0, 1.5]) + fig = render_path_3d(coords, linewidths=[1.0, 2.0, 3.0]) + assert isinstance(fig, Figure) + + def test_bad_shape_raises(self) -> None: + with pytest.raises(ValueError, match=r"shape \(N, 3\)"): + render_path_3d(np.zeros((3, 2))) + + def test_wrong_colour_count_raises(self) -> None: + coords = walk3d_layout([1.0, 2.0, 1.5]) + with pytest.raises(ValueError, match="length N-1"): + render_path_3d(coords, colors=categorical_colors([0, 1])) + + def test_wrong_linewidth_count_raises(self) -> None: + coords = walk3d_layout([1.0, 2.0, 1.5]) + with pytest.raises(ValueError, match="linewidths must have length"): + render_path_3d(coords, linewidths=[1.0, 2.0]) diff --git a/tests/test_walk3d.py b/tests/test_walk3d.py new file mode 100644 index 0000000..b1c31c0 --- /dev/null +++ b/tests/test_walk3d.py @@ -0,0 +1,43 @@ +import numpy as np +from hypothesis import given +from hypothesis import strategies as st + +from lexograph.layout.walk import walk_layout +from lexograph.layout.walk3d import walk3d_layout + +step_lists = st.lists( + st.floats(min_value=0.0, max_value=1e3, allow_nan=False, allow_infinity=False), + min_size=1, + max_size=60, +) + + +class TestWalk3DLayout: + """The corkscrew is the 2-D walk with a constant lift in z.""" + + def test_known_corkscrew(self) -> None: + coords = walk3d_layout([1.0, 1.0], z_step=2.0).round(6) + assert coords.tolist() == [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 2.0], + [1.0, -1.0, 4.0], + ] + + def test_returns_n_plus_one_vertices(self) -> None: + assert walk3d_layout([1, 2, 3]).shape == (4, 3) + + @given(step_lists, st.floats(min_value=0.01, max_value=50.0)) + def test_z_increases_by_z_step(self, steps: list[float], z_step: float) -> None: + coords = walk3d_layout(steps, z_step=z_step) + dz = np.diff(coords[:, 2]) + assert np.allclose(dz, z_step) + + @given(step_lists) + def test_xy_matches_2d_walk(self, steps: list[float]) -> None: + flat = walk_layout(steps) + helix = walk3d_layout(steps) + assert np.allclose(helix[:, :2], flat) + + @given(step_lists) + def test_deterministic(self, steps: list[float]) -> None: + assert np.array_equal(walk3d_layout(steps), walk3d_layout(steps))