Skip to content

Add oblique (axonometric) projection support - #83

Closed
bjmorgan wants to merge 109 commits into
mainfrom
feature/oblique-projection
Closed

Add oblique (axonometric) projection support#83
bjmorgan wants to merge 109 commits into
mainfrom
feature/oblique-projection

Conversation

@bjmorgan

@bjmorgan bjmorgan commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Adds oblique parallel projection -- cabinet, cavalier, and the general case -- as a first-class projection mode, replacing the workaround of writing a shear matrix into ViewState.rotation (which violates that attribute's documented contract and silently risks incorrect depth ordering). In the course of the work, the projection interface itself is reshaped: mode selection is now a single sum-typed field rather than a pair of loosely coupled scalars.

Design

An oblique projection decomposes into the existing orthonormal camera plus a depth-proportional screen offset, expressed as a (2, 3) screen_matrix on ViewState. A new project_camera method (shear, then perspective, then zoom) is the single source of truth for the camera-space-to-screen mapping: project, _project_point (bonds), cell edges, and the axes orientation widget all route through it, so every drawn object shears consistently. This deletes the duplicated inline perspective maths in cell_edges. The renderer distinguishes two 3D frames: depth, slab masks, and lighting stay in unsheared camera space, while bond junction geometry (XBS-style end-cap angles and occlusion tests) lives in the screen-aligned frame (ViewState.screen_frame), where the projection is a plain drop of z -- matching the manual-shear construction exactly, which a fat-atom regression test pins. Sphere silhouettes keep their circular outlines by drawing convention.

API

Projection mode is written with setter methods and read from one field holding one value:

scene.view.set_perspective(0.5, 10.0)     # strength, view distance
scene.view.set_oblique(35.0, 0.6)         # angle, foreshortening
scene.view.set_orthographic()             # the default
scene.view.projection                     # the current mode, as a value

The three mode classes (Orthographic, Perspective, Oblique) remain the exported value vocabulary for reading and comparing; direct assignment of a mode value stays supported as the underlying mechanism.

  • Oblique(angle, foreshortening), where angle is the on-screen direction of the receding axis; both parameters are required, and no preset constants are provided -- the appropriate values are a per-figure choice.
  • Perspective owns strength and view_distance; each mode validates its own parameters (finiteness included). Zero-strength perspective is unrepresentable -- orthographic is Orthographic(), removing the old magic-zero encoding.
  • Invalid mode combinations are structurally impossible rather than policed: there is no oblique-plus-perspective state to validate. The sum is closed (a bogus projection value raises TypeError at construction and at the dispatch).

Breaking change from 0.19

ViewState.perspective, ViewState.view_distance, and the interim oblique field are removed. slots=True makes stale spellings fail loudly: view.perspective = 0.3 raises AttributeError rather than silently creating an inert attribute. Perspective strength and viewing distance move onto Perspective.

Also in this change

  • Viewport sizing gains the exact sqrt(1 + f^2) shear allowance (largest singular value of the screen matrix), applied to _scene_extent, the widget corner inset, and the widget viewport expansion.
  • The interactive viewer copies ViewState generically (deepcopy and field-wise restore) instead of hand-enumerating fields; the p/P keys form a seamless ladder into and out of Orthographic() (with a float-residue tolerance so descent never strands on a near-zero perspective), and d/D act only in perspective mode.
  • The look_along docstring is corrected: the camera sits along +direction looking back, so camera +z points out of the screen. The previous wording is what led the manual workaround to an incorrect camera.

Rendered output is byte-identical to main for orthographic and perspective views, verified by exact-equality regression tests and an independent byte-comparison of projected coordinates across both implementations. A regression test pins the oblique API against the shear matrix used by the published figure in data_nbo2f_chirality (fig_p3121_legend.py): coordinates match to floating-point tolerance and depths exactly. Once released, that figure can drop its _oblique_rotation workaround.

…itch

Replace the hand-enumerated ViewState field copies (working copy, reset
snapshot, r-key restore) with generic deepcopy / dataclasses.fields()
loops, so a new ViewState field can no longer be silently dropped from
an interactive session. Also make the p key clear any oblique
projection before raising perspective, since the two are mutually
exclusive.
Copilot AI lite review requested due to automatic review settings August 10, 2026 19:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class oblique (axonometric) parallel projection support to the rendering pipeline via a new ViewState.oblique field and a unified ViewState.project_camera mapping, replacing the prior “shear stuffed into rotation” workaround and ensuring consistent projection across all rendered geometry.

Changes:

  • Introduces Oblique (+ CAVALIER/CABINET) and routes all camera→screen projection through ViewState.project_camera, including cell edges and the axes widget.
  • Updates interactive session state handling to deepcopy/restore full ViewState, and makes p a documented mode switch that clears oblique before enabling perspective.
  • Adds regression + cross-renderer integration tests, and updates API/docs/changelog to expose and document the new projection mode.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_rendering/test_projection.py Adds equivalence test pinning _project_point to project_camera; adds viewport extent regression for oblique padding.
tests/test_rendering/test_oblique.py New integration tests asserting consistent shearing across atoms, bonds, cell edges, and axes widget.
tests/test_rendering/test_interactive.py Verifies deepcopy/reset behavior preserves oblique and p clears oblique when enabling perspective.
tests/test_model/test_view_state.py Adds validation + projection math tests for Oblique, screen_matrix, screen_scale_bound, and project_camera backstop.
tests/test_init.py Ensures Oblique, CAVALIER, CABINET are exported and present in __all__.
src/hofmann/rendering/projection.py Replaces inline perspective math with project_camera; expands _scene_extent to account for oblique shear bound.
src/hofmann/rendering/painter.py Expands viewport to accommodate sheared axes widget reach using screen_scale_bound.
src/hofmann/rendering/interactive.py Uses deepcopy for session view state; resets via dataclass field restore; p clears oblique as a mode switch.
src/hofmann/rendering/cell_edges.py Routes cell-edge projection through project_camera for consistent shearing.
src/hofmann/rendering/axes_widget.py Applies screen_matrix to widget tips so axes shear consistently with the scene; accounts for sheared reach in insets.
src/hofmann/model/view_state.py Adds Oblique, constants, validation helper, screen_matrix, screen_scale_bound, project_camera, and with_oblique; refactors project.
src/hofmann/model/init.py Re-exports Oblique, CAVALIER, CABINET from the model package.
src/hofmann/init.py Re-exports Oblique, CAVALIER, CABINET at top level.
docs/rendering.rst Documents oblique projection usage and constraints.
docs/interactive.rst Documents p key as mode switch when starting from oblique; clarifies interactive view captures full state.
docs/changelog.rst Adds changelog entry for oblique projection feature.
docs/api.rst Adds API docs for Oblique and presets.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/hofmann/model/view_state.py
Copilot AI review requested due to automatic review settings August 10, 2026 20:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/hofmann/model/view_state.py:114

  • ViewState.perspective is treated as a non-negative “strength” (0 = orthographic), but __post_init__ currently allows negative finite values. That silently turns the projection into the orthographic branch (perspective > 0 is false) while leaving a non-zero perspective in the state, and also weakens the oblique/perspective exclusivity guarantee.
    def __post_init__(self) -> None:
        if not math.isfinite(self.zoom) or self.zoom <= 0:
            raise ValueError(
                f"zoom must be finite and positive, got {self.zoom}"
            )
        if not math.isfinite(self.view_distance) or self.view_distance <= 0:
            raise ValueError(
                f"view_distance must be finite and positive, got "
                f"{self.view_distance}"
            )
        if not math.isfinite(self.perspective):
            raise ValueError(
                f"perspective must be finite, got {self.perspective}"
            )
        _check_oblique_perspective_exclusive(self.oblique, self.perspective)

src/hofmann/model/view_state.py:183

  • project_camera is documented to accept shape (n, 3), but currently a common “single point” call like view.project_camera([x, y, z]) will raise an IndexError at camera[:, 2] rather than a clear error (or being handled). Adding a small shape check (and optionally reshaping (3,) to (1, 3)) makes the new public API harder to misuse and gives better errors.
    def project_camera(
        self, camera: np.ndarray,
    ) -> tuple[np.ndarray, np.ndarray]:
        """Map camera-space coordinates to screen coordinates.

        The single source of truth for the camera-space-to-screen
        mapping: applies :attr:`screen_matrix`, then perspective
        scaling, then zoom.  Every rendered object must obtain its
        screen positions through this method (directly or via
        :meth:`project`) so that all drawn geometry projects
        consistently.

        Args:
            camera: Array of shape ``(n, 3)`` in camera space, i.e.
                after centring and rotation.

        Returns:
            Tuple of ``(xy, scale)`` where *xy* has shape ``(n, 2)``
            and *scale* has shape ``(n,)``, the perspective scale
            factor at each depth (all ones when orthographic).

        Raises:
            ValueError: If :attr:`oblique` is set while
                :attr:`perspective` is positive.  Construction and
                :meth:`with_oblique` also reject this combination;
                this backstop closes the direct-assignment path.
        """
        _check_oblique_perspective_exclusive(self.oblique, self.perspective)
        camera = np.asarray(camera, dtype=float)
        xy = camera @ self.screen_matrix.T
        if self.perspective > 0:
            scale = self.view_distance / (
                self.view_distance - camera[:, 2] * self.perspective
            )
        else:
            scale = np.ones(len(camera))
        return xy * scale[:, np.newaxis] * self.zoom, scale

Copilot AI review requested due to automatic review settings August 11, 2026 07:58
Copilot AI review requested due to automatic review settings August 14, 2026 20:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (1)

tests/test_model/test_view_state.py:535

  • This test docstring says “Only a non-positive determinant is rejected…”, but ViewState._check_rotation() only rejects singular matrices (determinant == 0) and explicitly allows reflections (negative determinant), per the class-level comment. Updating the wording avoids documenting a behavior the code does not implement.
        """Only a non-positive determinant is rejected, not
        orthonormality (see the class-level comment): a proper,
        finite matrix that scales rather than rotates is accepted.
        This distinguishes the deliberate decline of an orthonormality
        check from an oversight — an orthonormality check would also

Copilot AI review requested due to automatic review settings August 14, 2026 20:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/hofmann/model/view_state.py:282

  • _checked_projection uses isinstance, which accepts subclasses of Orthographic/Perspective/Oblique. The PR description states the projection-mode sum is closed and that bogus values raise TypeError at dispatch; allowing subclasses undermines that guarantee (a subclass can silently add fields/behaviour while still being treated as a valid mode). If the intent is a closed vocabulary, validate with an exact type(...) check instead of isinstance (or update the docs to explicitly allow subclasses).
        scaling, then zoom.  Every rendered object must obtain its
        screen positions through this method (directly or via
        :meth:`project`) so that all drawn geometry projects
        consistently.

        Args:
            camera: Array of shape ``(n, 3)`` in camera space, i.e.
                after centring and rotation.

Copilot AI review requested due to automatic review settings August 14, 2026 20:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

A determinant test accepts rank-deficient matrices whose determinant
rounds to a tiny non-zero value, and rejects well-conditioned matrices
whose determinant underflows.
Three docstrings pointed at prose that had been deleted, and a fourth
named the pre-branch value of the silhouette clamp.
A non-orthonormal rotation is accepted and distorts the drawn
structure.  Array-valued fields coerce sequences on construction but
are annotated as ndarrays.
Test docstrings and comments now state the behaviour under test rather
than what the code used to do or which mutations a test catches.
direction and up are checked for shape and finiteness on entry, so a
bad argument is reported by name instead of surfacing as a rotation
error, a numpy message, or a stray warning.
The rotation is now asserted unchanged after the zero-direction and
parallel-up rejections, not just the non-finite one.
The two byte-identical eye-distance blocks become one helper with a
named constant for the parallel-projection stand-in.  Also rename s_a
and s_b to screen_a and screen_b, reach to max_tip_reach, and
_checked_projection to _validated_projection.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

The tangent offset sqrt(r^2 - bond_r^2) and the test for whether the
two tangent points have crossed were each spelled out three times, in
_clip_bond_3d, _bond_polygon, and _bond_polygons_batch, with comments
tying the copies together.  Extract _tangent_offsets and
_tangent_points_crossed, which take scalars or arrays, and call them
from all three sites so the copies cannot drift.

_bond_polygon called _clip_bond_3d only to learn whether the bond was
occluded and discarded the returned points; it now applies the
crossing test directly and reuses the bond length it computes anyway.
The perspective silhouette radius divided by an inline expression whose
overflow-avoiding shape needed a twelve-line comment to justify.  Move
it into _sqrt_difference_of_squares, whose docstring carries the one
fact that matters, and name the clamp _MIN_SILHOUETTE_DENOM.

Hoisting abs_d and rs above the warning and naming the predicate
eye_inside_sphere removes the comment that pointed forward to the
clamp: the warning now says in code which condition it reports.
The oblique shear allowance and the perspective magnification were
applied in sequence, with a comment asserting that only one of them
can ever be active.  A match on the projection says the same thing in
the structure: one arm shears, one magnifies, one does neither.

The subject is the validated projection, so an unrecognised value
still raises rather than silently taking no allowance now that
screen_scale_bound is no longer read on every path.
The p, P, d, and D handlers each carried their own match over the
projection, 48 lines that differed only in the arithmetic applied, and
P computed its new strength twice.  Each adjustment is now a named
function from a perspective to its replacement, and _adjust_perspective
holds the single match that applies one, so the handlers name what they
do and nothing else.

The bare 1e-9 and 0.1 become _PERSPECTIVE_FLOOR and _MIN_VIEW_DISTANCE.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

@bjmorgan

Copy link
Copy Markdown
Owner Author

Superseded by #91, which adds oblique (axonometric) projection re-cut onto the merged sum-type and perspective-correctness refactors (#85-90). Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants