Add oblique (axonometric) projection support - #83
Conversation
…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.
There was a problem hiding this comment.
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 throughViewState.project_camera, including cell edges and the axes widget. - Updates interactive session state handling to deepcopy/restore full
ViewState, and makespa 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.
There was a problem hiding this comment.
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.perspectiveis 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 > 0is false) while leaving a non-zeroperspectivein 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_camerais documented to accept shape(n, 3), but currently a common “single point” call likeview.project_camera([x, y, z])will raise anIndexErroratcamera[:, 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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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_projectionusesisinstance, which accepts subclasses ofOrthographic/Perspective/Oblique. The PR description states the projection-mode sum is closed and that bogus values raiseTypeErrorat 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 exacttype(...)check instead ofisinstance(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.
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.
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.
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_matrixonViewState. A newproject_cameramethod (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 incell_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:
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), whereangleis 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.Perspectiveownsstrengthandview_distance; each mode validates its own parameters (finiteness included). Zero-strength perspective is unrepresentable -- orthographic isOrthographic(), removing the old magic-zero encoding.projectionvalue raisesTypeErrorat construction and at the dispatch).Breaking change from 0.19
ViewState.perspective,ViewState.view_distance, and the interimobliquefield are removed.slots=Truemakes stale spellings fail loudly:view.perspective = 0.3raisesAttributeErrorrather than silently creating an inert attribute. Perspective strength and viewing distance move ontoPerspective.Also in this change
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.ViewStategenerically (deepcopy and field-wise restore) instead of hand-enumerating fields; thep/Pkeys form a seamless ladder into and out ofOrthographic()(with a float-residue tolerance so descent never strands on a near-zero perspective), andd/Dact only in perspective mode.look_alongdocstring is corrected: the camera sits along+directionlooking 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
mainfor 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 indata_nbo2f_chirality(fig_p3121_legend.py): coordinates match to floating-point tolerance and depths exactly. Once released, that figure can drop its_oblique_rotationworkaround.