From f3a96cd9c49f1d0e9d1f7d69dc81a0529fcf3232 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 2 Oct 2026 17:53:08 +0100 Subject: [PATCH] Visualizer3D: clip planes from the camera's distance, not fixed at 20 m Both cameras were made with fixed clip planes - the perspective one draws to 20 m, the orthographic one 20 m either side - and only frameBox refitted them, for the perspective camera alone. A facility framed whole stands further off: a 14 x 22 m lab opens with the camera 29.3 m above it, so the plan view, the home button and the projection toggle drew nothing. fitClipping(distance) sizes both cameras' planes from the camera's distance to its target; frameBox and setProjection call it. Co-Authored-By: Claude Opus 5.5 --- pylabrobot/visualizer3D/browser_tests.py | 26 ++++++++++++++++++++++ pylabrobot/visualizer3D/static/renderer.js | 20 +++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/pylabrobot/visualizer3D/browser_tests.py b/pylabrobot/visualizer3D/browser_tests.py index 04156acf6e1..c96ce111536 100644 --- a/pylabrobot/visualizer3D/browser_tests.py +++ b/pylabrobot/visualizer3D/browser_tests.py @@ -701,6 +701,32 @@ async def test_a_tree_that_changes_shape_keeps_the_models_it_had(self): self.assertGreater(len(seen), 0, "nothing was sampled while the scene was rebuilt") self.assertEqual(min(seen), drawn, f"the models went away and came back: {seen}") + async def test_a_facility_framed_from_far_off_is_drawn(self): + """Both cameras had fixed clip planes - 20 m deep - and only the perspective one was refitted + when a view was framed. A lab framed whole stands further off than that, so its plan view, the + home button and the projection toggle drew nothing at all.""" + lab = Facility(name="lab", size_x=14_000, size_y=22_000, size_z=3_000) + for i in range(4): + bench = Resource(name=f"bench_{i}", size_x=8_000, size_y=1_600, size_z=900, category="bench") + lab.assign_child_resource(bench, location=Coordinate(3_000, 2_500 + i * 5_000, 0)) + fs_port, ws_port = free_ports(2) + viewer = Viewer3D(lab, open_browser=False, fs_port=fs_port, ws_port=ws_port) + await viewer.start() + try: + async with Browser() as browser: + await browser.open(viewer.url) + await browser.settle("window.plrViewer && window.plrViewer.resources().includes('bench_3')") + await browser.frames() + self.assertGreater(await browser.drawn_fraction("#viewport"), 0.02, "the opening plan view") + await browser.evaluate("document.getElementById('view-projection').click()") + await browser.frames() + self.assertGreater(await browser.drawn_fraction("#viewport"), 0.02, "after the toggle") + await browser.evaluate("document.getElementById('home-button').click()") + await browser.frames() + self.assertGreater(await browser.drawn_fraction("#viewport"), 0.02, "after home") + finally: + await viewer.stop() + @unittest.skipUnless(CHROME, "no headless browser to drive") class SimulationTests(unittest.IsolatedAsyncioTestCase): diff --git a/pylabrobot/visualizer3D/static/renderer.js b/pylabrobot/visualizer3D/static/renderer.js index bff492ca005..31bdf370bf8 100644 --- a/pylabrobot/visualizer3D/static/renderer.js +++ b/pylabrobot/visualizer3D/static/renderer.js @@ -132,6 +132,15 @@ function sizeOrthographic(distance) { orthographicCamera.updateProjectionMatrix(); } +// Clip planes from how far the camera stands from what it frames. Fixed planes cut away anything +// framed from further off than they reach: a whole facility is framed from 29 m above. +function fitClipping(distance) { + perspectiveCamera.near = Math.max(distance / 1000, 0.1); + perspectiveCamera.far = distance * 50; + orthographicCamera.near = -distance * 50; + orthographicCamera.far = distance * 50; +} + // The axis presets live on the view helper: click an axis there and the camera animates onto it. // What the helper cannot do is choose a projection, so that button stays. export const projectionButton = document.getElementById("view-projection"); @@ -141,6 +150,7 @@ export function setProjection(kind) { const target = controls.target.clone(); const position = camera.position.clone(); const distance = position.distanceTo(target); + fitClipping(distance); projection = kind; camera = kind === "orthographic" ? orthographicCamera : perspectiveCamera; @@ -227,13 +237,9 @@ export function frameBox(box, direction) { controls.target.copy(centre); camera.position.copy(centre).add(direction.clone().normalize().multiplyScalar(distance)); - if (projection === "orthographic") { - fitOrthographic(w, h); - } else { - camera.near = Math.max(distance / 1000, 0.1); - camera.far = distance * 50; - camera.updateProjectionMatrix(); - } + fitClipping(distance); + if (projection === "orthographic") fitOrthographic(w, h); + else camera.updateProjectionMatrix(); // A drag leaves OrbitControls holding a rotation it has not finished applying: with damping on it // spends that residue over the following frames and only decays it, so it outlives the pointer // going up. Framing writes the camera straight to where it belongs, and the residue then turns it