Skip to content

Add clipping planes, axes inset, and named click intersections to Scene#5993

Draft
Jepson2k wants to merge 2 commits intozauberzeug:mainfrom
Jepson2k:scene-clipping-axes
Draft

Add clipping planes, axes inset, and named click intersections to Scene#5993
Jepson2k wants to merge 2 commits intozauberzeug:mainfrom
Jepson2k:scene-clipping-axes

Conversation

@Jepson2k
Copy link
Copy Markdown

@Jepson2k Jepson2k commented Apr 24, 2026

Motivation

Three related Scene additions that come up constantly in 3D-visualization apps and round out the interactivity story:

  • Clipping planes — hide parts of a mesh against arbitrary plane equations to "see inside" articulated geometry.
  • Orientation axes inset — small XYZ gizmo in the corner that follows the camera, so the user always knows which way is "up". Click an axis to snap-animate the camera along it.
  • Named click intersections — declare named planes once, then read each click ray's intersection with each plane on every click event. Generalizes the common "click ground to place" workflow without hard-coding a single ground plane.

Plus raycaster_threshold becomes a real watched prop instead of an init-only value.

This is one of several small slices being carved out of #5964 per the splitting discussion on that thread.

Implementation

Clipping planes (nicegui/elements/scene/scene_object3d.py, nicegui/elements/scene/scene.js, nicegui/events.py):

  • New SceneClipPlane(nx, ny, nz, d) dataclass.
  • Object3D.set_clipping_planes(planes: list[SceneClipPlane]) and clear_clipping_planes() chained methods.
  • JS-side: builds a THREE.Plane per spec, applies as material.clippingPlanes to every mesh descendant, and flips the renderer's localClippingEnabled flag on first use. clipIntersection = false means clipping is the union across planes.

Axes inset (Scene.set_axes_inset, Scene.set_axes_labels):

  • Typed kwargs (no untyped dict[str, Any]):
    Scene.set_axes_inset(*, enabled=True, size=128, margin=0,
                         margin_x=None, margin_y=None,
                         anchor='bottom-right')  # -> Self
    Scene.set_axes_labels(*, enabled=True, labels=('X', 'Y', 'Z'),
                          font='24px Arial', color='#000000', radius=14)  # -> Self
  • JS-side: lazily constructs Three.js' ViewHelper, then composites it into the configured corner inside the existing render loop using manual scissor + viewport math (renderer.setScissor / renderer.setViewport). This works on the ^0.180.0 three.js pin — no need for the ViewHelper.location.left/right/top/bottom field added in r184.
  • set_axes_labels honors the labels tuple via viewHelper.setLabels(...) and forwards font/color/radius to viewHelper.setLabelStyle(...) (a public, JSDoc-documented method on r180 with defaults '24px Arial' / '#000000' / 14). Toggling the inset off then on re-applies the cached labels and style on the rebuilt ViewHelper.
  • Click an axis to snap the camera: pointer events on the canvas are forwarded to viewHelper.handleClick(event) via a capture-phase listener. r180's handleClick has a hardcoded dim = 128 and assumes the inset is at the canvas's bottom-right, so we synthesize event coordinates that make its math compute the correct NDC for our actual inset rect (works for any anchor/size). When the click hits an axis, the listener calls event.stopImmediatePropagation() so OrbitControls/DragControls don't also see the click and start a drag-rotate. Subsequent click3d event emission is suppressed for that pointerdown so apps don't see a stray click on whatever the underlying scene happens to be at the inset's screen rect.

Named click intersections (Scene(intersection_planes=[...]), SceneClickEventArguments.intersections):

  • New SceneIntersectionPlane(name, axis: 'x' | 'y' | 'z', offset) dataclass and ScenePoint(x, y, z) dataclass.
  • Scene constructor gains intersection_planes: list[SceneIntersectionPlane] | None = None. Empty list / None → empty intersections dict on every click → zero per-click cost for opted-out users.
  • JS-side: pre-computes a THREE.Plane per spec at mount and on prop change (with cloned axis-normal vectors so the per-axis singleton is never mutated), then on every click does raycaster.ray.intersectPlane(plane, point) per configured plane.
  • SceneClickEventArguments.intersections: dict[str, ScenePoint | None] — every configured plane appears in the dict on every click; the value is None when the ray misses, so callers can distinguish "plane not configured" from "plane didn't intersect" without .get() ambiguity.

Raycaster threshold (Scene(raycaster_threshold=...)):

  • Now a real watched prop. Runtime changes to the line / points hit-test threshold take effect without remounting the scene.

