Stop the G-code preview from sizing the box that sizes it (issue #2887)

Opening a 3D Preview from Archives left an empty white pane with the
legend and the layer slider drawn over it, and the page's scrollbar
shrank for as long as it stayed open -- about 190px of page height a
second, with no limit.

The viewer appends its canvas into the very element it measures with
clientWidth/clientHeight and watches with a ResizeObserver. setSize
writes each new size onto the canvas as inline style, and three.js
leaves the canvas display:inline, so the line box adds descender space
on top of the height just set. Where that element takes its height from
its contents, the canvas sizes the box that sizes the canvas and gains a
fixed 33px every round -- the reporter measured container = canvas + 33
on every sample.

The page gave it no height to take instead. The viewer pane is flex-1
min-h-0, which divides nothing unless the column above it is a definite
height, and h-full is a percentage resolved against a main area whose
own height comes from a min-height -- a floor, not a size. So it fell
through to the content, and the content was the canvas.

Nothing was ever drawn because of the same loop, not a second fault:
every observer callback reallocated and cleared the frame buffer, and an
antialiased render of what had grown to roughly 18 megapixels never
finished before the next one arrived. The data path was fine throughout,
which the legend and the 1..57 layer slider both prove -- they are built
from the parsed toolpath.

The canvas is now positioned out of flow, so it cannot contribute to the
height of the element that measures it on any page, and that element
takes a definite height from the pane around it rather than a
percentage. display:block goes on too, for the case where something
overrides the positioning. The page is sized from the viewport the way
the File Manager page already was.

Either change alone stops the growth, but the structural one alone would
trade it for a collapsed pane: an out-of-flow canvas contributes nothing
to content height, so with no definite height above it the pane becomes
clientHeight || 1. They belong together.

The same viewer in the File Manager dialog was never affected -- a
dialog gives it a fixed height, so neither fault could arise there.

jsdom does no layout, so the loop cannot be reproduced in a test. The
structure that forbids it can: the new cases assert the canvas is out of
flow, that the measured element is definite-height rather than h-full,
and that the pane stays positioned so inset-0 resolves against it.
Reverting either change fails exactly those.
This commit is contained in:
maziggy
2026-08-22 09:05:12 +02:00
parent 4961990a7a
commit 3916db822c
9 changed files with 167 additions and 8 deletions
@@ -256,6 +256,19 @@ export function GcodeToolpathViewer({
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// Take the canvas out of flow before it is ever in the document (#2887).
// `setSize` writes the size onto the canvas as inline width/height, and the
// canvas lives inside the very element we measure and observe — so on a page
// where that element's height comes from its content, each resize grew the
// container, which fired the observer, which resized again. three.js leaves
// the canvas `display: inline`, so the line box added its descender space
// (~33px) every round and the page climbed without limit. Out of flow it
// cannot contribute to the container's height at all; `display: block` is
// belt and braces for the same descender, and matters if this is ever
// rendered somewhere the absolute positioning is overridden.
renderer.domElement.style.display = 'block';
renderer.domElement.style.position = 'absolute';
renderer.domElement.style.inset = '0';
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
@@ -437,7 +450,18 @@ export function GcodeToolpathViewer({
return (
<div className={`relative ${className}`}>
<div ref={containerRef} className="w-full h-full" />
{/*
Absolute, not `w-full h-full` (#2887). The canvas is appended here and
this element is what the ResizeObserver watches, so its height must come
from the pane above it and never from what it contains. `h-full` is a
percentage, which resolves to `auto` unless every ancestor has a definite
height — on the full-page route none does, so the height fell through to
the content and the canvas ended up sizing the box that sizes the canvas.
`inset-0` against the `relative` parent is a definite height whatever the
page does, which also keeps this working if a future caller forgets to
give the pane a height of its own.
*/}
<div ref={containerRef} className="absolute inset-0" />
{loading && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-bambu-dark/60 text-sm text-bambu-gray">