Files
orisys-app/apps/studio/tests/test_scene3d_gl_smoke.py
T
2026-09-27 10:42:38 +08:00

652 lines
27 KiB
Python

"""Stage 1 acceptance: a real GL context draws the scene, and survives its life.
This file deliberately does **not** force ``QT_QPA_PLATFORM=offscreen``: the
offscreen plugin cannot create a platform GL context at all ("this plugin does not
support createPlatformOpenGLContext"), and the design document is explicit that an
offscreen UI test cannot stand in for a GPU. Every test here skips itself when no
real context can be obtained, and asserts on pixels otherwise.
Run it with a real platform, e.g.::
QT_QPA_PLATFORM=windows python -m pytest tests/test_scene3d_gl_smoke.py
Two rules the tests themselves have to respect, learned the hard way here: the
widget is driven the way the application drives it (``show``/``repaint``, so Qt
owns the context), and pixels are read through Qt's own ``grabFramebuffer`` —
calling ``makeCurrent``/``initializeGL`` by hand creates the context *before* Qt
does, and the GPU objects then belong to a context that is thrown away.
"""
from __future__ import annotations
import os
import numpy as np
import pytest
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import (
ColorScale,
DisplayField,
DisplaySettings,
FieldDescriptor,
FrameIdentity,
RenderFrame,
freeze,
)
pytestmark = pytest.mark.skipif(
os.environ.get("QT_QPA_PLATFORM", "").lower() in ("offscreen", "minimal"),
reason="the offscreen/minimal plugin cannot create a platform GL context",
)
#: The renderer's clear colour in 8-bit, so "background" can be excluded from a
#: pixel assertion instead of dominating it.
BACKGROUND_RGB = np.array([15, 18, 23])
def _field(assets, values, valid=None, descriptor=None) -> DisplayField:
"""A display field on the scene's own domain, from a flat or gridded array.
The surface's colour and validity now come from the *field*, not from the vertices,
so a test that wants a particular picture builds one of these and hands it to the
widget. ``values`` is reshaped to the scene's ``[H,W]`` domain — which is what the
2D canvas would paint — and the fragment reads it through the mesh's UVs.
"""
shape = (int(assets.shape_hw[0]), int(assets.shape_hw[1]))
scalar = np.asarray(values, dtype=np.float32).reshape(shape)
mask = (
np.ones(shape, dtype=bool)
if valid is None
else np.asarray(valid, dtype=bool).reshape(shape)
)
return DisplayField(
domain_id=assets.field_domain_id,
shape_hw=shape,
scalar=freeze(np.array(scalar, dtype=np.float32), dtype=np.float32),
valid=freeze(mask, dtype=np.bool_),
descriptor=(
descriptor
or FieldDescriptor("flow_magnitude", "px", "approximate", False)
),
)
def _frame(assets, values, valid=None, *, frame_id=5, positions=None, normals=None,
descriptor=None, deformed=False) -> RenderFrame:
part = assets.sensing_part()
return RenderFrame(
identity=FrameIdentity("poc", 1, frame_id, 0.5, generation=1),
asset_hash=assets.asset_hash,
geometry_id=assets.geometry_id,
sensing_part_id=part.part_id,
positions_sensor_m=part.positions_sensor_m if positions is None else positions,
normals_sensor=part.normals_sensor if normals is None else normals,
field=_field(assets, values, valid, descriptor),
deformed=deformed,
)
def _pump(qt_app, widget, predicate, *, timeout_s: float = 5.0) -> bool:
"""Let Qt paint until ``predicate`` holds. Returns whether it did."""
import time
deadline = time.perf_counter() + timeout_s
while time.perf_counter() < deadline:
widget.repaint()
qt_app.processEvents()
if predicate():
return True
time.sleep(0.01)
return False
@pytest.fixture(scope="module")
def gl_widget(qt_app):
"""A real, shown QOpenGLWidget with the synthetic scene, driven by Qt."""
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.gl_backend import GLDispatch, GLRenderer, configure_surface_format
from orisys_studio.scene3d.widget import Sensor3DWidget
configure_surface_format("desktop33")
renderer = GLRenderer()
widget = Sensor3DWidget(renderer, OrbitCamera(), dispatch=GLDispatch())
failures: list = []
widget.diagnosticRaised.connect(failures.append)
widget.resize(240, 180)
widget.show()
widget.set_scene(synthetic.poc_scene())
painted = _pump(qt_app, widget, lambda: bool(renderer.part_ids), timeout_s=8.0)
if not painted:
widget.deleteLater()
pytest.skip(
"no usable GL context here: "
f"{widget.diagnostic() or 'the widget never initialized'}"
)
yield widget, renderer, failures
widget.release_resources()
widget.close()
widget.deleteLater()
qt_app.processEvents()
def _render(widget, qt_app) -> np.ndarray:
"""One Qt-driven paint, read back through Qt's own framebuffer grab.
``grabFramebuffer`` hands back a ``Format_RGB32`` image, whose bytes are BGRA
on a little-endian machine, with a stride that can exceed the width — so the
conversion and the stride trim are both load-bearing.
"""
from PySide6.QtGui import QImage
widget.repaint()
qt_app.processEvents()
image = widget.grabFramebuffer()
assert not image.isNull(), "Qt returned no framebuffer"
image = image.convertToFormat(QImage.Format.Format_RGB888)
width, height = image.width(), image.height()
# PySide6 returns a `memoryview` from constBits(), not a QByteArray: wrap it directly.
buffer = np.frombuffer(image.constBits(), dtype=np.uint8)
stride = image.bytesPerLine()
rows = np.frombuffer(buffer, dtype=np.uint8).reshape(height, stride)
return rows[:, : width * 3].reshape(height, width, 3).copy()
def _drawn(pixels: np.ndarray) -> np.ndarray:
"""Pixels that are not the clear colour, as ints."""
flat = pixels.reshape(-1, 3).astype(int)
background = np.abs(flat - BACKGROUND_RGB).max(axis=1) <= 3
return flat[~background]
def _plate_is_grey(pixels: np.ndarray) -> float:
"""Mean |r-g| of the drawn pixels. The neutral path is grey, the LUT is not."""
drawn = _drawn(pixels)
assert drawn.size, "nothing was drawn"
return float(np.abs(drawn[:, 0] - drawn[:, 1]).mean())
def _plate_mean_rgb(pixels: np.ndarray) -> np.ndarray:
"""The mean RGB of the drawn pixels: the plate's own colour, on a flat surface."""
drawn = _drawn(pixels)
assert drawn.size, "nothing was drawn"
return drawn.mean(axis=0)
def test_the_capabilities_come_from_a_real_context(gl_widget):
widget, _, _ = gl_widget
capabilities = widget.capabilities()
assert capabilities is not None
assert capabilities.vendor and capabilities.renderer
assert capabilities.profile == "desktop33", "a desktop context must compile desktop shaders"
assert not capabilities.software_renderer, "the dev machine must be driven by a GPU"
assert capabilities.max_texture_size >= 1024
assert widget.viewport_pixels() == (240, 180)
def test_both_parts_are_drawn_and_the_housing_can_be_hidden(gl_widget, qt_app):
widget, renderer, _ = gl_widget
assert set(renderer.part_ids) == {"sensing_surface", "housing"}
# ``DisplaySettings.housing_visible`` ships False (the frame dominated the
# picture), so the visible case has to be asked for explicitly rather than
# relying on the default — otherwise both renders below are the hidden one and
# the comparison cannot mean anything.
widget.set_settings(DisplaySettings(housing_visible=True))
with_housing = _drawn(_render(widget, qt_app))
assert with_housing.size, "nothing was drawn at all"
widget.set_settings(DisplaySettings(housing_visible=False))
without_housing = _drawn(_render(widget, qt_app))
assert 0 < without_housing.size < with_housing.size, (
"hiding the housing did not reduce what is on screen "
f"({without_housing.size} vs {with_housing.size} pixels)"
)
widget.set_settings(DisplaySettings(housing_visible=True))
def test_the_rest_frame_is_drawn_neutral_not_as_measurement_zero(gl_widget, qt_app):
"""No measurement yet: the plate must not be coloured as if it had one.
Asserted as a *colour identity*, not as a hue difference: the valid path must draw
the lookup table's own colour for the value it was given, and the neutral path must
draw the neutral colour. (The old form of this test compared mean ``|r-g|``, which
only worked because the 3D pane used a purple ramp whose low end is far from grey.
With one ramp serving both views the low end is the theme's own navy, where ``r≈g``,
so a hue test would have had to be loosened until it stopped meaning anything. A
colour-identity test is both stricter and independent of which ramp is in use.)
"""
from orisys_studio.scene3d.gl_backend import INVALID_COLOR, LUT_SIZE, lut_rgba
widget, _, _ = gl_widget
assets = synthetic.poc_scene()
widget.set_settings(DisplaySettings(housing_visible=False))
rest_rgb = _plate_mean_rgb(_render(widget, qt_app))
neutral_8bit = np.array(INVALID_COLOR[:3]) * 255.0
value = 0.05
frame = _frame(assets, np.full(assets.shape_hw, value, dtype=np.float32))
widget.set_settings(
DisplaySettings(
housing_visible=False,
color_scale=ColorScale(
descriptor=frame.field.descriptor,
lower=0.0,
upper=1.0,
lut_id="sequential",
),
)
)
widget.present(frame)
measured_rgb = _plate_mean_rgb(_render(widget, qt_app))
# Where the fragment shader samples the table: ``vec2(t, 0.5)`` on a 256x1 texture
# with linear filtering, i.e. ``t * 256 - 0.5`` clamped into the table.
table = lut_rgba("sequential")[:, 0, :3].astype(float)
position = float(np.clip(value * LUT_SIZE - 0.5, 0.0, LUT_SIZE - 1.0))
low = int(np.floor(position))
high = min(low + 1, LUT_SIZE - 1)
fraction = position - low
expected = (1.0 - fraction) * table[low] + fraction * table[high]
assert np.abs(rest_rgb - neutral_8bit).max() < 6.0, (
f"the unmeasured surface is not the neutral colour: {rest_rgb} vs {neutral_8bit}"
)
assert np.abs(measured_rgb - expected).max() < 8.0, (
"the colour map's own colour did not reach the plate: "
f"drawn {measured_rgb}, the table gives {expected} for {value} — the valid path "
"is not being taken, or the pane is not using the shared ramp"
)
assert np.abs(expected - neutral_8bit).max() > 10.0, (
"this value is too close to the neutral colour to make the point; pick another"
)
widget.set_settings(DisplaySettings(housing_visible=True))
def test_a_frame_updates_only_the_sensing_buffers(gl_widget, qt_app):
widget, renderer, _ = gl_widget
assets = synthetic.poc_scene()
count = int(assets.sensing_part().positions_sensor_m.shape[0])
positions = np.asarray(assets.sensing_part().positions_sensor_m).copy()
positions[:, 2] = 0.004 # a visible bump
frame = _frame(
assets,
np.linspace(0.0, 1.0, count).reshape(assets.shape_hw),
frame_id=6,
positions=freeze(positions, dtype=np.float32),
deformed=True,
)
widget.set_settings(
DisplaySettings(
color_scale=ColorScale(
descriptor=frame.field.descriptor, lower=0.0, upper=1.0,
lut_id="sequential",
)
)
)
# The housing's GPU buffers must be untouched by a surface update.
before = _buffer_names(renderer, "housing")
widget.present(frame)
assert _plate_is_grey(_render(widget, qt_app)) > 2.0, "the scalar was not colour-mapped"
assert _buffer_names(renderer, "housing") == before, (
"a dynamic surface update rebuilt the static housing buffers"
)
def _buffer_names(renderer, part_id: str) -> tuple[int, int, int]:
part = renderer._parts[part_id]
return (part.vao, part.vbo, part.ebo)
def test_a_frame_from_another_geometry_is_refused_without_losing_the_view(gl_widget, qt_app):
"""A bad frame is refused; the scene stays usable and stops showing it."""
widget, _, failures = gl_widget
before = widget.rejected_frames
frame = _frame(
synthetic.poc_scene(), np.zeros(synthetic.poc_scene().shape_hw, dtype=np.float32),
frame_id=2,
)
object.__setattr__(frame, "asset_hash", "other")
object.__setattr__(frame, "geometry_id", "some-other-geometry")
widget.present(frame)
assert _drawn(_render(widget, qt_app)).size, "the view died with a refused frame"
assert widget.rejected_frames == before + 1
assert any(item.code == "geometry_mismatch" for item in failures), (
"a frame from another geometry was accepted"
)
assert not widget.unavailable, "one bad frame must not take the whole view down"
def test_resize_is_followed(gl_widget, qt_app):
widget, _, _ = gl_widget
widget.resize(320, 240)
image = _render(widget, qt_app)
assert (image.shape[1], image.shape[0]) == (320, 240), image.shape
widget.resize(120, 90)
image = _render(widget, qt_app)
assert (image.shape[1], image.shape[0]) == (120, 90), image.shape
widget.resize(240, 180)
assert _drawn(_render(widget, qt_app)).size, "nothing was drawn after resizing back"
def test_release_is_idempotent_and_a_new_context_gets_the_scene_back(gl_widget, qt_app):
"""Closing must free everything; a rebuilt context must get the scene again.
A context rebuild is the case that matters: the GPU objects are gone while the
CPU asset is still here, so "the parts are still uploaded" would be a lie and
the window would come back empty. Qt calls ``initializeGL`` for a new context —
re-parenting does *not* rebuild one on Qt 5.15 (measured), so the test drives
that one call explicitly rather than pretending re-parenting did it.
"""
widget, renderer, _ = gl_widget
widget.release_resources()
assert not renderer.is_initialized
widget.release_resources() # idempotent: no crash, still released
assert not renderer.is_initialized
assert not renderer.part_ids, "release left GPU buffers behind"
# What Qt does when it hands the widget a new context.
widget.makeCurrent()
widget.initializeGL()
widget.doneCurrent()
recovered = _pump(qt_app, widget, lambda: bool(renderer.part_ids), timeout_s=8.0)
assert recovered, "the scene did not come back for the new context"
assert renderer.is_initialized
assert _drawn(_render(widget, qt_app)).size, "the scene is not on screen again"
def test_a_software_renderer_is_refused_with_its_capabilities(gl_widget, monkeypatch):
"""A board must not be signed off on llvmpipe, and the report has to say so."""
from orisys_studio.scene3d import gl_backend
widget, _, _ = gl_widget
monkeypatch.setattr(gl_backend, "is_software_renderer", lambda vendor, renderer: True)
with pytest.raises(gl_backend.ThreeDUnsupported) as excinfo:
gl_backend.GLDispatch().bind_current_context(widget.context())
capabilities = excinfo.value.capabilities
assert capabilities.software_renderer is True
assert any(item.code == "gl_unsupported" for item in capabilities.diagnostics)
assert "software" in excinfo.value.diagnostic.message.lower()
def test_a_broken_shader_takes_the_view_down_not_the_application(gl_widget, qt_app, monkeypatch):
"""Shader compilation failure is a diagnostic and an empty view, nothing more."""
from orisys_studio.scene3d import gl_backend
from orisys_studio.scene3d.camera import OrbitCamera
from orisys_studio.scene3d.widget import Sensor3DWidget
widget, _, _ = gl_widget
capabilities = widget.capabilities()
assert capabilities is not None
# The renderer refuses to come up, and says why.
def broken_source(profile, program):
return "#version 330 core\nvoid main() { this is not glsl }\n", "#version 330 core\n"
monkeypatch.setattr(gl_backend, "shader_source", broken_source)
renderer = gl_backend.GLRenderer()
widget.makeCurrent()
try:
with pytest.raises(Exception) as excinfo:
renderer.initialize(capabilities)
finally:
widget.doneCurrent()
item = getattr(excinfo.value, "diagnostic", None)
assert item is not None and item.code == "shader_compile_failed", excinfo.value
assert not renderer.is_initialized, "a failed initialize must not claim to be up"
# The widget turns the same failure into a state: a diagnostic, an empty view,
# and no exception on the way out.
monkeypatch.undo()
class FailingRenderer(gl_backend.GLRenderer):
def initialize(self, capabilities):
raise gl_backend.error("shader_compile_failed", "forced for this test")
failing = Sensor3DWidget(FailingRenderer(), OrbitCamera())
seen: list = []
failing.diagnosticRaised.connect(seen.append)
failing.resize(120, 90)
failing.show()
failing.repaint()
qt_app.processEvents()
failing.repaint() # a second paint of a failed view must be a no-op
qt_app.processEvents()
assert failing.unavailable
assert seen and seen[0].code == "shader_compile_failed"
failing.close()
failing.deleteLater()
def test_the_probe_reports_this_machine_truthfully():
"""The probe's contract: exit 0 only for a GPU-driven context."""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[3] / "tools" / "scene_assets" / "probe_gl.py"
spec = importlib.util.spec_from_file_location("probe_gl_tool", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
report = module.collect("desktop33", keep_open_s=0.3)
context = report.get("context")
assert context and context["valid"], report
assert context["profile_used"] == "desktop33"
assert not context["software_renderer"], "the dev machine has a real GPU"
assert report["renderer_initialized"] and report["released"]
# ------------------------------------------------- stage 4: the assembled view
class _Packet:
"""The parts of an ``SdkPacket`` the finger adapter and the identity need.
The provenance flags are part of the contract now: a producer has to say that the
array it hands over belongs to this frame, and the adapter refuses one that does
not say (see ``adapters._field_validity``).
"""
def __init__(self, frame_id: int, shape_hw: tuple[int, int], value: float = 0.5) -> None:
self.session_id = 1
self.frame_id = int(frame_id)
self.mono_t = float(frame_id) / 30.0
self.flow = np.full((*shape_hw, 2), float(value), dtype=np.float32)
self.raw_frame = np.zeros((4, 4, 3), dtype=np.uint8)
self.valid = True
self.invalid_reason = ""
self.flow_valid = True
self.flow_frame_id = int(frame_id)
self.depth = None
self.depth_valid = None
self.depth_frame_id = None
self.forces_valid = True
self.valid_mask = None
@pytest.fixture(scope="module")
def assembled_view(qt_app):
"""A real ``Scene3DView``: pane, coordinator, composer, widget, real context."""
from orisys_studio.scene3d.gl_backend import configure_surface_format
from orisys_studio.scene3d.view import Scene3DView
configure_surface_format("desktop33")
view = Scene3DView()
if not view.available:
pytest.skip(f"no GL view here: {view.reason}")
assets = synthetic.poc_scene()
if not view.use_scene(assets, source_id="poc-live"):
pytest.skip("the synthetic scene was refused")
view.resize(360, 300)
view.show()
view.set_enabled(True)
renderer = view.gl_widget._renderer
painted = _pump(qt_app, view.gl_widget, lambda: bool(renderer.part_ids), timeout_s=8.0)
if not painted:
view.close()
pytest.skip(f"no usable GL context here: {view.gl_widget.diagnostic()}")
yield view, renderer, assets
view.close()
view.close()
qt_app.processEvents()
def test_the_assembled_view_draws_what_the_acquisition_thread_hands_it(assembled_view, qt_app):
"""The whole stage-4 path: packet in, pixels out, and no recording involved."""
view, renderer, assets = assembled_view
widget = view.gl_widget
for index in range(30):
view.submit_packet(_Packet(index, assets.shape_hw, value=0.4 + index * 0.05))
view.tick()
qt_app.processEvents()
assert _pump(qt_app, widget, lambda: view.state() in ("live", "no_data"), timeout_s=5.0)
assert view.state() in ("live", "no_data"), view.state()
stats = widget._last_stats_at and None # stats are published on a timer
del stats
assert view.last_descriptor is not None, "the pane never learned what it is showing"
assert view.last_descriptor.quantity == "flow_magnitude"
assert view.last_descriptor.quality == "approximate"
drawn = _drawn(_render(widget, qt_app))
assert drawn.size, "the live frame was not drawn"
def test_the_producer_side_never_waits_for_the_gpu(assembled_view, qt_app):
"""The measured budget: the acquisition thread's own cost, with a slow composer.
This is the stage-3 property asserted through the *application's* entry point, so
it includes the adapter (the flow magnitude over the whole grid) and not just the
mailbox. The composer is slowed to 50 ms, which is far below any real frame rate:
frames must drop in the display and nowhere else.
"""
import time
view, _, assets = assembled_view
coordinator = view.coordinator
real_composer = coordinator._composer
class Slow:
def compose(self, *args, **kwargs):
time.sleep(0.05)
return real_composer.compose(*args, **kwargs)
def rest_frame(self, *args, **kwargs):
return real_composer.rest_frame(*args, **kwargs)
coordinator._composer = Slow()
try:
samples: list[float] = []
for index in range(200):
packet = _Packet(index, assets.shape_hw, value=0.5)
started = time.perf_counter()
view.submit_packet(packet)
samples.append((time.perf_counter() - started) * 1000.0)
view.tick()
ordered = sorted(samples)
p99 = ordered[int(len(ordered) * 0.99) - 1]
assert p99 < 1.0, f"submit p99 was {p99:.2f} ms with a 50 ms composer"
assert max(samples) < 5.0, f"submit peaked at {max(samples):.2f} ms"
# A slow composer must cost the *display* something, and nothing else. Two
# mechanisms account for it now: the mailbox supersedes what it never showed,
# and the producer declines to build a snapshot while one is already waiting
# (which is cheaper and loses nothing — the waiting frame is still the newest).
accounted = (
coordinator.superseded_frames + view.render_totals()["skipped_producer"]
)
assert accounted > 0, (
"a 50 ms composer produced no display-side drops at all: neither the mailbox "
"nor the producer budget is doing its job"
)
finally:
coordinator._composer = real_composer
# Drain everything the slow composer left behind, *including* the frames still
# waiting in the mailbox: this view is shared by the whole module, and a leftover
# frame would be uploaded by whichever test paints next.
deadline = time.perf_counter() + 8.0
while time.perf_counter() < deadline and (
coordinator._inflight is not None or coordinator.has_pending_frame()
):
coordinator.tick()
time.sleep(0.01)
assert coordinator._inflight is None and not coordinator.has_pending_frame(), (
"the slow-composer test did not drain before the next test"
)
def test_an_automatic_colour_scale_follows_the_frame(assembled_view, qt_app):
"""A range taken from the data, and a fixed range that ignores it."""
view, renderer, assets = assembled_view
widget = view.gl_widget
assets_frame = view.assets
assert assets_frame is not None
def frame_with(values: np.ndarray) -> RenderFrame:
return RenderFrame(
identity=FrameIdentity("poc-live", 1, 1, 0.0, generation=view.coordinator.generation),
asset_hash=assets.asset_hash,
geometry_id=assets.geometry_id,
sensing_part_id=assets_frame.sensing_part().part_id,
positions_sensor_m=assets_frame.sensing_part().positions_sensor_m,
normals_sensor=assets_frame.sensing_part().normals_sensor,
field=_field(assets_frame, values),
)
descriptor = FieldDescriptor("flow_magnitude", "px", "approximate", False)
auto = DisplaySettings(
housing_visible=False,
color_scale=ColorScale(
descriptor=descriptor, lower=0.0, upper=1.0, lut_id="sequential", mode="automatic"
),
)
fixed_wide = DisplaySettings(
housing_visible=False,
color_scale=ColorScale(
descriptor=descriptor, lower=0.0, upper=1000.0, lut_id="sequential", mode="fixed"
),
)
ramp = np.linspace(3.0, 7.0, int(assets_frame.shape_hw[0]) * int(assets_frame.shape_hw[1]))
widget.set_settings(fixed_wide)
widget.present(frame_with(ramp))
flat_pixels = _drawn(_render(widget, qt_app))
assert renderer._scalar_range is not None and renderer._scalar_range[0] > 2.0, (
"the frame's own values never reached the renderer"
)
widget.set_settings(auto)
widget.present(frame_with(ramp))
auto_pixels = _drawn(_render(widget, qt_app))
# The data spans 3..7. A fixed 0..1000 range squeezes all of it into the first
# fraction of a percent of the LUT (the ramp starts dark, so the picture is nearly
# black); an automatic range spreads it over the whole ramp. Mean brightness is the
# property that follows from that, and unlike "how many distinct colours" it does
# not depend on how much of the plate happens to be visible in this window.
flat_mean = float(flat_pixels.mean())
auto_mean = float(auto_pixels.mean())
assert auto_mean > flat_mean + 20.0, (
"an automatic range did not use the frame's own values "
f"(mean brightness: automatic {auto_mean:.1f}, fixed 0..1000 {flat_mean:.1f})"
)
def test_the_pane_releases_its_context_and_survives_a_second_close(qt_app):
"""`closeEvent` calls this; a second close must be a no-op, not a crash."""
from orisys_studio.scene3d.gl_backend import configure_surface_format
from orisys_studio.scene3d.view import Scene3DView
configure_surface_format("desktop33")
view = Scene3DView()
if not view.available:
pytest.skip("no GL view here")
if not view.use_scene(synthetic.poc_scene(), source_id="poc-close"):
pytest.skip("the synthetic scene was refused")
view.resize(200, 160)
view.show()
view.set_enabled(True)
renderer = view.gl_widget._renderer
if not _pump(qt_app, view.gl_widget, lambda: bool(renderer.part_ids), timeout_s=8.0):
view.close()
pytest.skip("no usable GL context here")
view.close()
assert not renderer.is_initialized
assert not renderer.part_ids, "closing the pane left GPU buffers behind"
view.close()
view.close()
qt_app.processEvents()