Tests (tests/test_scene.py):

  • test_set_clipping_planes — apply + clear, assert each material's clippingPlanes array is populated then cleared.
  • test_set_axes_inset_and_labels — toggle on, set custom labels with non-default font/color/radius, assert the cached opts are reflected on the JS side, toggle off then on, assert the cached labels/style survive the rebuild.
  • test_axes_inset_handle_click_snaps_camera — dispatch a pointerdown over the +X axis sprite at its computed screen position, assert viewHelper.animating flips on, then off when the snap-animation completes.
  • test_intersection_planes_in_click_event — configure 'ground' and 'wall' planes, click the canvas, assert both names appear in e.intersections.
  • test_raycaster_threshold_runtime_change — change the prop after mount and assert the raycaster's Line.threshold updates.

Documentation (website/documentation/content/scene_documentation.py): three new demos covering clipping planes, the axes inset (now showing the click-to-snap interaction and font/color/radius), and click-plane intersections.

Progress

  • The PR title is a short phrase starting with a verb like "Add ...", "Fix ...", "Update ...", "Remove ...", etc.
  • The implementation is complete. (Otherwise, open a draft PR.)
  • This PR does not address a security issue. (Security fixes must be coordinated via the security advisory process before opening a PR.)
  • Pytests have been added.
  • Documentation has been added.
  • No breaking changes to the public API. (SceneClickEventArguments gains an intersections field with a default-factory empty dict.)

Jepson2k and others added 2 commits April 24, 2026 10:56
Three related Scene additions:

**Clipping planes** (`Object3D.set_clipping_planes` / `clear_clipping_planes`):
Apply arbitrary `nx*x + ny*y + nz*z + d = 0` clip planes to an object and
all of its mesh descendants. Useful for "see inside an articulated mesh"
views. Backed by Three.js' per-material `clippingPlanes`; the renderer's
`localClippingEnabled` flag is flipped on first use. New
`SceneClipPlane` dataclass for the plane spec.

**Axes inset overlay** (`Scene.set_axes_inset` / `set_axes_labels`):
Toggleable orientation gizmo (Three.js' `ViewHelper`) anchored to a
configurable corner with explicit pixel size and margins. The inset is
positioned via manual scissor + viewport math inside the existing render
loop (compatible with the current `^0.180.0` three.js pin — no need for
the `ViewHelper.location.left/right/top/bottom` field added in r184).
Custom labels honored via `setLabels`; the documented (but previously
unimplemented) `font` / `color_x/y/z` / `size` knobs are intentionally
not exposed because Three.js' ViewHelper doesn't surface them through a
stable public API.

**Named click intersections** (`Scene(intersection_planes=[...])`,
`SceneClickEventArguments.intersections`): the host application declares
named planes (axis + offset along that axis) and reads the click ray's
intersection with each plane on every click event as
`e.intersections[name]`. Each entry is a `ScenePoint` (with `.x/.y/.z`)
when the ray hits the plane, or `None` when it misses. Empty plane list
means an empty dict — no per-click cost for users who don't opt in.
Generalizes the common "click ground to place" workflow without
hard-coding a single ground plane.

`raycaster_threshold` is also now a real prop with a Vue watcher, so
runtime changes to the line/points hit-test threshold take effect
without remounting the scene.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
- handleClick: forward canvas pointerdown to viewHelper.handleClick via a
  capture-phase listener with synthesized event coords (so r180's hardcoded
  dim=128 / bottom-right math works for any anchor/size), stopImmediate-
  Propagation so OrbitControls/DragControls don't race on the same
  pointerdown, and suppress click3d emission for the consumed pointerdown.
- setLabelStyle: extend Scene.set_axes_labels with font/color/radius kwargs
  forwarded to viewHelper.setLabelStyle (public, JSDoc-documented on r180);
  cache opts and re-apply after viewHelper rebuild on enable/disable cycles.
- Review fixes: drop spurious add_rename calls for the new props, make
  set_axes_inset/set_axes_labels return Self, drop defensive .get() in
  _handle_click, move per-frame viewport reset into the inset block (no
  cost when inset is disabled), drop redundant Object.assign merge, clone
  shared axis-normal vectors, replace fixed mount-wait sleeps with a
  deterministic _wait_for_scene_ready helper polling renderer readiness.
- Tests: add test_axes_inset_handle_click_snaps_camera, extend
  test_set_axes_inset_and_labels with style + rebuild persistence assertions.
- Docs: axes_inset demo now shows click-to-snap and color/radius kwargs.
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.

1 participant