From 343df056915e343b4833be12133cfe927b59fb2d Mon Sep 17 00:00:00 2001 From: Hugo Uittenbosch Date: Fri, 6 Nov 2026 15:06:06 +0100 Subject: [PATCH] Own views on the page "Results": card_views returns CardViews of points, a field or a series, whose functions the window calls after full solves and only draws; the views of a Detector are such views, with the power of BeamletOptics; results_view! places the page of any object in an own figure, spot_colors colors the spots per hit - CardView(f, form; name, label, unit, normalize, preview, text), card_views(obj): a series records one value per full solve of every object, also without a shown card, "clear" empties it - results_view!(position, [gui,] obj), refresh!, close: shares the options with the cards, without a window computed on build and on refresh! - The power of the intensity view is optical_power(samples, I) of BeamletOptics; the GUI keeps only display helpers (centroid, radii) - AGENTS.md: the GUI displays results, it does not compute them - The buttons of a segmented switch start with a color as stroke color: a switch built while a view expands failed to draw - Precompile session: own views, the series, results_view! with and without a window Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 23 +- docs/src/api.md | 12 + docs/src/assets/examples/live_card_views.jl | 50 ++ docs/src/live_view.md | 31 +- docs/src/widgets.md | 64 ++- ext/BeamletOpticsGUIGLMakieExt/session.jl | 124 ++++ skills/beamletopticsgui/API.md | 3 +- skills/beamletopticsgui/SKILL.md | 10 +- skills/beamletopticsgui/VISUALIZATION.md | 24 +- skills/beamletopticsgui/WIDGETS.md | 34 ++ src/API.jl | 142 ++++- src/BeamletOpticsGUI.jl | 4 +- src/LiveCardRows.jl | 11 +- src/LiveCardViews.jl | 43 ++ src/LiveComponents.jl | 10 +- src/LiveDetectorView.jl | 601 ++++++++++++-------- src/LiveDetectors.jl | 216 +++++-- src/LiveLinks.jl | 5 +- src/LiveResultsView.jl | 191 +++++++ src/LiveView.jl | 20 +- src/LiveWidgets.jl | 7 +- test/TestLiveApp.jl | 2 +- test/TestLiveCard.jl | 12 +- test/TestLiveCardViews.jl | 197 +++++++ test/TestLiveDetectorView.jl | 108 ++-- test/TestLiveDetectors.jl | 12 +- test/TestLiveInspector.jl | 14 +- test/TestLiveResultsView.jl | 262 +++++++++ test/TestLiveSplitter.jl | 4 +- test/TestLiveView.jl | 10 +- 30 files changed, 1863 insertions(+), 383 deletions(-) create mode 100644 docs/src/assets/examples/live_card_views.jl create mode 100644 src/LiveCardViews.jl create mode 100644 src/LiveResultsView.jl create mode 100644 test/TestLiveCardViews.jl create mode 100644 test/TestLiveResultsView.jl diff --git a/AGENTS.md b/AGENTS.md index 2db6a87..cf36301 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,9 @@ describe it as available in docs or the skill. | # | Principle | Gap in 0.3 | |---|---|---| | P1 | The GUI sits on BeamletOptics systems and visualizes them. All physics is computed by BeamletOptics. Components, sources and systems can be added to and removed from the scene in the GUI. One view holds several systems with several beams each; an object belongs to any number of systems or to none and is drawn once, a source to at most one, and each system is traced on its own. | `StaticSystem`s can not be changed; only components and sources from the catalog can be changed afterwards (page "Edit"); `export_script` writes the constructors of those and of the objects added with `code` (`add_component!`), since the constructor of an object from the user's script is not known otherwise; the glasses of the catalog (`catalog_glasses`) are data of the GUI until BeamletOptics has a glass catalog | -| P2 | The layout is built from widgets, one per BeamletOptics type (e.g. the card of a lens, of a detector, of a system). Widgets without a type are generic windows (`add_panel!`, `add_controls!`, `add_tool!`). | the pages of a card and its view ("Results") are chosen per type by internal traits (`_has_page`, `_has_view`), only `Detector` has a view; tree icons per type are internal | +| P2 | The layout is built from widgets, one per BeamletOptics type (e.g. the card of a lens, of a detector, of a system). Widgets without a type are generic windows (`add_panel!`, `add_controls!`, `add_tool!`). | the pages of a card and its view ("Results") are chosen per type by internal traits (`_has_page`; the view from `card_views`, `_has_view`); tree icons per type are internal | | P3 | Any layout can be assembled from the widgets and presented to the user. Layouts are composed in code from public widget blocks; `:compact` and `:app` are two such compositions. | the layout interface `AbstractLiveLayout` is internal, the widgets have no public constructors | -| P4 | An extensible API lets users with own BeamletOptics types define widgets that the GUI loads (`card_rows`, `card_actions`, `CardWidget`, `card_input`, `card_show!`; a component package adds them in a package extension on BeamletOpticsGUI). | own views (the page "Results") and icons per type | +| P4 | An extensible API lets users with own BeamletOptics types define widgets that the GUI loads (`card_rows`, `card_actions`, `CardWidget`, `card_input`, `card_show!`; a component package adds them in a package extension on BeamletOpticsGUI). | icons per type | **Rules that follow from them:** @@ -42,6 +42,14 @@ describe it as available in docs or the skill. `initialize!`, never by the GUI (no `empty!` of a detector): once for all systems before their sources are solved (`_compute`), since the sources superpose on a detector, and for a component that is removed. +- **The GUI displays results, it does not compute them.** Results come from BMO or from the + user's own calculations, which the GUI calls and draws. The GUI computes only lightweight + helpers for showing them, such as a centroid or an RMS radius to place a marker, a cut line, + limits or a normalization. Such a helper takes data that BMO returned, is closed-form (sums, + moments, extrema, scaling; no tracing, no integration with the weights of a sampling, no fits), + is marked as a display helper where it is defined, and is never returned by public API: no + widget or view hands results to the user. A value that a user would want in a script belongs in + BMO. - **A solve in the background owns the beams and detectors**, from its start until its result is shown or it is cancelled. Nothing else changes or reads them meanwhile: changes of objects go through `_change!`, which cancels the solve first, and code that reads the beams or the hits @@ -83,10 +91,13 @@ describe it as available in docs or the skill. one of them again as it is and place it with the mouse. Adding, removing and changing are actions of the undo history of the controls (`_push_action!` in `src/LiveInteraction.jl`, `_record_added!` in `src/LiveComponents.jl`). -- `src/LiveDetectorView.jl`: the view of a detector on the page "Results" of its card: the kinds - of views per type of hits (spot diagram, PSF, intensity), their results and metrics, and the - widget (thumbnail, zoomable plot). `src/LiveDetectors.jl`: the state of the view per detector, - its computation only while it is shown, and the `detectors` kwarg. +- `src/LiveCardViews.jl`: `CardView`, `card_views` and the built-in views of a `Detector` (spot + diagram, PSF, intensity) as `card_views(::Detector)`. `src/LiveDetectorView.jl`: the view of the + page "Results" of a card: the drawing per form (`:points`, `:field`, `:series`), their results and + display metrics, and the widget (thumbnail, zoomable plot). `src/LiveDetectors.jl`: the state of + the view per object with views, its computation only while it is shown, the recording of the + series after every full solve, and the `detectors` kwarg. `src/LiveResultsView.jl`: + `results_view!`, `ResultsView`, `refresh!`: the widget of the page "Results" in a figure of its own. - `src/LiveTrace.jl`, `src/LiveProgress.jl`: solving, progress window. `src/LiveSystemTrace.jl`: tracing per system (`_system_auto`, `_set_system_auto!`, `_trace_system!`, `_trace_set`: systems that share a `Detector` are traced together, the outdated beams of a system). `src/LiveClip.jl`, `src/LiveMeasure.jl`, `src/LiveCamera.jl`, diff --git a/docs/src/api.md b/docs/src/api.md index 64d1266..bd4a572 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -70,6 +70,18 @@ card_input card_show! ``` +## Results views + +The views of the page "Results" of a card and their placement in a figure of your own. + +```@docs +CardView +card_views +results_view! +ResultsView +refresh! +``` + ## Agent skill ```@docs diff --git a/docs/src/assets/examples/live_card_views.jl b/docs/src/assets/examples/live_card_views.jl new file mode 100644 index 0000000..375041a --- /dev/null +++ b/docs/src/assets/examples/live_card_views.jl @@ -0,0 +1,50 @@ +using BeamletOptics, BeamletOpticsGUI, GLMakie + +# An own component type: a screen that absorbs the rays and keeps the points where they hit it +# (x, z relative to its center [m], valid while the screen faces the beam along y without rotation) +struct Screen{T} <: BeamletOptics.AbstractObject{T} + shape::BeamletOptics.PlanoSurfaceSDF{T} + hits::Vector{Point2{Float64}} + lock::ReentrantLock +end + +Screen(width, thickness) = Screen(BeamletOptics.PlanoSurfaceSDF(thickness, width), + Point2{Float64}[], ReentrantLock()) + +# Called for every ray that hits the screen (the solve may use several threads) +function BeamletOptics.interact3d(::BeamletOptics.AbstractSystem, s::Screen, ::Beam, ray::Ray) + p = position(ray) + length(ray) * direction(ray) - position(s) + lock(() -> push!(s.hits, Point2(p[1], p[3])), s.lock) + return nothing +end + +# The hits of the last solve are discarded before the next one +BeamletOptics.initialize!(s::Screen) = (lock(() -> empty!(s.hits), s.lock); nothing) + +# The functions of the views: they only read the screen and return what is shown +hit_count(s::Screen) = length(s.hits) +hit_points(s::Screen) = copy(s.hits) + +# The page "Results" of the card of a screen: the first view is shown first. `:series` records the +# count after each full solve and draws it over the solves, `:points` draws a spot diagram. +BeamletOpticsGUI.card_views(::Screen) = ( + CardView(hit_count, :series; name = :count, label = "Count", unit = "rays", + text = (s, n) -> "$(Int(n)) rays hit the screen"), + CardView(hit_points, :points; name = :spot, label = "Hits")) + +# A narrow beam and a screen of 10 mm width behind it +beam = CollimatedSource([0.0, 0, 0], [0.0, 1, 0], 4e-3, 1e-6; num_rings = 2, num_rays = 40) +screen = Screen(10e-3, 2e-3) +translate3d!(screen, [0.0, 0.1, 0.0]) +system = System([screen]) + +# Select the screen and drag it sideways: the count over the solves and the spots follow. A script +# gets the same numbers by calling `hit_count(screen)` after `solve_system!`. +gui = live_view(system, beam; labels = Dict(screen => "screen")) + +# Open the interactive window when used from the REPL or run as a script +if isinteractive() + display(gui) +elseif abspath(PROGRAM_FILE) == @__FILE__ + wait(display(gui)) +end diff --git a/docs/src/live_view.md b/docs/src/live_view.md index de6eb4c..22e9ef7 100644 --- a/docs/src/live_view.md +++ b/docs/src/live_view.md @@ -231,8 +231,10 @@ its visible part. The results of a detector are on its card. Every card has pages, chosen by a page bar below its head: "Pose" (the rows of [`card_rows`](@ref); on the card of the selection also the keyboard step and the Move/Rotate mode) -and "Properties" (the property list of the object, see `properties`). The card of a `Detector` -has a third page, "Results", between them, which shows the detector view. The card of a source (a +and "Properties" (the property list of the object, see `properties`). The card of an object with +[`card_views`](@ref) has a third page, "Results", shown first, which shows the view of the object, e.g. the +detector view of a `Detector`; an own type adds its views the same way, see +[Own results on a card](@ref). The card of a source (a beam, a beam group or a Gaussian beamlet) has the page "Color": a menu sets the color in which it is drawn ("wavelength" draws every ray in the display color of its own wavelength (`wavelength_color` of BeamletOptics), so the beams of a group with several wavelengths, or the beams behind a @@ -245,8 +247,8 @@ is traced again, and they are not part of [`export_changes`](@ref). In a script, `beam_kwargs = Dict(source => (; color = :orange, alpha = 0.5, linewidth = 2))` sets them from the start; `color = :wavelength` (or `(:wavelength, alpha)`) is the default mode. If a long solve runs in the background while the color or the length changes, that solve starts again. A card with a single -page, e.g. of an inspected point or a measurement, has no page bar. A card opens on "Results" for a -detector and on "Pose" for every other object; a pinned card keeps its page. +page, e.g. of an inspected point or a measurement, has no page bar. A card opens on "Results" for an +object with views and on "Pose" for every other object; a pinned card keeps its page. The view is expanded by default. Its chevron collapses it to a thumbnail (92 px) with the kind, the key value (the power or the number of rays), the centroid and the radii; a click on the @@ -268,13 +270,20 @@ peak) and at the powers of ten with "log". The image is scaled to its maximum af i.e. it looks the same while the intensity changes; the ticks of the bar show the change. Use `colorrange` for a fixed scale. -The kinds depend on the hits, the first is the default: +The kinds are the [`CardView`](@ref)s of [`card_views`](@ref) for a `Detector` (the same API as for an +own type, see [Own results on a card](@ref)), and depend on the hits; the first is the default. With +the kind `:auto`, a view whose function throws gives way to the next one. A `Detector` keeps its page +"Results" without hits, where it reads "no hits": | Hits | Kinds | |:-----|:------| | rays (`RayHit`, `PolarizedRayHit`) | "Spot" (spot diagram), "PSF" (the `intensity` of the rays, which BeamletOptics returns unscaled, hence shown normalized to its peak, with the metrics without the power) | | Gaussian beamlets (`GaussianBeamletHit`, `AstigmaticGaussianBeamletHit`) | "Intensity" (in W/m², with the power), "Spot" (the 1/e² outlines) | +The power `P` in the line below the intensity is integrated by BeamletOptics (`optical_power` of the +samples and the intensity of the view, in the BeamletOptics release that this version of the GUI +requires); the GUI computes no power itself. + A chosen kind that the current hits do not offer falls back to their default and applies again when they offer it. Without hits, the view reads "no hits". Moving a detector (or a group that contains it) empties it (BeamletOptics 0.14): with auto trace off, its view reads "no hits" until @@ -303,7 +312,8 @@ computations slower than `trace_budget` show a coarse preview first. Only shown views are computed: the page "Results" of the card of the selected detector or of a pinned card that is not collapsed. A thumbnail is computed on a grid of at most 48 points per axis. A view that is shown later is computed from the hits of the last solve. The spectator mode -hides the cards and with them the views. +hides the cards and with them the views. The exception is a `:series` view of an own type, which is +recorded after every full solve whether or not a card shows it. The floating card with an expanded view is resizable: a grip at its bottom right corner changes its size from 160 px up to the size of the 3D view. In the sidebar of the app layout, the view @@ -337,11 +347,20 @@ view passes on to it: | `colorbar = true` | `false` hides the colorbar below the plot of a field | | `profiles = false` | `true` adds the axis with the intensity along x and z through the centroid | | `expanded = true` | `false` starts the pinned card with the thumbnail | +| `spot_colors = nothing` | `hit -> color`: the color of each spot of the spot view, a beamlet contour in the color of its hit | ```julia gui = live_view(system, beam; detectors = [pd => (:intensity, (; colorscale = :log, profiles = true))]) ``` +```julia +gui = live_view(system, beam; detectors = [pd => (:spot, (; spot_colors = hit -> :red))]) +``` + +The view of a detector can also be placed into a figure of your own with [`results_view!`](@ref), +e.g. in a second window; it takes the same options (`view`, `expanded`, `spot_colors`) and shares them +with the cards of the detector, see [Views in an own figure](@ref). + The option `n` is now `num_points` and throws an `ArgumentError`. The option `history` is no longer available and throws an `ArgumentError` as well: record the values in `on_change` and plot them in a panel of [`add_panel!`](@ref), see [Own panels, controls and tools](@ref) diff --git a/docs/src/widgets.md b/docs/src/widgets.md index 385efc0..bb23703 100644 --- a/docs/src/widgets.md +++ b/docs/src/widgets.md @@ -18,7 +18,8 @@ copied unchanged. | widgets for something in the scene: a component, a group, a source, a system, an own `AbstractObject` subtype | a card | [`card_rows`](@ref), [`card_actions`](@ref) | | a parameter without a scene object, e.g. a setting of the whole setup | a controls section | [`add_controls!`](@ref) | | an action, e.g. a button or a toggle with a key | a tool | [`add_tool!`](@ref) | -| a plot or a result that is updated after each solve | a panel | [`add_panel!`](@ref) | +| a result of an object that is updated after each solve (points, a field, a value over the solves) | a view on its card | [`card_views`](@ref) | +| a plot or a result without an object that is updated after each solve | a panel | [`add_panel!`](@ref) | A card needs a scene object because the object gives the card its anchor (the card is placed next to the bounding box of the object and follows it), its selection (the card shows what is selected, @@ -339,6 +340,61 @@ function add_tilt_controls!(gui, mirror) end ``` +## Own results on a card + +A type that computes something worth showing, e.g. the hits of an own detector, gets the page +"Results" of its card with a method of [`card_views`](@ref) that returns [`CardView`](@ref)s. The +GUI only draws: a `CardView` holds a function `f` that computes what is shown from the object, and +a form that says how the window draws it. The window hands no result back; a script that wants the +numbers calls `f` itself. The card of an object with views shows "Results" first, with a switch +between its views (the first is the default); a `Detector` has views of its own, see +[Detector view](@ref). + +| Form | `f` returns | Drawn as | +|:--|:--|:--| +| `:points` | `f(obj)`: the points `(x, z)` [m], or `(points, colors)` | spot diagram, centroid cross, number, centroid, RMS and largest radius | +| `:field` | `f(obj; num_points, window)`: `(x, z, I)` | image, colorbar in the `unit`, "log", profiles; zoom calls `f` again with the new `window` | +| `:series` | `f(obj)`: a number | the values of the full solves as a line, the last value, "clear" | + +`f` runs in the background task of the solve, reads the object and must not change plots or +observables. An error of `f` is shown in the view. With `preview = false` (the default) `f` is not +called for the previews while an object is dragged, only after the full solve; `preview = true` +also calls it for those. The centroid, the radii and the color scale are computed by the window to +draw the view, they are not results of the view. + +A series is recorded by the GUI: one value after each full solve for every object of the systems +that has a `:series` view, also while no card shows it. While an object is dragged nothing is +recorded, the value comes once the movement pauses. The window keeps at most 10 000 values per +object and view, "clear" empties them, removing the object drops them and undo keeps them. + +The full [script](https://github.com/StackEnjoyer/BeamletOpticsGUI.jl/blob/main/docs/src/assets/examples/live_card_views.jl) +defines a screen that records its hits, and shows the count as a series and the hits as points: + +```julia +BeamletOpticsGUI.card_views(::Screen) = ( + CardView(hit_count, :series; name = :count, label = "Count", unit = "rays", + text = (s, n) -> "$(Int(n)) rays hit the screen"), + CardView(hit_points, :points; name = :spot, label = "Hits")) +``` + +Like the other card functions, a package adds the method in its package extension. To show such a +view in a figure of your own, e.g. next to the window, use [`results_view!`](@ref), see +[Views in an own figure](@ref). + +### Views in an own figure + +[`results_view!`](@ref) places the view of the page "Results" of an object into the position of a +figure, e.g. in a second window. With the window `gui` it shares the view that is shown, the grid, +the color scale and the zoom with the cards of the object and updates after each solve. Without a +window it computes when it is built, on [`refresh!`](@ref) and on its own widgets (a series shows +only its current value there). `close(view)` removes it. + +```julia +fig = Figure() +rv = results_view!(fig[1, 1], gui, screen; view = :spot) +display(GLMakie.Screen(), fig) +``` + ## Checklist for a new component - `interact3d(system, object, beam, ray)`: required, see Core design page of BeamletOptics. @@ -346,6 +402,7 @@ end hits of an own detector. It discards them; the live view calls it before every solve. - `render!`: optional, how the component is drawn. - `card_rows` (and `card_actions`): optional, its rows and buttons on the card of the live view. +- `card_views`: optional, its views on the page "Results" of the card, see [Own results on a card](@ref). - `BeamletOptics.properties`: optional, the properties listed on the page "Properties" of the card and of the inspector, see `properties`. @@ -362,6 +419,11 @@ CardRow CardWidget card_input card_show! +CardView +card_views +results_view! +ResultsView +refresh! add_controls! add_tool! ``` diff --git a/ext/BeamletOpticsGUIGLMakieExt/session.jl b/ext/BeamletOpticsGUIGLMakieExt/session.jl index f75387b..62a4147 100644 --- a/ext/BeamletOpticsGUIGLMakieExt/session.jl +++ b/ext/BeamletOpticsGUIGLMakieExt/session.jl @@ -7,6 +7,27 @@ runs `f`: the workload swallows the error of a step, `test/TestPrecompileSession A patch that adds an action of the mouse or the keys to the GUI adds a step here, see `AGENTS.md`. =# +# An own type for the views of a card: a beam block that counts the rays that it absorbs, with one +# view per form (a series, a field and points) +struct _Counter{T} <: BMO.AbstractObject{T} + shape::BMO.PlanoSurfaceSDF{T} + count::Threads.Atomic{Int} +end +_Counter(width, thickness) = _Counter(BMO.PlanoSurfaceSDF(thickness, width), Threads.Atomic{Int}(0)) +BMO.interact3d(::BMO.AbstractSystem, c::_Counter, ::BMO.AbstractBeam, ::BMO.Ray) = + (Threads.atomic_add!(c.count, 1); nothing) +BMO.initialize!(c::_Counter) = (c.count[] = 0; nothing) +function _counter_field(c::_Counter; num_points, window) + x0, x1, z0, z1 = something(window, (-1e-3, 1e-3, -1e-3, 1e-3)) + x, z = range(x0, x1; length = num_points), range(z0, z1; length = num_points) + return (x, z, [c.count[] * exp(-(xi^2 + zi^2) / 1e-7) for xi in x, zi in z]) +end +_counter_points(::_Counter) = ([Makie.Point2(1e-3 * i, 0.0) for i in 1:3], [:red, :green, :blue]) +GUI.card_views(::_Counter) = ( + CardView(c -> c.count[], :series; name = :count, label = "Count", unit = "rays"), + CardView(_counter_field, :field; name = :map, label = "Map", unit = "rays/m²"), + CardView(_counter_points, :points; name = :marks, label = "Marks")) + """ The scene of `_session`: a `system` with a `mirror`, a `lens` and a `detector`, and its `beam`. The fields have no types, such that the session is not compiled again for the types of another scene. @@ -1004,6 +1025,109 @@ function _session(step, layout::Symbol, fixture::_Fixture = _fixture()) _check(!GUI._debug_on(gui), "the toggle did not switch the debug mode off") end + # The views of a card (`card_views`): the spot colors of the detector, an own type with a view per + # form (switch, series over two solves, "clear"), and the views in a figure of their own + step("views: spot colors of the detector") do + GUI._select!(gui, fx.detector) + settle!() + GUI._set_view!(gui, fx.detector; kind = :spot, spot_colors = _ -> :red) + settle!() + state = GUI._detector_state(gui, fx.detector) + _check(!isnothing(state.opts.spot_colors) && state.opts.kind === :spot, "the spot colors were not set") + GUI._set_view!(gui, fx.detector; kind = :auto, spot_colors = nothing) + settle!() + _check(isnothing(state.opts.spot_colors), "the spot colors were not reset") + deselect!() + end + counter = Ref{Any}(nothing) + own_view() = (c = counter[]; + cards = filter(k -> GUI._card_object(gui, k) === c, GUI._edit_cards(gui)); + _check(!isempty(cards) && !isnothing(last(cards).view), "no card shows the page Results of the own object"); + last(cards).view) + step("views: own object, switch through its views, series, clear") do + c = _Counter(20e-3, 2e-3) + translate3d!(c, [0.1, 0.2, 0]) + counter[] = c + add_component!(gui, c; select = false) + settle!() + _check(GUI._has(fx.system.objects, c), "the own object was not added") + GUI._select!(gui, c) + settle!() + state = GUI._detector_state(gui, c) + for key in (:map, :marks, :count) + v = own_view() + _check(!isnothing(v.switch) && key in v.switch.keys, "the switch has no view :$key") + _click!(gui, _rect_center(v.switch.buttons[findfirst(==(key), v.switch.keys)])) + settle!() + _check(state.opts.kind === key, "the switch did not choose the view :$key") + end + n = length(state.series[:count]) + retrace!(gui) + settle!() + retrace!(gui) + settle!() + _check(length(state.series[:count]) == n + 2, "two solves did not record two values") + _click!(gui, _rect_center(own_view().clear_button)) + settle!() + _check(isempty(state.series[:count]), "the button clear did not empty the series") + end + step("views: results_view! in a figure with the live view, options, close") do + c = counter[] + fig = Figure(; size = (400, 400)) + rv = results_view!(fig[1, 1], gui, c; view = :map) + w = getfield(rv, :widget) + s = _screen() + try + display(s, fig) + Makie.colorbuffer(s) + notify(w.switch.buttons[findfirst(==(:marks), w.switch.keys)].clicks) + settle!() + Makie.colorbuffer(s) + _check(GUI._detector_state(gui, c).opts.kind === :marks, "the switch of the figure did not choose :marks") + notify(w.collapse_button.clicks) + notify(w.expand_button.clicks) + refresh!(rv) + settle!() + Makie.colorbuffer(s) + close(rv) + _check(getfield(rv, :closed) && !any(r -> r[2] === w, gui.detectors.registered), + "close did not remove the view") + finally + close(s) + end + end + step("views: results_view! without a live view, options, collapse, expand, refresh") do + c = counter[] + fig = Figure(; size = (400, 400)) + rv = results_view!(fig[1, 1], c; view = :map) + w = getfield(rv, :widget) + s = _screen() + try + display(s, fig) + Makie.colorbuffer(s) + w.log_toggle.active[] = true + w.profiles_toggle.active[] = true + notify(w.switch.buttons[findfirst(==(:marks), w.switch.keys)].clicks) + Makie.colorbuffer(s) + _check(getfield(rv, :opts).kind === :marks, "the switch did not choose :marks") + notify(w.collapse_button.clicks) + _check(!w.expanded, "the collapse button did not collapse the view") + notify(w.expand_button.clicks) + _check(w.expanded, "the expand button did not expand the view") + refresh!(rv) + Makie.colorbuffer(s) + close(rv) + _check(getfield(rv, :closed), "close did not close the view") + finally + close(s) + end + end + step("views: remove the own object") do + remove_component!(gui, counter[]) + settle!() + _check(!GUI._has(fx.system.objects, counter[]), "the own object was not removed") + end + if layout == :app tree = gui.layout.tree step(() -> (tree.clicked[] = fx.detector; settle!()), "tree: click detector") diff --git a/skills/beamletopticsgui/API.md b/skills/beamletopticsgui/API.md index 4372aa6..c14ac41 100644 --- a/skills/beamletopticsgui/API.md +++ b/skills/beamletopticsgui/API.md @@ -28,6 +28,7 @@ Look up the docstring of any name before use, e.g. | Interactive helpers | `kinematic_controls!`, `view_cube!` | | Extending the window | `add_panel!`, `add_controls!`, `add_tool!` | | Cards | `card_rows`, `pose_card_rows`, `beam_card_rows`, `card_actions`, `CardRow`, `CardWidget`, `card_input`, `card_show!` | +| Results views | `CardView`, `card_views`, `results_view!`, `ResultsView`, `refresh!` | Not exported: `BeamletOpticsGUI.install_agent_skill`. @@ -40,7 +41,7 @@ Not exported: `BeamletOpticsGUI.install_agent_skill`. | `sidebar_width = (240, 300)`, `dock_height = nothing` | app layout only (compact ignores them): start widths [px] of the left and right sidebar (a number sets both, each within 160 and 600) and start height [px] of the dock (within 80 and 70 % of the figure height; `nothing`: 36 % of the window); outside the limits throws `ArgumentError`. In the window: drag the edge of a sidebar or the upper edge of the dock, a double click restores the start size | | `labels = Dict(obj => "name")` | names in cards, status line and menus | | `constraints = Dict(obj => (; move = (), rotate = (:x, :z)))` | lock local axes `:x`, `:y`, `:z` of components (`:v` is an error) | -| `detectors = :auto` or `[]`, or a vector (`pd`, `pd => :spot`, `pd => (:intensity, (; num_points, colorscale, colorrange, colorbar, profiles, expanded, x_min, ...))`) | `:auto` and `[]`: every detector has the page "Results" on its card, none is pinned at start; a vector pins the cards of these detectors at start with these options (kind `:auto`, `:spot`, `:psf` or `:intensity`; other entries are keywords of `BeamletOptics.RegularGrid`, e.g. `x_min` or `crop_factor`; `n` (now `num_points`) and `history` throw `ArgumentError`) | +| `detectors = :auto` or `[]`, or a vector (`pd`, `pd => :spot`, `pd => (:intensity, (; num_points, colorscale, colorrange, colorbar, profiles, expanded, spot_colors, x_min, ...))`) | `:auto` and `[]`: every detector has the page "Results" on its card, none is pinned at start; a vector pins the cards of these detectors at start with these options (kind `:auto`, `:spot`, `:psf` or `:intensity`; `spot_colors = hit -> color` colors each spot of the spot view, a beamlet contour in the color of its hit; other entries are keywords of `BeamletOptics.RegularGrid`, e.g. `x_min` or `crop_factor`; `n` (now `num_points`) and `history` throw `ArgumentError`) | | `sliders = ["label" => (range, callback)]` | custom parameters (callbacks in SI units); compact: entry "Sliders" of the tool rail, app: "Parameters" | | `on_change = (gui, obj) -> ...` | called after each full solve, with the moved object or `nothing` | | `extras = [housing => (; color = ...)]` | objects without a system: shown, movable, but never traced (listed under "No system" in the tree; removable; can become a member of a system later) | diff --git a/skills/beamletopticsgui/SKILL.md b/skills/beamletopticsgui/SKILL.md index e38109d..e48520e 100644 --- a/skills/beamletopticsgui/SKILL.md +++ b/skills/beamletopticsgui/SKILL.md @@ -120,10 +120,12 @@ When this Skill is active: BeamletOptics (`translate_to3d!`, `rotate3d!`) and call `retrace!`. - The window has two fixed layouts (`layout = :compact` or `:app`); there are no public widget blocks to assemble an own layout. -- The detector view (page "Results" of a card) exists for `Detector` only; there is no public API for - views of own types. Own plots are added with `add_panel!`. -- There are no detector panels beside the 3D view or in the dock and no `history` option: record - values in `on_change` and plot them in an `add_panel!` panel. +- The views of the page "Results" are points, a field or a series (`CardView`); a view can not draw + into its axis itself. Other own plots are added with `add_panel!`. The window hands no results back: + a script calls the function of a view itself. +- There are no detector panels beside the 3D view or in the dock and no `history` option: a value + over the solves is a `:series` view of `card_views`, or recorded in `on_change` and plotted in an + `add_panel!` panel. Do not describe these as available. diff --git a/skills/beamletopticsgui/VISUALIZATION.md b/skills/beamletopticsgui/VISUALIZATION.md index d0721ee..3926780 100644 --- a/skills/beamletopticsgui/VISUALIZATION.md +++ b/skills/beamletopticsgui/VISUALIZATION.md @@ -26,8 +26,8 @@ object: shows it again). The pencil next to its label renames a component, a sou with its component, so several components can be edited side by side. Dragging the head of a card moves it to a fixed place in the view (kept when pinned; double click on the head: back next to its component). Every card has pages, chosen by a page bar below its head: "Pose" (the pose -boxes and the rows of the type) and "Properties"; the card of a `Detector` has "Results" between -them (details below), the card of a source "Color" (a menu of colors with "wavelength" and +boxes and the rows of the type) and "Properties"; the card of an object with `card_views` (a +`Detector`, or an own type, see `WIDGETS.md`) has "Results" between them, shown first (details below), the card of a source "Color" (a menu of colors with "wavelength" and "layout", a box for a hex value or a color name, sliders for the opacity and the line width; display only, the same as `beam_kwargs = Dict(source => (; color, alpha, linewidth))` from a script; sources start with `color = :wavelength`: every ray in the display color of its own @@ -138,6 +138,26 @@ pins the card at start (floating next to the detector in compact, docked in the app); other entries are keywords of `BeamletOptics.RegularGrid` (the sampling of the field), `x_min`/`x_max`/`z_min`/`z_max` set the area that "fit" shows. `history` is gone (`ArgumentError`): record in `on_change`, plot in `add_panel!`. +The views of a `Detector` are its `card_views` (Gaussian beamlets: Intensity and Spot; rays: Spot and +PSF); with kind `:auto` a view whose function throws gives way to the next. The power on the Intensity +view is integrated by BeamletOptics (`optical_power`). A `Detector` keeps its page without hits +("no hits"). `spot_colors = hit -> color` in the options colors each spot (a beamlet contour in the +color of its hit): `detectors = [pd => (:spot, (; spot_colors = hit -> :red))]`. + +**A view in a second window.** `results_view!(position, gui, obj; view = :auto, expanded = true, +width = 280, height = 280, spot_colors = nothing)` places the view of the page "Results" of `obj` (any +object with `card_views`) into a figure of your own; it shares view, grid, scale and zoom with the +cards of `obj` and updates after each solve. `results_view!(position, obj; theme = :light, name = +"Results", ...)` without a window computes when built, on `refresh!(v)` (e.g. after `solve_system!`) +and on its own widgets; a series shows only its current value there. `close(v)` removes it. It only +draws: it returns no data, call the function of the `CardView` for the numbers. + +```julia +gui = live_view(system, beam) +fig = Figure() +v = results_view!(fig[1, 1], gui, pd; view = :spot) +display(GLMakie.Screen(), fig) # v follows every solve of gui; close(v) when done +``` Own GUI parts go into a `gui = live_view(...)` via three functions that work with both `layout = :compact` and `layout = :app` (the layout places them); do not place blocks at fixed diff --git a/skills/beamletopticsgui/WIDGETS.md b/skills/beamletopticsgui/WIDGETS.md index a042682..5e9d7a3 100644 --- a/skills/beamletopticsgui/WIDGETS.md +++ b/skills/beamletopticsgui/WIDGETS.md @@ -19,6 +19,7 @@ puts these methods into a package extension (`[weakdeps] BeamletOpticsGUI`, | widgets for something in the scene: a component, group, source, system, own `AbstractObject` subtype | a card | `card_rows`, `card_actions` | | a parameter without a scene object (a setting of the whole setup) | a controls section | `add_controls!` | | an action (button, or toggle, optionally with a key) | a tool | `add_tool!` | +| a result of an object (points, a field, a value over the solves) | a view on its card | `card_views` | | a plot or result updated after each solve | a panel | `add_panel!` (see `VISUALIZATION.md`) | A card needs a scene object: it gives the card its anchor (next to the bounding box, follows the @@ -334,6 +335,38 @@ moves it to where it is placed. `return gui -> ...` when one is wanted. - A card keeps no state: do not capture `gui` or the object in the widget declarations. +## Recipe: own results on the card (`card_views`) + +An object with views gets the page "Results" on its card (shown first, with a switch between its +views). The GUI only draws: it hands no result back, a script gets the numbers by calling the same +function. Define `BeamletOpticsGUI.card_views(x::MyType)` (in a package extension like the other card +functions) and return a tuple of `CardView(f, form; name, label, unit, normalize, preview, text)`: + +| Form | `f` returns | Drawn as | +|:--|:--|:--| +| `:points` | `f(obj)`: points `(x, z)` [m], or `(points, colors)` | spot diagram, centroid cross, N / c / rms / max | +| `:field` | `f(obj; num_points, window)`: `(x, z, I)` | image, colorbar in `unit`, log, profiles; zoom calls `f` again with the new `window` | +| `:series` | `f(obj)`: a number | one value per full solve as a line, "clear" | + +- `f` runs in the background task of the solve: it reads the object and must not touch plots or + observables. An error of `f` is shown in the view. `preview = false` (default): not called for the + previews while an object is dragged. `text(obj, data)` adds a line below the view. +- A series is recorded after every full solve for every object of the systems that has a series view, + also while no card shows it, but not while the object is dragged (the value comes when the movement + pauses). At most 10 000 values per object and view; "clear" empties them; removing the object drops + them; undo keeps them. +- The method may look at the object (e.g. offer a view only with hits) and must be cheap. + +```julia +# A screen that records its hits: the count over the solves and the hits as a spot diagram +BeamletOpticsGUI.card_views(::MyScreen) = ( + CardView(s -> length(s.hits), :series; name = :count, label = "Count", unit = "rays"), + CardView(s -> copy(s.hits), :points; name = :spot, label = "Hits")) # hits: Vector of Point2 +``` + +`results_view!(fig[1, 1], gui, obj; view = :spot)` shows the same view in a figure of your own, see +`VISUALIZATION.md`. + ## Checklist for a new component - `interact3d(system, object, beam, ray)`: required. @@ -341,4 +374,5 @@ moves it to where it is placed. hits of an own detector). It discards them; the live view calls it before every solve. - `render!`: optional, how it is drawn. - `card_rows` (and `card_actions`): optional, its rows and buttons on the card. +- `card_views`: optional, its views on the page "Results" of the card. - `BeamletOptics.properties`: optional, the properties on the page "Properties" of the card. diff --git a/src/API.jl b/src/API.jl index 691253f..a6c4d0a 100644 --- a/src/API.jl +++ b/src/API.jl @@ -29,9 +29,11 @@ source belongs to at most one system. Systems are added and removed in the windo none, and each system is traced on its own; see "Systems" and "Tracing per system" in the method below. -The card of a `Detector` has a page "Results" with its detector view (spot diagram, PSF or -intensity, with metrics such as centroid, RMS or 1/e² radius and power), which collapses to a -thumbnail and, as a field, has a colorbar and can be shown on a logarithmic color scale with profiles. A click on a beam shows its position, path +The card of an object with views (see [`card_views`](@ref)), e.g. a `Detector`, has a page +"Results" with them: of a detector the spot diagram, the PSF or the intensity, with the centroid, +the RMS or 1/e² radius and the power of BeamletOptics, which collapses to a thumbnail and, as a +field, has a colorbar and can be shown on a logarithmic color scale with profiles; the same view +is placed into a figure of its own by [`results_view!`](@ref). A click on a beam shows its position, path length and, for Gaussian beamlets, its radius and curvature, the "measure" toggle measures distances and angles between components and beams. The key `g` zooms to the selection, "home", the "views" menu and "save view" set and store camera views. While moving, beam groups are solved @@ -40,7 +42,7 @@ only for their rendered beams (`preview = true`), the full group once the moveme Main keyword arguments: `detectors` (`:auto` or `[]`: every detector has its page "Results", no card is pinned at start; a vector of `pd`, `pd => kind` or `pd => (kind, kwargs)` pins the cards of these detectors at start, with `kind` one of `:auto`, `:spot`, `:psf` and `:intensity` and the -options `num_points`, `colorscale`, `colorrange`, `colorbar`, `profiles` and `expanded`), `on_change = (gui, obj) -> nothing` (called after full solves), +options `num_points`, `colorscale`, `colorrange`, `colorbar`, `profiles`, `expanded` and `spot_colors = hit -> color`), `on_change = (gui, obj) -> nothing` (called after full solves), `sliders = ["label" => (range, callback)]`, `system_kwargs`, `beam_kwargs`, `preview = true`, `views = ["name" => (eye, lookat, up)]`, `lighting = :studio` (see `BeamletOptics.studio_lighting!`), `edges` and `size`. `extras = [obj => render_kwargs, ...]` adds objects without a system that are @@ -414,6 +416,138 @@ unless it is called with `force = true`, which the card does after an input was """ function card_show! end +""" + CardView(f, form::Symbol; name::Symbol, label = string(name), unit = "", normalize = false, + preview = false, text = nothing) + +A view on the page "Results" of a card of [`live_view`](@ref), see [`card_views`](@ref): the +function `f` computes what the view shows from the object of the card, the `form` says how the +window draws it. The window only draws: it hands no result of `f` back, a script that wants the +numbers calls `f` itself. + +`f` is called after each full solve of the window, not after the previews while an object moves +(unless `preview = true`), in a background task: it reads the object and calls BeamletOptics or +own code, but must not change plots or observables. An error of `f` is shown in the view. The +forms and what `f` returns: + +- `:points`: `f(obj)` returns the points `(x, z)` [m] of a spot diagram, e.g. 2-vectors or + `Point2`s, or a tuple `(points, colors)` with a color per point. The view marks their centroid + and shows their number, centroid, RMS radius and largest distance from the centroid. +- `:field`: `f(obj; num_points, window)` returns `(x, z, I)`, the values `I[i, j]` of a field at + `x[i]`, `z[j]` [m], on a grid of `num_points` per axis in the `window` + `(x_min, x_max, z_min, z_max)` [m], or in limits of its own for `window = nothing`. The view + shows it as an image with a colorbar in the `unit`, `normalize = true` divides it by its peak, and + it marks its centroid and shows its 1/e² radii. Zoom and pan call `f` again with the new `window`. +- `:series`: `f(obj)` returns a number, which the window records once per full solve and draws + over the solves, labeled with the `unit`. The window keeps the last 10 000 values of each object + and view, "clear" on the page empties them, and they are dropped when the object is removed. + +`name` identifies the view, e.g. in the `detectors` kwarg of [`live_view`](@ref) and in +[`results_view!`](@ref), `label` is its text in the switch of the views. `text(obj, data)`, if +given, returns a line shown below the view, with the `data` that `f` returned, e.g. a value that +BeamletOptics computes from it; like `f`, it runs in the background task. + +The centroid, the radii and the scales of the colorbar are computed by the window only to draw the +view, from what `f` returned. +""" +struct CardView + f::Any + form::Symbol + name::Symbol + label::String + unit::String + normalize::Bool + preview::Bool + text::Any +end + +# The forms of a `CardView` +const _CARD_VIEW_FORMS = (:points, :field, :series) + +function CardView(f, form::Symbol; name::Symbol, label::AbstractString = string(name), + unit::AbstractString = "", normalize::Bool = false, preview::Bool = false, text = nothing) + form in _CARD_VIEW_FORMS || + throw(ArgumentError("the form of a CardView must be one of $_CARD_VIEW_FORMS, got :$form")) + return CardView(f, form, name, String(label), String(unit), normalize, preview, text) +end + +Base.show(io::IO, v::CardView) = print(io, "CardView(:", v.name, ", :", v.form, ")") + +""" + card_views(obj) -> Tuple{Vararg{CardView}} + +The views on the page "Results" of the card of `obj` in [`live_view`](@ref), a tuple of +[`CardView`](@ref)s, chosen by multiple dispatch like [`card_rows`](@ref); the first one is shown +by default. A card whose object has views gets the page "Results", which it shows first, with a +switch between them. By default none; a `Detector` has the intensity and the spot diagram of its +beamlet hits, or the spot diagram and the PSF of its ray hits, none without hits. + +Add a method for an own type, e.g. in a package extension on BeamletOpticsGUI: + +```julia +BeamletOpticsGUI.card_views(pd::MyPhotodiode) = ( + CardView(:series; name = :power, label = "Power", unit = "W") do pd + BeamletOptics.optical_power(pd.detector) + end,) +``` + +The method may look at `obj`, e.g. offer a view only while it has hits; it is called whenever a +card shows `obj` and after each solve, and must be cheap. The view with a `name` keeps its options +(e.g. the color scale or the zoom) per object while the views of the method change. +""" +function card_views end + +""" + results_view!(position, gui::LiveView, obj; view = :auto, expanded = true, width = 280, height = 280, + spot_colors = nothing) -> ResultsView + results_view!(position, obj; theme = :light, name = "Results", view = :auto, expanded = true, + width = 280, height = 280, spot_colors = nothing) -> ResultsView + +Places the view of the page "Results" of the card of `obj` into the `position` of a figure, a +`GridPosition` such as `fig[1, 2]` or a `GridLayout`, e.g. in a second window next to the +[`live_view`](@ref) window `gui`. `obj` is any object with [`card_views`](@ref), e.g. a `Detector`. +`view` is the `name` of the [`CardView`](@ref) that is shown, `:auto` for the first one; the switch +of the view changes it later. The view starts `expanded` (the axis of `width` × `height` pixels with +its controls) or collapsed to its thumbnail. `spot_colors = hit -> color` colors the spots of the +spot diagram of a `Detector` per hit, like the option of the `detectors` kwarg of +[`live_view`](@ref). + +With the window `gui`, the view shows `obj` like its cards: it is computed after each solve of the +window, and the view that is shown, the grid, the color scale and the zoom are those of `obj` in +the window, which a change in any of its views changes in all. A `view` other than `:auto` and +`spot_colors` other than `nothing` are set for all of them, the other options stay. The name of the +view is the label of `obj` in the window. + +Without a window, the view shows the current state of `obj` when it is built and changes only on +[`refresh!`](@ref) (e.g. after `solve_system!`) and on its own widgets, with options of its own and +the colors of the `theme` (`:light` or `:dark`); `name` is its name in the view. A `:series` shows +only its current value there, since only a window records the values of its solves. + +Throws an `ArgumentError` if `obj` has no views or if no view of `obj` has the name `view`. A +`Detector` without hits offers no views yet: it is accepted with any `view`, which applies once it +has hits. Remove the view with `close(view)`. + +```julia +using BeamletOptics, BeamletOpticsGUI, GLMakie +gui = live_view(system, beam) +fig = Figure() +results_view!(fig[1, 1], gui, detector; view = :spot) +display(GLMakie.Screen(), fig) +``` +""" +function results_view! end + +""" + refresh!(v::ResultsView) -> v + +Computes the view `v` again from the current state of its object, e.g. after `solve_system!` for a +view without a live view (see [`results_view!`](@ref)). A view of a [`live_view`](@ref) window is +computed from the results of the last solve of the window once that solve is complete, as after a +change of its options: a change of the objects from code is solved by [`retrace!`](@ref). Throws +an `ArgumentError` for a view that is closed. +""" +function refresh! end + """ add_component!(gui, obj; system = nothing, select = true, label = nothing, code = nothing) -> obj diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index 2c42f79..a669655 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -21,7 +21,7 @@ export live_view, kinematic_controls!, view_cube!, export_changes, export_script add_panel!, add_controls!, add_tool!, retrace!, add_component!, remove_component!, open_system, add_system!, remove_system!, CatalogEntry, CatalogParam, CatalogGlass, component_catalog, catalog_glasses, select!, - spectator!, wait_solve + spectator!, wait_solve, CardView, card_views, results_view!, ResultsView, refresh! import Makie using Makie: Figure, Axis3, LScene, mesh!, lines!, linesegments!, RGBf, RGBAf, scatter!, text!, @@ -50,6 +50,7 @@ include("LiveProgress.jl") include("LiveIcons.jl") include("ViewCube.jl") include("LiveWidgets.jl") +include("LiveCardViews.jl") include("LiveDetectorView.jl") include("LiveCardPages.jl") include("LiveCard.jl") @@ -58,6 +59,7 @@ include("LiveLayout.jl") include("LiveScroll.jl") include("LiveSplitter.jl") include("LiveDetectors.jl") +include("LiveResultsView.jl") include("LiveTrace.jl") include("LiveBeamSwitch.jl") include("LivePolarization.jl") diff --git a/src/LiveCardRows.jl b/src/LiveCardRows.jl index f030bdc..9d6dff6 100644 --- a/src/LiveCardRows.jl +++ b/src/LiveCardRows.jl @@ -331,10 +331,15 @@ function _signal_text(gui::LiveView, pd) _tracing(gui) && return _TRACING_VALUE state = get(gui.detectors.states, pd, nothing) (isnothing(state) || state.stale || isnothing(state.result)) && return _hits_text(pd) - return _signal_metric(state.result.metrics, pd) + return _signal_metric(state.result, pd) +end +# The line of the shown view (e.g. the power of the intensity), the number of spots, or the hits +function _signal_metric(r::_ViewResult, pd) + isnothing(r.error) || return _hits_text(pd) + isnothing(r.text) || return r.text + (!isnothing(r.view) && r.view.form === :points) && return "N = $(r.metrics.n)" + return _hits_text(pd) end -_signal_metric(m::NamedTuple, pd) = haskey(m, :P) ? "P = $(_fmt3(1e3 * m.P)) mW" : - haskey(m, :n) ? "N = $(m.n)" : _hits_text(pd) _signal_metric(_, pd) = _hits_text(pd) _hits_text(pd) = (n = length(pd); iszero(n) ? "no hits" : n == 1 ? "1 hit" : "$n hits") diff --git a/src/LiveCardViews.jl b/src/LiveCardViews.jl new file mode 100644 index 0000000..1ac555a --- /dev/null +++ b/src/LiveCardViews.jl @@ -0,0 +1,43 @@ +#= +The views on the page "Results" of the cards (see `CardView` and `card_views` in `API.jl`): the +default and the views of a `Detector` +=# + +card_views(@nospecialize(_)) = () + +#= +The views of a `Detector`: the spot diagram (`BeamletOptics.spot_diagram`), the intensity of +beamlet hits (`BeamletOptics.intensity`, with the power of `BeamletOptics.optical_power` as its +line) and the PSF of ray hits (the intensity of the rays, normalized to its peak). They are shown +also while the detector moves, on a coarse grid, see `_view_grid`. +=# + +# The points of the spot diagram of the detector `pd` +_detector_spots(@nospecialize(pd)) = BMO.spot_diagram(pd) + +# The intensity of the detector `pd` on a `RegularGrid` of `num_points` in the `window`, with the +# further keywords of the grid, e.g. `crop_factor` +function _detector_intensity(@nospecialize(pd); num_points::Integer, window, kwargs...) + grid = merge(NamedTuple(kwargs), (; num_points = Int(num_points)), _window_kwargs(window)) + return BMO.intensity(pd, BMO.RegularGrid(; grid...)) +end + +# The power of the intensity `(x, z, I)` of the detector `pd`, integrated by BeamletOptics with the +# weights of the samples of its grid, without computing the intensity again +function _detector_power(@nospecialize(pd), (x, z, I)) + P = BMO.optical_power(BMO.sample_points(pd, BMO.RectilinearGrid(x, z)), I) + return "P = $(_fmt3(1e3 * P)) mW" +end + +const _SPOT_VIEW = CardView(_detector_spots, :points; name = :spot, label = "Spot", preview = true) +const _PSF_VIEW = CardView(_detector_intensity, :field; name = :psf, label = "PSF", normalize = true, + preview = true) +const _INTENSITY_VIEW = CardView(_detector_intensity, :field; name = :intensity, label = "Intensity", + unit = "W/m²", preview = true, text = _detector_power) + +# The views of a detector per type of its hits, the first one is the default: the spot diagram and +# the PSF of ray hits, the intensity and the spot diagram of beamlet hits, none without hits +card_views(@nospecialize(pd::BMO.Detector)) = _detector_views(BMO.hits(pd)) +_detector_views(_) = () +_detector_views(::AbstractVector{<:BMO.AbstractRayHit}) = (_SPOT_VIEW, _PSF_VIEW) +_detector_views(::AbstractVector{<:BMO.AbstractBeamletHit}) = (_INTENSITY_VIEW, _SPOT_VIEW) diff --git a/src/LiveComponents.jl b/src/LiveComponents.jl index 7854d54..a0af446 100644 --- a/src/LiveComponents.jl +++ b/src/LiveComponents.jl @@ -70,12 +70,14 @@ _add_system(::LiveView, x) = # A detector that is added gets the state of its view when its card first shows the page "Results", # see `_detector_state`. The view forgets a removed one; its cards were unpinned before, see # `remove_component!` -function _forget_detector!(gui::LiveView, pd::BMO.Detector) - delete!(gui.detectors.states, pd) - filter!(((p, _),) -> p !== pd, gui.detectors.registered) +function _forget_detector!(gui::LiveView, @nospecialize(obj)) + delete!(gui.detectors.states, obj) + registered = gui.detectors.registered + for i in length(registered):-1:1 + registered[i][1] === obj && deleteat!(registered, i) + end return nothing end -_forget_detector!(::LiveView, _) = nothing # `origin` is `nothing` or `(; code, pose0)`: the constructor call of `obj` as Julia code and its # pose as constructed, e.g. of a component of the catalog, see `_ComponentState` and `_export_code`. diff --git a/src/LiveDetectorView.jl b/src/LiveDetectorView.jl index 513df4d..6c3d685 100644 --- a/src/LiveDetectorView.jl +++ b/src/LiveDetectorView.jl @@ -8,59 +8,41 @@ using Makie: Axis, Label, Button, Toggle, GridLayout, Observable, Point2f, Vec2f Mouse, Outside, Auto, colsize!, colgap!, translate! #= -Kinds of views, by the type of the hits of the detector +Forms of the views, see `CardView` =# """ - _ViewKind + _ViewForm -Kind of the view of the hits of a detector: `_SpotKind` (the spot diagram), `_PSFKind` (the -intensity of ray hits, normalized to its peak) and `_IntensityKind` (the intensity of beamlet -hits); the latter two are fields on a grid, i.e. `_FieldKind`s. The kinds that hits offer are -`_view_kinds(hits)`. Per kind: `_kind_name`, `_kind_label`, the data (`_kind_data`), the metrics -(`_kind_metrics`) and how the widget draws it (`_draw_kind!`). +How the view widget draws a `CardView`: `_PointsForm` (a spot diagram), `_FieldForm` (an image on +a grid) and `_SeriesForm` (a value over the solves), see `_form`. Per form: the data (`_form_data`), +the display helpers (`_form_metrics`) and the drawing (`_draw_form!`). """ -abstract type _ViewKind end -abstract type _FieldKind <: _ViewKind end -struct _SpotKind <: _ViewKind end -struct _PSFKind <: _FieldKind end -struct _IntensityKind <: _FieldKind end - -"""Name of the `kind` in the options of a view, see `_ViewOptions`.""" -_kind_name(::_SpotKind) = :spot -_kind_name(::_PSFKind) = :psf -_kind_name(::_IntensityKind) = :intensity - -"""Label of the `kind` in the switch of a view.""" -_kind_label(::_SpotKind) = "Spot" -_kind_label(::_PSFKind) = "PSF" -_kind_label(::_IntensityKind) = "Intensity" +abstract type _ViewForm end +struct _PointsForm <: _ViewForm end +struct _FieldForm <: _ViewForm end +struct _SeriesForm <: _ViewForm end -""" - _view_kinds(hits) -> Tuple{Vararg{_ViewKind}} - -The kinds of views that the `hits` of a detector offer, the first one is the default: the spot -diagram and the PSF for ray hits, the intensity and the spot diagram for beamlet hits, none -without hits. -""" -_view_kinds(_) = () -_view_kinds(::AbstractVector{<:BMO.AbstractRayHit}) = (_SpotKind(), _PSFKind()) -_view_kinds(::AbstractVector{<:BMO.AbstractBeamletHit}) = (_IntensityKind(), _SpotKind()) +"""The form of the `CardView` `cv`, see `_ViewForm`.""" +_form(cv::CardView) = cv.form === :points ? _PointsForm() : cv.form === :field ? _FieldForm() : _SeriesForm() +_form(::Nothing) = nothing # Grid of a field that is shown only as a thumbnail const _THUMB_N = 48 """ _ViewOptions(; kind = :auto, num_points = 100, kwargs = (;), colorscale = :linear, colorrange = nothing, - colorbar = true, profiles = false, window = nothing) + colorbar = true, profiles = false, window = nothing, spot_colors = nothing) _ViewOptions(opts; changed...) -Options of the view of a detector: the `kind` (`:auto` or a name, see `_kind_name`; a kind that the -hits do not offer falls back to their default), the grid `num_points` of a field and further `kwargs` of -`BeamletOptics.intensity`, the `colorscale` (`:linear` or `:log`) and the fixed `colorrange` of a -field (or `nothing`), whether the `colorbar` and the `profiles` of a field are shown, and the -`window` `(x_min, x_max, z_min, z_max)` [m] of a field, `nothing` for the automatic limits of -BeamletOptics. The second form copies `opts` with changed fields. +Options of the view of an object: the `kind`, i.e. the `name` of the `CardView` that is shown +(`:auto` or a name that the object does not offer: its first one), the grid `num_points` of a field +and further `kwargs` of its function (for a `Detector` those of `BeamletOptics.RegularGrid`), the +`colorscale` (`:linear` or `:log`) and the fixed `colorrange` of a field (or `nothing`), whether the +`colorbar` and the `profiles` of a field are shown, the `window` `(x_min, x_max, z_min, z_max)` [m] +of a field, `nothing` for its own limits, and the `spot_colors` of the spot diagram of a +`Detector` (`hit -> color`, or `nothing`), see `_hit_colors`. The second form copies `opts` with +changed fields. """ struct _ViewOptions kind::Symbol @@ -71,15 +53,19 @@ struct _ViewOptions colorbar::Bool profiles::Bool window::Union{Nothing, NTuple{4, Float64}} + spot_colors::Any end _ViewOptions(; kind = :auto, num_points = 100, kwargs = (;), colorscale = :linear, colorrange = nothing, - colorbar = true, profiles = false, window = nothing) = - _ViewOptions(kind, num_points, kwargs, colorscale, colorrange, colorbar, profiles, _view_window(window)) + colorbar = true, profiles = false, window = nothing, spot_colors = nothing) = + _ViewOptions(kind, num_points, kwargs, colorscale, colorrange, colorbar, profiles, _view_window(window), + spot_colors) _ViewOptions(o::_ViewOptions; kind = o.kind, num_points = o.num_points, kwargs = o.kwargs, colorscale = o.colorscale, - colorrange = o.colorrange, colorbar = o.colorbar, profiles = o.profiles, window = o.window) = - _ViewOptions(kind, num_points, kwargs, colorscale, colorrange, colorbar, profiles, _view_window(window)) + colorrange = o.colorrange, colorbar = o.colorbar, profiles = o.profiles, window = o.window, + spot_colors = o.spot_colors) = + _ViewOptions(kind, num_points, kwargs, colorscale, colorrange, colorbar, profiles, _view_window(window), + spot_colors) _view_window(::Nothing) = nothing _view_window(w) = NTuple{4, Float64}(Float64.(Tuple(w))) @@ -92,28 +78,36 @@ Base.hash(o::_ViewOptions, h::UInt) = """ _ViewResult -What the view of a detector shows after a solve, computed by `_view_result`: the `kinds` that the -hits offer, the `kind` that is shown (`nothing` without hits), its `data` (the points [m] of a -spot diagram, or `(x, z, I)` of a field), its `metrics` (see `_kind_metrics`) and the exception -`error` of a failed computation (then without data and metrics). `n` is the grid of the field as -computed (after the reduction of a `coarse` result; as requested for a spot diagram), `coarse` and -`preview` mark a result on a coarse grid and after a preview solve. +What the view of an object shows, computed by `_view_result`: the `views` (`CardView`s) that the +object offers, the `view` that is shown (`nothing` without views), its `data` (the points [m] of a +spot diagram, `(x, z, I)` of a field, the values of a series), the `colors` of the points (or +`nothing`), its `metrics` (the display helpers of the form, see `_form_metrics`), the line `text` +of the view (or `nothing`) and the exception `error` of a failed computation (then without data +and metrics). `n` is the grid of a field as computed (after the reduction of a `coarse` result; as +requested for the other forms), `coarse` and `preview` mark a result on a coarse grid and after a +preview solve. """ struct _ViewResult - kinds::Tuple{Vararg{_ViewKind}} - kind::Union{Nothing, _ViewKind} + views::Tuple{Vararg{CardView}} + view::Union{Nothing, CardView} data::Any + colors::Any metrics::Any + text::Union{Nothing, String} n::Int coarse::Bool preview::Bool error::Any end -"""Returns the kind of the `kinds` with the `name`, their first one for `:auto` or another name.""" -function _resolve_kind(kinds::Tuple, name::Symbol) - i = findfirst(k -> _kind_name(k) == name, kinds) - return isnothing(i) ? first(kinds) : kinds[i] +_ViewResult(views, view, n, coarse, preview; data = nothing, colors = nothing, metrics = nothing, + text = nothing, error = nothing) = + _ViewResult(views, view, data, colors, metrics, text, Int(n), coarse, preview, error) + +"""The view of the `views` with the `name`, their first one for `:auto` or another name.""" +function _resolve_view(views::Tuple, name::Symbol) + i = findfirst(cv -> cv.name === name, views) + return isnothing(i) ? first(views) : views[i] end """Grid of a field with `n` samples, reduced for a `coarse` result while objects move.""" @@ -123,77 +117,112 @@ _window_kwargs(::Nothing) = (;) _window_kwargs(w::NTuple{4, Float64}) = (; x_min = w[1], x_max = w[2], z_min = w[3], z_max = w[4]) """ - _kind_data(kind, pd, hits, opts, n, coarse) + _form_data(form, cv, obj, opts, n, coarse) -> (data, colors) -Computes the data of the `kind` of view for the detector `pd` with the `hits`: the points [m] of -the spot diagram, or the field `(x, z, I)` on the grid `_view_grid(n, coarse)` in the window of -the `opts` (see `_ViewOptions`), for the PSF divided by its maximum. The grid is a -`BeamletOptics.RegularGrid` with the kwargs of the `opts`, e.g. `x_min` or `crop_factor`. +Calls the function of the `CardView` `cv` for `obj`: the points [m] of a spot diagram and their +colors (from the function, or the `spot_colors` of the `opts`, see `_hit_colors`, or `nothing`), +the field `(x, z, I)` on the grid `_view_grid(n, coarse)` in the window of the `opts` (see +`_ViewOptions`), divided by its peak for `normalize`, or the value of a series. """ -_kind_data(::_SpotKind, pd, _, _, _, _) = BMO.spot_diagram(pd) -function _kind_data(::_IntensityKind, pd, _, opts::_ViewOptions, n, coarse) - kwargs = merge(opts.kwargs, (; num_points = _view_grid(n, coarse)), _window_kwargs(opts.window)) - x, z, I = BMO.intensity(pd, BMO.RegularGrid(; kwargs...)) - return (x, z, I) -end -function _kind_data(::_PSFKind, pd, hits, opts::_ViewOptions, n, coarse) - x, z, I = _kind_data(_IntensityKind(), pd, hits, opts, n, coarse) +function _form_data(::_PointsForm, cv::CardView, @nospecialize(obj), opts::_ViewOptions, _, _) + pts, colors = _split_points(cv.f(obj)) + isnothing(opts.spot_colors) || (colors = _hit_colors(obj, opts.spot_colors, length(pts))) + return pts, colors +end +function _form_data(::_FieldForm, cv::CardView, @nospecialize(obj), opts::_ViewOptions, n, coarse) + x, z, I = cv.f(obj; num_points = _view_grid(n, coarse), window = opts.window, opts.kwargs...) + cv.normalize || return (x, z, I), nothing peak = maximum(I) - return (x, z, peak > 0 ? I ./ peak : I) + return (x, z, peak > 0 ? I ./ peak : I), nothing end +_form_data(::_SeriesForm, cv::CardView, @nospecialize(obj), _, _, _) = Float64(cv.f(obj)), nothing -# The data of the `kind`, with the kind that is shown -_shown_data(kind::_ViewKind, pd, hits, opts, n, coarse) = - (kind, _kind_data(kind, pd, hits, opts, n, coarse)) +# The points of a spot diagram and the colors that its function returned, if any +_split_points(t::Tuple{Any, Any}) = (t[1], t[2]) +_split_points(pts) = (pts, nothing) -# With the kind `:auto`, the spot diagram is shown if the field of the hits can not be evaluated -function _shown_data(kind::_IntensityKind, pd, hits::AbstractVector{<:BMO.AstigmaticGaussianBeamletHit}, - opts, n, coarse) - try - return (kind, _kind_data(kind, pd, hits, opts, n, coarse)) - catch e - (BMO.is_cancelled(e) || opts.kind != :auto) && rethrow() - return (_SpotKind(), _kind_data(_SpotKind(), pd, hits, opts, n, coarse)) - end -end +""" + _hit_colors(obj, f, n) -> Vector{RGBAf} -"""Metrics of the `data` of the `kind`, see `_spot_metrics` and `_intensity_metrics`; the PSF without the power.""" -_kind_metrics(::_SpotKind, _, pts) = _spot_metrics(pts) -_kind_metrics(::_IntensityKind, pd, (x, z, I)) = _intensity_metrics(pd, x, z, I) -function _kind_metrics(::_PSFKind, pd, (x, z, I)) - m = _intensity_metrics(pd, x, z, I) - return (; m.cx, m.cz, m.wx, m.wz, m.peak) +The colors of the `n` points of the spot diagram of `obj` from the `spot_colors` `f` of its view +(`hit -> color`): for a `Detector`, `f` is called for each of its hits, in the order of the points, +which give one point per ray hit and the contour of `n ÷ length(hits)` points per beamlet hit. +`nothing` for other objects, whose functions return their own colors. +""" +_hit_colors(@nospecialize(_), _, _) = nothing +function _hit_colors(@nospecialize(pd::BMO.Detector), f, n::Int) + hits = BMO.hits(pd) + (isnothing(hits) || isempty(hits)) && return nothing + k = n ÷ length(hits) + k * length(hits) == n || return nothing + return RGBAf[RGBAf(Makie.to_color(c)) for hit in hits for c in Iterators.repeated(f(hit), k)] end -_result_n(::_FieldKind, n, coarse) = _view_grid(n, coarse) -_result_n(::_ViewKind, n, _) = Int(n) +""" + _form_metrics(form, cv, data) + +The display helpers of the `data` of a view of the `form`, which the widget draws: of a spot +diagram the number of points, the centroid, the RMS radius and the largest distance from the +centroid (`_spot_metrics`), of a field its centroid, its 1/e² radii and its peak +(`_field_metrics`), of a series its last value. Computed by the window only to draw the view. +""" +_form_metrics(::_PointsForm, _, pts) = _spot_metrics(pts) +_form_metrics(::_FieldForm, _, (x, z, I)) = _field_metrics(x, z, I) +_form_metrics(::_SeriesForm, _, values) = (; n = length(values), last = isempty(values) ? NaN : last(values)) + +""" + _view_text(cv, obj, data) -> Union{Nothing, String} +The line of the `CardView` `cv` with the `data` of `obj`, see the keyword `text` of `CardView`. """ - _view_result(pd, opts::_ViewOptions; n = opts.num_points, coarse = false, preview = false) -> _ViewResult +_view_text(cv::CardView, @nospecialize(obj), data) = isnothing(cv.text) ? nothing : string(cv.text(obj, data)) -Computes what the view of the detector `pd` shows with the options `opts`: the kinds of its hits, -the kind of the options or their default, its data (`BeamletOptics.spot_diagram`, or -`BeamletOptics.intensity` on a grid of `n`, for `coarse` a quarter of it, at least 16) and its -metrics. Creates no plot and touches no observable, such that it can run in a background task. -An error is returned in the result, a cancelled solve is rethrown, see `BMO.is_cancelled`. """ -function _view_result(pd, opts::_ViewOptions; n::Integer = opts.num_points, coarse::Bool = false, - preview::Bool = false) - kinds, kind = (), nothing + _view_result(obj, opts::_ViewOptions; n = opts.num_points, coarse = false, preview = false) -> _ViewResult + +Computes what the view of `obj` shows with the options `opts`: its views (see `card_views`), the +view of the options or its first one, and the data, the display helpers and the line of that view +(for a field on a grid of `n`, for `coarse` a quarter of it, at least 16). With the kind `:auto`, a +view whose function throws gives way to the next one, e.g. the spot diagram of astigmatic beamlets +whose field can not be evaluated. Creates no plot and touches no observable, such that it can run +in a background task. An error is returned in the result, a cancelled solve is rethrown, see +`BMO.is_cancelled`. +""" +function _view_result(@nospecialize(obj), opts::_ViewOptions; n::Integer = opts.num_points, + coarse::Bool = false, preview::Bool = false) + views, cv = (), nothing try - hits = BMO.hits(pd) - kinds = _view_kinds(hits) - isempty(kinds) && return _ViewResult(kinds, nothing, nothing, nothing, Int(n), coarse, preview, nothing) - kind = _resolve_kind(kinds, opts.kind) - kind, data = _shown_data(kind, pd, hits, opts, n, coarse) - return _ViewResult(kinds, kind, data, _kind_metrics(kind, pd, data), _result_n(kind, n, coarse), - coarse, preview, nothing) + views = Tuple(card_views(obj)) + isempty(views) && return _ViewResult(views, nothing, n, coarse, preview) + cv = _resolve_view(views, opts.kind) + return _computed_result(views, cv, obj, opts, n, coarse, preview) catch e BMO.is_cancelled(e) && rethrow() - return _ViewResult(kinds, kind, nothing, nothing, Int(n), coarse, preview, e) + if opts.kind === :auto && !isnothing(cv) + # The next view, if the first one fails + i = findfirst(v -> v === cv, views) + for other in views[(i + 1):end] + try + return _computed_result(views, other, obj, opts, n, coarse, preview) + catch e2 + BMO.is_cancelled(e2) && rethrow() + end + end + end + return _ViewResult(views, cv, n, coarse, preview; error = e) end end +function _computed_result(views, cv::CardView, @nospecialize(obj), opts, n, coarse, preview) + form = _form(cv) + data, colors = _form_data(form, cv, obj, opts, n, coarse) + return _ViewResult(views, cv, _result_n(form, n, coarse), coarse, preview; data, colors, + metrics = _form_metrics(form, cv, data), text = _view_text(cv, obj, data)) +end + +_result_n(::_FieldForm, n, coarse) = _view_grid(n, coarse) +_result_n(::_ViewForm, n, _) = Int(n) + #= Metrics and their texts =# @@ -213,12 +242,14 @@ end """ _spot_metrics(pts) -Returns the metrics `(; n, cx, cz, rms, rmax)` of the spot diagram `pts` (local x/z [m]): the -number of hits, the centroid, the RMS radius `sqrt(mean(|p - c|²))` and the geometric radius, i.e. -the largest distance from the centroid [m]. +Returns the display helpers `(; n, cx, cz, rms, rmax)` of the spot diagram `pts` (local x/z [m]): +the number of points, the centroid, the RMS radius `sqrt(mean(|p - c|²))` and the geometric +radius, i.e. the largest distance from the centroid [m]. They place the centroid and are shown +below the view; the window computes them only to draw it. """ function _spot_metrics(pts) n = length(pts) + n == 0 && return (; n, cx = NaN, cz = NaN, rms = NaN, rmax = NaN) cx = sum(q -> Float64(q[1]), pts) / n cz = sum(q -> Float64(q[2]), pts) / n r2 = [(Float64(q[1]) - cx)^2 + (Float64(q[2]) - cz)^2 for q in pts] @@ -226,26 +257,25 @@ function _spot_metrics(pts) end """ - _intensity_metrics(pd, x, z, I) - -Returns the metrics `(; P, cx, cz, wx, wz, peak)` of the intensity `I[i, j]` at `x[i]`, `z[j]` -[m] on the detector `pd`: the power (integrated like `optical_power`, with the weights of the -sampling of the grid, see `BeamletOptics.sample_points`), the centroid, the 1/e² radii `2σ` from -the second moments along x and z and the peak intensity. The centroid and the radii are `NaN` -without intensity. + _field_metrics(x, z, I) + +Returns the display helpers `(; cx, cz, wx, wz, peak)` of the field `I[i, j]` at `x[i]`, `z[j]` +[m]: the centroid, the 1/e² radii `2σ` from the second moments along x and z and the peak. The +centroid and the radii are `NaN` for a field without values above zero. They place the centroid +and the cuts of the profiles and are shown below the view; the window computes them only to draw +it. The power of an intensity is not one of them: it is the `text` of the view of a `Detector`, +computed by `BeamletOptics.optical_power`, see `card_views`. """ -function _intensity_metrics(pd, x, z, I) - w = BMO.sample_points(pd, BMO.RectilinearGrid(x, z)).weights - P = sum(Broadcast.instantiate(Broadcast.broadcasted(*, w, I))) +function _field_metrics(x, z, I) peak = maximum(I) S = sum(I) - S > 0 || return (; P, cx = NaN, cz = NaN, wx = NaN, wz = NaN, peak) + S > 0 || return (; cx = NaN, cz = NaN, wx = NaN, wz = NaN, peak) # Marginal distributions along x and z, the uniform cell area cancels Ix, Iz = vec(sum(I; dims = 2)), vec(sum(I; dims = 1)) cx, cz = dot(Ix, x) / S, dot(Iz, z) / S σx2 = sum(Ix .* (x .- cx) .^ 2) / S σz2 = sum(Iz .* (z .- cz) .^ 2) / S - return (; P, cx, cz, wx = 2 * sqrt(σx2), wz = 2 * sqrt(σz2), peak) + return (; cx, cz, wx = 2 * sqrt(σx2), wz = 2 * sqrt(σz2), peak) end """Formats the length `x` like `_length_string` with its sign, below 1 pm as `0 nm`.""" @@ -254,18 +284,6 @@ function _signed_length_string(x) return x < 0 ? "-" * _length_string(-x) : _length_string(x) end -"""Formats the metrics `m` of a view, see `_spot_metrics` and `_intensity_metrics`, in two lines.""" -function _metrics_string(m) - if haskey(m, :rms) - return "N = $(m.n), c = ($(_signed_length_string(m.cx)), $(_signed_length_string(m.cz)))\n" * - "rms $(_length_string(m.rms)), max $(_length_string(m.rmax))" - end - s = "P = $(_fmt3(1e3 * m.P)) mW, peak $(_fmt_sigdigits(m.peak)) W/m²" - isfinite(m.cx) || return s * "\nno intensity" - return s * "\nc = ($(_signed_length_string(m.cx)), $(_signed_length_string(m.cz))), " * - "w = ($(_length_string(m.wx)), $(_length_string(m.wz)))" -end - """ _pad_degenerate_limits!(ax, xy; ε = 1e-6) @@ -292,7 +310,7 @@ end """ _display_intensity(I, colorscale) -> (values, colorrange) -Returns the values of the image of the intensity `I` and their color range: `I` for the linear +Returns the values of the image of the field `I` and their color range: `I` for the linear scale, `log10(I)` with a floor of `_LOG_FLOOR` times the maximum for the logarithmic scale. """ function _display_intensity(I, colorscale::Symbol) @@ -302,28 +320,58 @@ function _display_intensity(I, colorscale::Symbol) return log10.(max.(I, floor)), (log10(floor), log10(floor / _LOG_FLOOR)) end -# The two lines of the metrics below the expanded view -_metrics_text(::_ViewKind, m) = _metrics_string(m) -function _metrics_text(::_PSFKind, m) - s = "PSF, normalized to its peak" +# A value with its unit, e.g. the peak of a field or the last value of a series +_value_text(x::Real, unit::String) = isempty(unit) ? _fmt_sigdigits(x) : "$(_fmt_sigdigits(x)) $unit" + +_centroid_text(m) = "c = ($(_signed_length_string(m.cx)), $(_signed_length_string(m.cz)))" +_radii_text(::_PointsForm, m) = "rms $(_length_string(m.rms)), max $(_length_string(m.rmax))" +_radii_text(::_FieldForm, m) = "w = ($(_length_string(m.wx)), $(_length_string(m.wz)))" + +# The peak of a field: normalized, or with its unit +_peak_text(cv::CardView, m) = cv.normalize ? "$(cv.label), normalized to its peak" : "peak " * _value_text(m.peak, cv.unit) + +_with_text(s::String, ::Nothing) = s +_with_text(s::String, text::String) = text * ", " * s + +""" + _metrics_text(form, cv, m, text) -> String + +The two lines below the expanded view of the `CardView` `cv` with the display helpers `m` and the +line `text` of the view (or `nothing`): of a spot diagram the number of points and the centroid, the +RMS and the largest radius; of a field the line and the peak, the centroid and the radii; of a +series its last value and the number of values. +""" +function _metrics_text(::_PointsForm, _, m, text) + m.n == 0 && return _with_text("N = 0", text) * "\nno points" + return _with_text("N = $(m.n), " * _centroid_text(m), text) * "\n" * _radii_text(_PointsForm(), m) +end +function _metrics_text(::_FieldForm, cv::CardView, m, text) + s = _with_text(_peak_text(cv, m), text) isfinite(m.cx) || return s * "\nno intensity" - return s * "\n" * _centroid_text(m) * ", " * _radii_text(_PSFKind(), m) + return s * "\n" * _centroid_text(m) * ", " * _radii_text(_FieldForm(), m) +end +function _metrics_text(::_SeriesForm, cv::CardView, m, text) + m.n == 0 && return _with_text("no values", text) * "\n" + s = _with_text("last " * _value_text(m.last, cv.unit), text) + return s * "\n" * (m.n == 1 ? "1 solve" : "$(m.n) solves") end -_centroid_text(m) = "c = ($(_signed_length_string(m.cx)), $(_signed_length_string(m.cz)))" -_radii_text(::_SpotKind, m) = "rms $(_length_string(m.rms)), max $(_length_string(m.rmax))" -_radii_text(::_FieldKind, m) = "w = ($(_length_string(m.wx)), $(_length_string(m.wz)))" +# The key value of a view beside the thumbnail: the line of the view, or one of its form +_key_text(::_PointsForm, _, m, _) = "N = $(m.n)" +_key_text(::_FieldForm, cv::CardView, m, text) = something(text, cv.normalize ? "normalized" : "peak " * _value_text(m.peak, cv.unit)) +_key_text(::_SeriesForm, cv::CardView, m, _) = m.n == 0 ? "no values" : "last " * _value_text(m.last, cv.unit) -# The key value of the metrics beside the thumbnail -_key_text(::_SpotKind, m) = "N = $(m.n)" -_key_text(::_IntensityKind, m) = "P = $(_fmt3(1e3 * m.P)) mW" -_key_text(::_PSFKind, _) = "normalized" +_no_data_text(::_FieldForm) = "no intensity" +_no_data_text(::_ViewForm) = "no points" # The key value, the centroid and the radii beside the thumbnail, one per line -function _thumb_text(kind::_ViewKind, m) - isfinite(m.cx) || return _key_text(kind, m) * "\nno intensity" - return join((_key_text(kind, m), _centroid_text(m), _radii_text(kind, m)), "\n") +function _thumb_text(form::_ViewForm, cv::CardView, m, text) + key = _key_text(form, cv, m, text) + isfinite(m.cx) || return key * "\n" * _no_data_text(form) + return join((key, _centroid_text(m), _radii_text(form, m)), "\n") end +_thumb_text(::_SeriesForm, cv::CardView, m, text) = + _key_text(_SeriesForm(), cv, m, text) * "\n" * (m.n == 1 ? "1 solve" : "$(m.n) solves") #= The view widget @@ -412,6 +460,9 @@ mutable struct _DetectorView const profiles_toggle::Toggle const bar_toggle::Toggle const fit_button::Button + # the button "clear" of a series + const series_controls::GridLayout + const clear_button::Button const collapse_button::_IconButton const plots::GridLayout const ax::Axis @@ -430,6 +481,8 @@ mutable struct _DetectorView const images::Vector{AbstractPlot} const spots::Vector{AbstractPlot} const crosses::Vector{AbstractPlot} + # the line of a series + const lines::Vector{AbstractPlot} const profile_x::AbstractPlot const profile_z::AbstractPlot # the lines of the cuts of the profiles in the axis and the point [mm] they cross in @@ -453,12 +506,15 @@ mutable struct _DetectorView name::String result::Union{Nothing, _ViewResult} opts::_ViewOptions - kind::Union{Nothing, _ViewKind} + view::Union{Nothing, CardView} + # the names of the axes inside the frame, see `_view_names` + names::Tuple{String, String} scale::Any xy::Vector{Point2f} extent::NTuple{4, Float64} status_text::String fields_shown::Bool + series_shown::Bool bar_shown::Bool profiles_shown::Bool # the limits of a spot diagram were changed by the mouse, they stay over new results @@ -506,13 +562,15 @@ function _view_plots!(ax::Axis, t::NamedTuple, margin::Float32) end image = image!(ax, (0.0, 1.0), (0.0, 1.0), zeros(Float32, 2, 2); colormap = :viridis, colorrange = (0.0f0, 1.0f0), interpolate = true, visible = false, inspectable = false) - spots = scatter!(ax, Point2f[]; markersize = 3, color = t.text, visible = false, inspectable = false) + # A color per spot, see `_spot_colors` + spots = scatter!(ax, Point2f[]; markersize = 3, color = RGBAf[], visible = false, inspectable = false) cross = scatter!(ax, Point2f[]; marker = :cross, markersize = 11, color = :red, inspectable = false) - foreach(((k, p),) -> translate!(p, 0, 0, k), enumerate((frame, image, spots, cross))) + line = lines!(ax, Point2f[]; color = t.accent, linewidth = 1.5, visible = false, inspectable = false) + foreach(((k, p),) -> translate!(p, 0, 0, k), enumerate((frame, image, spots, cross, line))) # An image sets the margins of the automatic limits to zero, the spots need them ax.xautolimitmargin[] = (margin, margin) ax.yautolimitmargin[] = (margin, margin) - return (; frame, image, spots, cross) + return (; frame, image, spots, cross, line) end function _DetectorView(grid::GridLayout, theme::NamedTuple; expanded::Bool = true, width::Real = 280, @@ -553,6 +611,9 @@ function _DetectorView(grid::GridLayout, theme::NamedTuple; expanded::Bool = tru fit_button = Button(controls[1, 3]; label = "fit", _card_style(t, Button)..., fontsize = 11, height = 20, padding = (6, 6, 2, 2)) colsize!(controls, 2, Auto()) + series_controls = GridLayout(controls[1, 1]; default_colgap = 4) + clear_button = Button(series_controls[1, 1]; label = "clear", _card_style(t, Button)..., fontsize = 11, + height = 20, padding = (6, 6, 2, 2)) plots = GridLayout(full[3, 1]; halign = :left, valign = :top, default_rowgap = 5) ax = _view_axis(plots[1, 1], t; width, height, yaxisposition = :right, xtickalign = 1, ytickalign = 1, xticksize = 4, yticksize = 4, xticks = _VIEW_TICKS, yticks = _VIEW_TICKS) @@ -613,13 +674,14 @@ function _DetectorView(grid::GridLayout, theme::NamedTuple; expanded::Bool = tru v = _DetectorView(grid, t, true, thumb, thumb_ax, expand_button, kind_label, info_label, full, header, controls, field_controls, log_toggle, profiles_toggle, bar_toggle, fit_button, - collapse_button, plots, ax, bar, bar_ax, bar_unit, bar_image, profiles_ax, metrics_label, + series_controls, clear_button, collapse_button, plots, ax, bar, bar_ax, bar_unit, bar_image, profiles_ax, metrics_label, Dict{Any, _Segmented}(), nothing, (), AbstractPlot[a.frame, b.frame], AbstractPlot[a.image, b.image], AbstractPlot[a.spots, b.spots], AbstractPlot[a.cross, b.cross], + AbstractPlot[a.line, b.line], profile_x, profile_z, cut_x, cut_z, nothing, select_fill, select_line, xlabels, zlabels, xname, zname, status, Rect2f[], _tree_font(ax.blockscene, :regular), Dict{String, Vec2f}(), false, "", nothing, - _ViewOptions(), nothing, nothing, Point2f[], (0.0, 1.0, 0.0, 1.0), "", true, true, true, false, + _ViewOptions(), nothing, _POSITION_NAMES, nothing, Point2f[], (0.0, 1.0, 0.0, 1.0), "", true, true, true, true, false, false, nothing, nothing, nothing, false, false, 0.0, Point2f(0), _no_options, _no_expanded, Any[]) on(_ -> _update_decorations!(v), ax.finallimits) @@ -628,6 +690,7 @@ function _DetectorView(grid::GridLayout, theme::NamedTuple; expanded::Bool = tru on(_ -> v.on_expanded(true), expand_button.clicks) on(_ -> v.on_expanded(false), collapse_button.clicks) on(_ -> _fit!(v), fit_button.clicks) + on(_ -> v.on_options(; clear = true), clear_button.clicks) on(a -> v.refreshing || v.on_options(; colorscale = a ? :log : :linear), log_toggle.active) on(a -> v.refreshing || v.on_options(; profiles = a), profiles_toggle.active) on(a -> v.refreshing || v.on_options(; colorbar = a), bar_toggle.active) @@ -636,6 +699,7 @@ function _DetectorView(grid::GridLayout, theme::NamedTuple; expanded::Bool = tru _show_profiles!(v, false) _show_bar!(v, false) _show_field_controls!(v, false) + _show_series_controls!(v, false) _view_detach!(expanded ? thumb : full) v.expanded = expanded _draw_empty!(v, "no result") @@ -691,7 +755,7 @@ _refresh_switch!(::_DetectorView, ::Nothing) = nothing function _refresh_switch!(v::_DetectorView, r::_ViewResult) v.refreshing = true try - _show_switch!(v, r.kinds, r.kind) + _show_switch!(v, r.views, r.view) finally v.refreshing = false end @@ -723,6 +787,13 @@ function _show_field_controls!(v::_DetectorView, shown::Bool) return nothing end +function _show_series_controls!(v::_DetectorView, shown::Bool) + v.series_shown == shown && return nothing + v.series_shown = shown + shown ? _view_attach!(v.controls, 1, 1, v.series_controls, v.expanded) : _view_detach!(v.series_controls) + return nothing +end + function _show_profiles!(v::_DetectorView, shown::Bool) v.profiles_shown == shown && return nothing v.profiles_shown = shown @@ -748,27 +819,27 @@ function _arrange_plots!(v::_DetectorView) return nothing end -# The switch of the `kinds`, of which `kind` is selected; none without kinds -function _show_switch!(v::_DetectorView, kinds::Tuple, kind) - key = map(_kind_name, kinds) +# The switch of the `views` (`CardView`s), of which `view` is selected; none without views +function _show_switch!(v::_DetectorView, views::Tuple, view) + key = map(cv -> cv.name, views) if key != v.switch_key # A new switch is built into the header, i.e. only while the expanded state is laid out; # a collapsed view gets it when it is expanded, see `_set_expanded!` (v.expanded || isempty(key) || haskey(v.switches, key)) || return nothing isnothing(v.switch) || _view_detach!(v.switch.grid) - v.switch = isempty(key) ? nothing : get!(() -> _view_switch(v, kinds), v.switches, key) + v.switch = isempty(key) ? nothing : get!(() -> _view_switch(v, views), v.switches, key) v.switch_key = key isnothing(v.switch) || _view_attach!(v.header, 1, 1, v.switch.grid, v.expanded) end - _select_kind!(v.switch, kind) + _select_view!(v.switch, view) return nothing end -_select_kind!(_, _) = nothing -_select_kind!(s::_Segmented, kind::_ViewKind) = _update!(s.selected, _kind_name(kind)) +_select_view!(_, _) = nothing +_select_view!(s::_Segmented, cv::CardView) = _update!(s.selected, cv.name) -function _view_switch(v::_DetectorView, kinds::Tuple) - options = Pair{Symbol, String}[_kind_name(k) => _kind_label(k) for k in kinds] +function _view_switch(v::_DetectorView, views::Tuple) + options = Pair{Symbol, String}[cv.name => cv.label for cv in views] s = _Segmented(v.header[1, 1], options; theme = v.theme, tellwidth = true) on(k -> v.refreshing || v.on_options(; kind = k), s.selected) return s @@ -794,21 +865,21 @@ _boxes_meet(a::Rect2f, b::Rect2f, gap) = all(minimum(a) .< maximum(b) .+ gap) && all(minimum(b) .< maximum(a) .+ gap) """ - _view_decorations(lims, size, measure, status) -> (; xpos, xtext, zpos, ztext, boxes) + _view_decorations(lims, size, measure, status, names = _POSITION_NAMES) -> (; xpos, xtext, zpos, ztext, boxes) The tick labels inside the frame of an axis with the limits `lims` and the `size` [px]: the positions [data] and texts of the x labels at its bottom edge and of the z labels at its right -edge, and the pixel `boxes` in the frame of all texts, first those of the axis names in the bottom -left and the top right corner and of the `status` in the top left corner (if not empty). +edge, and the pixel `boxes` in the frame of all texts, first those of the axis `names` in the +bottom left and the top right corner and of the `status` in the top left corner (if not empty). `measure(text)` returns the size of a text [px]. A tick label that would stick out of the frame or come closer than `_VIEW_LABEL_GAP` to a text placed before is left out. """ -function _view_decorations(lims, size::Vec2f, measure, status::String) +function _view_decorations(lims, size::Vec2f, measure, status::String, names = _POSITION_NAMES) lo, w = Makie.origin(lims), Makie.widths(lims) W, H = size o = _VIEW_LABEL_OFFSET box(x, y, s) = Rect2f(x, y, s[1], s[2]) - sx, sz = measure("x [mm]"), measure("z [mm]") + sx, sz = measure(names[1]), measure(names[2]) boxes = Rect2f[box(o.corner, o.x, sx), box(W - o.z - sz[1], H - o.corner - sz[2], sz)] if !isempty(status) s = measure(status) @@ -848,11 +919,11 @@ function _update_decorations!(v::_DetectorView) (all(>(0), size) && all(x -> isfinite(x) && x > 0, w)) || return nothing top_left = [Makie.Point2d(lo[1], lo[2] + w[2])] Makie.update!(v.status; arg1 = top_left, text = [v.status_text]) - decorated = !isnothing(v.kind) + decorated = !isnothing(v.view) Makie.update!(v.xname; arg1 = [Makie.Point2d(lo)], visible = decorated) Makie.update!(v.zname; arg1 = [Makie.Point2d(lo .+ w)], visible = decorated) if decorated - d = _view_decorations(lims, size, s -> _text_size(v, s), v.status_text) + d = _view_decorations(lims, size, s -> _text_size(v, s), v.status_text, v.names) Makie.update!(v.xlabels; arg1 = d.xpos, text = d.xtext) Makie.update!(v.zlabels; arg1 = d.zpos, text = d.ztext) v.boxes = d.boxes @@ -864,26 +935,49 @@ function _update_decorations!(v::_DetectorView) return nothing end -# Look of the axis per kind: white texts with a glow on the image of a field, whose frame has the -# lowest color of the colormap; the text color of the theme on the spots, whose background is the card -_kind_look(::_FieldKind, t) = (; text = RGBAf(1, 1, 1, 1), glow = 2.0, tick = RGBAf(1, 1, 1, 1), +# Look of the axis per form: white texts with a glow on the image of a field, whose frame has the +# lowest color of the colormap; the text color of the theme on the spots and the series, whose +# background is the card +_form_look(::_FieldForm, t) = (; text = RGBAf(1, 1, 1, 1), glow = 2.0, tick = RGBAf(1, 1, 1, 1), background = RGBAf(first(Makie.to_colormap(:viridis)))) -_kind_look(::_SpotKind, t) = (; text = RGBAf(Makie.to_color(t.text)), glow = 0.0, +_form_look(::_ViewForm, t) = (; text = RGBAf(Makie.to_color(t.text)), glow = 0.0, tick = RGBAf(Makie.to_color(t.muted)), background = RGBAf(0, 0, 0, 0)) -_is_field(::_FieldKind) = true +_is_field(::_FieldForm) = true _is_field(_) = false +_is_series(::_SeriesForm) = true +_is_series(_) = false + +# The names of the axes inside the frame: positions in mm, a series over the solves +const _POSITION_NAMES = ("x [mm]", "z [mm]") +_view_names(::_ViewForm, _) = _POSITION_NAMES +_view_names(::_SeriesForm, cv::CardView) = ("solve", isempty(cv.unit) ? "value" : cv.unit) + +# Equal scales for positions, none for a series +_aspect(::_ViewForm) = 1 +_aspect(::_SeriesForm) = nothing + +# Whether the shown view is the `CardView` `b`: the method of `card_views` builds them anew +_same_view(::Nothing, _) = false +_same_view(a::CardView, b::CardView) = a.name === b.name && a.form === b.form && a.label == b.label && + a.unit == b.unit && a.normalize == b.normalize # The color of the background of the frames of the thumbnail and of the axis, see `_view_plots!` _set_frames!(v::_DetectorView, color::RGBAf) = foreach(p -> Makie.update!(p; arg3 = fill(color, 1, 1)), v.frames) -# Shows the look and the controls of the `kind`; returns whether it is another kind than before -function _set_kind!(v::_DetectorView, kind::_ViewKind) - v.kind == kind && return false - v.kind = kind +# Shows the look and the controls of the `CardView` `cv`; returns whether it is another view than before +function _set_view_look!(v::_DetectorView, cv::CardView) + _same_view(v.view, cv) && (v.view = cv; return false) + v.view = cv v.zoomed = false - look = _kind_look(kind, v.theme) + form = _form(cv) + look = _form_look(form, v.theme) + v.names = _view_names(form, cv) + Makie.update!(v.xname; text = [v.names[1]]) + Makie.update!(v.zname; text = [v.names[2]]) + _update!(v.ax.autolimitaspect, _aspect(form)) + _update!(v.thumb_ax.autolimitaspect, _aspect(form)) for p in (v.xlabels, v.zlabels, v.xname, v.zname, v.status) Makie.update!(p; color = look.text, glowwidth = look.glow) end @@ -892,7 +986,8 @@ function _set_kind!(v::_DetectorView, kind::_ViewKind) Makie.update!(v.select_fill; color = RGBAf(look.text.r, look.text.g, look.text.b, _VIEW_SELECT_ALPHA)) Makie.update!(v.select_line; color = look.text) _set_frames!(v, look.background) - _show_field_controls!(v, _is_field(kind)) + _show_field_controls!(v, _is_field(form)) + _show_series_controls!(v, _is_series(form)) return true end @@ -931,7 +1026,7 @@ end _draw!(v::_DetectorView, ::Nothing, _, _) = (_show_switch!(v, (), nothing); _draw_empty!(v, "no result")) function _draw!(v::_DetectorView, r::_ViewResult, opts::_ViewOptions, fresh::Bool) - _show_switch!(v, r.kinds, r.kind) + _show_switch!(v, r.views, r.view) _draw_data!(v, r.error, r, opts, fresh) return nothing end @@ -944,21 +1039,29 @@ function _draw_data!(v::_DetectorView, e, ::_ViewResult, _, _) end function _draw_data!(v::_DetectorView, ::Nothing, r::_ViewResult, opts::_ViewOptions, fresh::Bool) v.last_error = nothing - _draw_kind!(v, r.kind, r, opts, fresh) + _draw_form!(v, _form(r.view), r, opts, fresh) return nothing end _preview_text(r::_ViewResult) = (r.coarse || r.preview) ? "preview" : "" _with_preview(s::String, r::_ViewResult) = (r.coarse || r.preview) ? s * " (preview)" : s -_draw_kind!(v::_DetectorView, ::Nothing, r::_ViewResult, _, _) = _draw_empty!(v, _with_preview("no hits", r)) +_draw_form!(v::_DetectorView, ::Nothing, r::_ViewResult, _, _) = _draw_empty!(v, _with_preview("no hits", r)) -function _draw_kind!(v::_DetectorView, kind::_SpotKind, r::_ViewResult, ::_ViewOptions, fresh::Bool) - changed = _set_kind!(v, kind) +# The colors of the `n` spots: those of the result, or the text color of the theme (a plot keeps the +# type of its color attribute, hence always a vector) +_spot_colors(v::_DetectorView, colors::AbstractVector, n::Int) = + length(colors) == n ? RGBAf[RGBAf(Makie.to_color(c)) for c in colors] : _spot_colors(v, nothing, n) +_spot_colors(v::_DetectorView, _, n::Int) = fill(RGBAf(Makie.to_color(v.theme.text)), n) + +function _draw_form!(v::_DetectorView, form::_PointsForm, r::_ViewResult, ::_ViewOptions, fresh::Bool) + changed = _set_view_look!(v, r.view) if fresh || changed v.xy = [Point2f(1e3 * q[1], 1e3 * q[2]) for q in r.data] + color = _spot_colors(v, r.colors, length(v.xy)) foreach(p -> Makie.update!(p; visible = false), v.images) - foreach(p -> Makie.update!(p; arg1 = v.xy, visible = true), v.spots) + foreach(p -> Makie.update!(p; visible = false), v.lines) + foreach(p -> Makie.update!(p; arg1 = v.xy, color, visible = true), v.spots) _show_centroid!(v, r.metrics) _fit_spots!(v.thumb_ax, v.xy) v.zoomed || _fit_spots!(v.ax, v.xy) @@ -966,12 +1069,12 @@ function _draw_kind!(v::_DetectorView, kind::_SpotKind, r::_ViewResult, ::_ViewO _show_profiles!(v, false) _set_cuts!(v, nothing) _show_bar!(v, false) - _show_texts!(v, kind, r) + _show_texts!(v, form, r) return nothing end -function _draw_kind!(v::_DetectorView, kind::_FieldKind, r::_ViewResult, opts::_ViewOptions, fresh::Bool) - changed = _set_kind!(v, kind) +function _draw_form!(v::_DetectorView, form::_FieldForm, r::_ViewResult, opts::_ViewOptions, fresh::Bool) + changed = _set_view_look!(v, r.view) x, z, I = r.data scale = (opts.colorscale, opts.colorrange) if fresh || changed || scale != v.scale @@ -981,16 +1084,55 @@ function _draw_kind!(v::_DetectorView, kind::_FieldKind, r::_ViewResult, opts::_ # The samples are the centers of the pixels of the image ex, ez = _pixel_edges(x), _pixel_edges(z) foreach(p -> Makie.update!(p; visible = false), v.spots) + foreach(p -> Makie.update!(p; visible = false), v.lines) foreach(p -> Makie.update!(p; arg1 = ex, arg2 = ez, arg3 = Float32.(values), colorrange, visible = true), v.images) - _set_bar!(v, kind, colorrange, opts.colorscale) + _set_bar!(v, r.view, colorrange, opts.colorscale) end _show_bar!(v, opts.colorbar) v.extent = 1e3 .* (Float64(first(x)), Float64(last(x)), Float64(first(z)), Float64(last(z))) _view_limits!(v.thumb_ax, v.extent) _view_limits!(v.ax, _window_mm(opts.window, v.extent)) _draw_profiles!(v, opts.profiles, x, z, I, r.metrics) - _show_texts!(v, kind, r) + _show_texts!(v, form, r) + return nothing +end + +# The values of a series as points (solve, value): one value of a view without a live view +_series_points(values::AbstractVector) = [Point2f(i, x) for (i, x) in enumerate(values)] +_series_points(x::Real) = [Point2f(1, x)] + +function _draw_form!(v::_DetectorView, form::_SeriesForm, r::_ViewResult, ::_ViewOptions, fresh::Bool) + changed = _set_view_look!(v, r.view) + if fresh || changed + v.xy = _series_points(r.data) + foreach(p -> Makie.update!(p; visible = false), v.images) + foreach(p -> Makie.update!(p; arg1 = Point2f[], color = RGBAf[], visible = false), v.spots) + foreach(p -> Makie.update!(p; arg1 = Point2f[]), v.crosses) + foreach(p -> Makie.update!(p; arg1 = v.xy, visible = true), v.lines) + _fit_series!(v.thumb_ax, v.xy) + v.zoomed || _fit_series!(v.ax, v.xy) + end + _show_profiles!(v, false) + _set_cuts!(v, nothing) + _show_bar!(v, false) + _show_texts!(v, form, r) + return nothing +end + +# Limits of a series: all its values; a single value, or values that are all equal, in a range +# around them +function _fit_series!(ax::Axis, xy) + autolimits!(ax) + isempty(xy) && return nothing + lo, hi = extrema(q -> Float64(q[2]), xy) + n = length(xy) + if hi - lo <= 1e-12 * max(abs(lo), abs(hi), floatmin(Float64)) + h = lo == 0 ? 1.0 : abs(lo) / 10 + limits!(ax, 0.5, n + 0.5, lo - h, hi + h) + elseif n == 1 + limits!(ax, 0.5, 1.5, lo, hi) + end return nothing end @@ -1009,31 +1151,30 @@ const _SUPERSCRIPTS = ('⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸ _superscript(k::Integer) = join(c == '-' ? '⁻' : _SUPERSCRIPTS[c - '0' + 1] for c in string(k)) """ - _bar_unit(kind) -> String - _bar_prefix(kind, hi) -> Pair{Float64, String} - -The unit of the values of the field `kind`: `W/m²` for the intensity, `rel.` for the PSF, which is -normalized to its maximum. `_bar_prefix` is the SI prefix (`factor => prefix`) with which the -linear colorbar shows values up to `hi`, such that its largest tick has two to four digits, e.g. -`1e3 => "k"` for 25000 W/m²; none for the PSF. + _bar_unit(cv) -> String + _bar_prefix(cv, hi) -> Pair{Float64, String} + +The unit of the values of the field of the `CardView` `cv`: its `unit`, e.g. `W/m²` for the +intensity, `rel.` for a field that is normalized to its peak, e.g. the PSF. `_bar_prefix` is the SI +prefix (`factor => prefix`) with which the linear colorbar shows values up to `hi`, such that its +largest tick has two to four digits, e.g. `1e3 => "k"` for 25000 W/m²; none for a normalized field +or one without a unit. """ -_bar_unit(::_IntensityKind) = "W/m²" -_bar_unit(::_PSFKind) = "rel." -_bar_prefix(::_IntensityKind, hi::Real) = +_bar_unit(cv::CardView) = cv.normalize ? "rel." : cv.unit +_bar_prefix(cv::CardView, hi::Real) = (cv.normalize || isempty(cv.unit)) ? (1.0 => "") : _BAR_PREFIXES[something(findlast(p -> 10 * first(p) <= hi, _BAR_PREFIXES), 1)] -_bar_prefix(::_PSFKind, _) = 1.0 => "" """ - _bar_scale(kind, colorrange, colorscale) -> (; limits, ticks, unit) + _bar_scale(cv, colorrange, colorscale) -> (; limits, ticks, unit) -What the colorbar of the field `kind` shows for the `colorrange` of its image: its `limits`, its +What the colorbar of the field of the `CardView` `cv` shows for the `colorrange` of its image: its `limits`, its `ticks` (an object for the `xticks` of an `Axis`) and its `unit`. On the linear scale, the limits are the color range in the unit with the prefix of `_bar_prefix`. On the logarithmic scale, the color range and the limits are `log10` of the values in the unit without a prefix, with ticks at the powers of ten (at most about five, e.g. `10⁻²`); a range without a power of ten gets the values themselves as tick labels. """ -function _bar_scale(kind::_FieldKind, colorrange, colorscale::Symbol) +function _bar_scale(kind::CardView, colorrange, colorscale::Symbol) lo, hi = Float64.(Tuple(colorrange)) # A degenerate range has no limits hi > lo || (hi = lo + 1) @@ -1052,13 +1193,13 @@ function _bar_scale(kind::_FieldKind, colorrange, colorscale::Symbol) end """ - _set_bar!(view, kind, colorrange, colorscale) + _set_bar!(view, cv, colorrange, colorscale) -Shows the `colorrange` of the image of the field `kind` in the colorbar of the `view`, see +Shows the `colorrange` of the image of the field of the `CardView` `cv` in the colorbar of the `view`, see `_bar_scale`: the colormap spans its limits, i.e. the bar looks the same for every range and only its ticks and its unit change. """ -function _set_bar!(v::_DetectorView, kind::_FieldKind, colorrange, colorscale::Symbol) +function _set_bar!(v::_DetectorView, kind::CardView, colorrange, colorscale::Symbol) s = _bar_scale(kind, colorrange, colorscale) lo, hi = s.limits Makie.update!(v.bar_image; arg1 = (lo, hi)) @@ -1108,10 +1249,11 @@ function _update_cuts!(v::_DetectorView, c::Point2f) return nothing end -function _show_texts!(v::_DetectorView, kind::_ViewKind, r::_ViewResult) - _update!(v.kind_label.text, _with_preview(_kind_label(kind), r)) - _update!(v.info_label.text, _thumb_text(kind, r.metrics)) - _update!(v.metrics_label.text, _metrics_text(kind, r.metrics)) +function _show_texts!(v::_DetectorView, form::_ViewForm, r::_ViewResult) + cv = r.view + _update!(v.kind_label.text, _with_preview(cv.label, r)) + _update!(v.info_label.text, _thumb_text(form, cv, r.metrics, r.text)) + _update!(v.metrics_label.text, _metrics_text(form, cv, r.metrics, r.text)) _set_status!(v, _preview_text(r)) return nothing end @@ -1124,18 +1266,20 @@ end # No plots and the `message` in place of the kind and the metrics, e.g. without hits function _draw_empty!(v::_DetectorView, message::String) - v.kind = nothing + v.view = nothing v.scale = nothing v.zoomed = false v.xy = Point2f[] _set_frames!(v, RGBAf(0, 0, 0, 0)) foreach(p -> Makie.update!(p; visible = false), v.images) - foreach(p -> Makie.update!(p; arg1 = Point2f[], visible = false), v.spots) + foreach(p -> Makie.update!(p; arg1 = Point2f[], color = RGBAf[], visible = false), v.spots) foreach(p -> Makie.update!(p; arg1 = Point2f[]), v.crosses) + foreach(p -> Makie.update!(p; arg1 = Point2f[], visible = false), v.lines) _show_profiles!(v, false) _set_cuts!(v, nothing) _show_bar!(v, false) _show_field_controls!(v, false) + _show_series_controls!(v, false) _update!(v.kind_label.text, message) _update!(v.info_label.text, "") _update!(v.metrics_label.text, message) @@ -1188,20 +1332,21 @@ end # The limits of the axis were changed by the mouse: a field is computed again for this window by # the host, a spot diagram keeps them _limits_changed!(::_DetectorView, ::Nothing) = nothing -_limits_changed!(v::_DetectorView, ::_SpotKind) = (v.zoomed = true; nothing) -_limits_changed!(v::_DetectorView, ::_FieldKind) = (v.on_options(; window = 1e-3 .* _shown_limits(v.ax)); nothing) +_limits_changed!(v::_DetectorView, ::_ViewForm) = (v.zoomed = true; nothing) +_limits_changed!(v::_DetectorView, ::_FieldForm) = (v.on_options(; window = 1e-3 .* _shown_limits(v.ax)); nothing) """Shows all of the result in the view `v` again: the spots, or the field in its automatic window.""" function _fit!(v::_DetectorView) v.zoomed = false v.drag = nothing _end_selection!(v) - _fit_kind!(v, v.kind) + _fit_kind!(v, _form(v.view)) return nothing end _fit_kind!(::_DetectorView, ::Nothing) = nothing -_fit_kind!(v::_DetectorView, ::_SpotKind) = _fit_spots!(v.ax, v.xy) -function _fit_kind!(v::_DetectorView, ::_FieldKind) +_fit_kind!(v::_DetectorView, ::_PointsForm) = _fit_spots!(v.ax, v.xy) +_fit_kind!(v::_DetectorView, ::_SeriesForm) = _fit_series!(v.ax, v.xy) +function _fit_kind!(v::_DetectorView, ::_FieldForm) _view_limits!(v.ax, v.extent) isnothing(v.opts.window) || v.on_options(; window = nothing) return nothing @@ -1221,7 +1366,7 @@ end function _zoom_to!(v::_DetectorView, lims::NTuple{4, Float64}) (lims[2] - lims[1] > 1e-9 && lims[4] - lims[3] > 1e-9) || return nothing limits!(v.ax, lims...) - _limits_changed!(v, v.kind) + _limits_changed!(v, _form(v.view)) return nothing end @@ -1294,7 +1439,7 @@ end function _on_scroll!(v::_DetectorView, events, (_, dy)) p = Point2f(events.mouseposition[]) - (_over_axis(v, p) && !isnothing(v.kind)) || return Consume(false) + (_over_axis(v, p) && !isnothing(v.view)) || return Consume(false) iszero(dy) || _zoom!(v, p, (1 - _VIEW_ZOOM_SPEED)^dy) return Consume(true) end @@ -1314,7 +1459,7 @@ function _on_left!(v::_DetectorView, p::Point2f, ::Val{true}, ctrl::Bool) v.thumb_pressed = true return Consume(true) end - (_over_axis(v, p) && !isnothing(v.kind)) || return Consume(false) + (_over_axis(v, p) && !isnothing(v.view)) || return Consume(false) # the right button pans isnothing(v.drag) || return Consume(true) now = time() @@ -1344,7 +1489,7 @@ function _on_left!(v::_DetectorView, p::Point2f, ::Val{false}, ::Bool) end function _on_right!(v::_DetectorView, p::Point2f, ::Val{true}) - (_over_axis(v, p) && !isnothing(v.kind)) || return Consume(false) + (_over_axis(v, p) && !isnothing(v.view)) || return Consume(false) # the left button selects isnothing(v.select) || return Consume(true) v.drag, v.dragged = (p, _shown_limits(v.ax)), false @@ -1354,7 +1499,7 @@ end function _on_right!(v::_DetectorView, ::Point2f, ::Val{false}) isnothing(v.drag) && return Consume(false) v.drag = nothing - v.dragged && _limits_changed!(v, v.kind) + v.dragged && _limits_changed!(v, _form(v.view)) return Consume(true) end diff --git a/src/LiveDetectors.jl b/src/LiveDetectors.jl index 3db510e..0d77681 100644 --- a/src/LiveDetectors.jl +++ b/src/LiveDetectors.jl @@ -1,16 +1,17 @@ #= -Detectors of the live view: the state of the view of each detector, its computation after a -solve and the `detectors` kwarg of `live_view` +The views of the pages "Results" of the live view (see `card_views`): the state of the view of +each object, its computation after a solve, the series, and the `detectors` kwarg of `live_view` =# """ _has_view(obj) -> Bool -Whether the card of `obj` has the page "Results" with a view of what `obj` measures (see -`_DetectorView`): a `Detector`. Add a method for a type to give its card this page. +Whether the card of `obj` has the page "Results" with its views (see `card_views` and +`_DetectorView`): an object with views, and a `Detector` also without hits, whose page then reads +"no hits". """ -_has_view(_) = false -_has_view(::BMO.Detector) = true +_has_view(@nospecialize(obj)) = !isempty(card_views(obj)) +_has_view(@nospecialize(_::BMO.Detector)) = true """Returns all `Detector`s of the `systems`, deduplicated by identity, in the order of discovery.""" function _find_detectors(systems) @@ -26,13 +27,53 @@ function _find_detectors(systems) end """ - _detector_state(gui, pd) -> _DetectorState + _detector_state(gui, obj) -> _DetectorState -The state of the view of the detector `pd` in the `gui`, created with the default options (see -`_ViewOptions`) when it is first asked for. +The state of the view of `obj` (an object with views, e.g. a detector) in the `gui`, created with +the default options (see `_ViewOptions`) when it is first asked for. """ -_detector_state(gui::LiveView, pd::BMO.Detector) = - get!(() -> _DetectorState(pd, _ViewOptions(), nothing, true, 0.0), gui.detectors.states, pd) +function _detector_state(gui::LiveView, @nospecialize(obj)) + states = gui.detectors.states + haskey(states, obj) && return states[obj] + return states[obj] = _DetectorState(obj, _ViewOptions()) +end + +""" + _view_objects(systems) -> Vector{Any} + +The objects of the `systems` (their leaves, deduplicated by identity, in the order of discovery) +that have views, see `card_views`, e.g. the detectors and own types; their series are recorded +after each solve, see `_view_requests`. +""" +function _view_objects(systems) + seen = Base.IdSet{Any}() + objs = Any[] + for sys in systems, obj in _leaves(sys) + (obj in seen || isempty(card_views(obj))) && continue + push!(seen, obj) + push!(objs, obj) + end + return objs +end + +"""The states of the objects of the `systems` in the `gui`, e.g. to mark them stale, see `_view_objects`.""" +function _view_states(gui::LiveView, systems) + states = gui.detectors.states + out = _DetectorState[] + seen = Base.IdSet{Any}() + for sys in systems, obj in _leaves(sys) + (obj in seen || !haskey(states, obj)) && continue + push!(seen, obj) + push!(out, states[obj]) + end + return out +end + +# The `CardView` that the options `opts` of `obj` show, `nothing` without views +function _shown_card_view(@nospecialize(obj), opts) + views = Tuple(card_views(obj)) + return isempty(views) ? nothing : _resolve_view(views, opts.kind) +end #= Views that are shown: on the floating cards (see `_card_view`) and in the layout (see @@ -76,13 +117,13 @@ function _shown_views(gui::LiveView) end """ - _register_view!(gui, pd, view) + _register_view!(gui, obj, view) -Registers the `view` (a `_DetectorView`) of the detector `pd` that a host outside the cards and the +Registers the `view` (a `_DetectorView`) of the object `obj` that a host outside the cards and the layout of the `gui` shows, e.g. in a figure of its own: it is computed and shown like the views of the cards while it is registered, see `_shown_views`. """ -function _register_view!(gui::LiveView, pd::BMO.Detector, view) +function _register_view!(gui::LiveView, @nospecialize(pd), view) push!(gui.detectors.registered, (pd, view)) _view_needed!(gui, pd) return view @@ -96,40 +137,64 @@ Computation: a request per detector with a shown view, computed with the fields """ _ViewRequest -The computation of the view of the detector `pd` with the options `opts` (those of its `state` when +The computation of the view of the object `pd` with the options `opts` (those of its `state` when the request was made) on a grid of `n` points per axis, see `_view_requests`. It is computed in a -background task, see `_view_field`, and shown by `_apply_view!`. +background task, see `_view_field`, and shown by `_apply_view!`. A request that is not `shown` +only records the value of a series view, the `kind` of its `opts`, that is not shown. """ struct _ViewRequest - pd::BMO.Detector + pd::Any state::_DetectorState opts::Any n::Int + shown::Bool end +_ViewRequest(@nospecialize(pd), state, opts, n) = _ViewRequest(pd, state, opts, n, true) + """ _view_requests(gui; stale_only = false) -> Vector{Any} -A request per detector of which the `gui` shows a view (see `_shown_views`), on the grid of its -options if one of its views is expanded, on the grid of a thumbnail (`_THUMB_N`) otherwise. With -`stale_only`, only for the views that need a computation, see `_needs_result`. +A request per object of which the `gui` shows a view (see `_shown_views`), on the grid of its +options if one of its views is expanded, on the grid of a thumbnail (`_THUMB_N`) otherwise, and +one per series view of the objects of the systems that is not shown (see `_view_objects`), which +records its value once per solve, see `_record_series`. With `stale_only`, only for the views that +need a computation (see `_needs_result`) and the series that miss the value of the last solve; +neither for views that wait for a full result while objects move, see `_waits`. """ function _view_requests(gui::LiveView; stale_only::Bool = false) requests = Any[] seen = Base.IdSet{Any}() views = _shown_views(gui) + shown = Dict{Any, Symbol}() for (pd, _) in views pd in seen && continue push!(seen, pd) state = _detector_state(gui, pd) expanded = any(((p, v),) -> p === pd && v.expanded, views) n = expanded ? state.opts.num_points : min(state.opts.num_points, _THUMB_N) - (stale_only && !_needs_result(gui, state, n)) && continue + cv = _shown_card_view(pd, state.opts) + isnothing(cv) || (shown[pd] = cv.name) + stale_only && (!_needs_result(gui, state, n) || _waits(gui, cv)) && continue push!(requests, _ViewRequest(pd, state, state.opts, n)) end + for obj in _view_objects(_systems(gui)), cv in card_views(obj) + (cv.form === :series && get(shown, obj, nothing) !== cv.name) || continue + state = _detector_state(gui, obj) + stale_only && (_waits(gui, cv) || get(state.recorded, cv.name, -1) == gui.detectors.solve) && continue + push!(requests, _ViewRequest(obj, state, _ViewOptions(state.opts; kind = cv.name), state.opts.num_points, false)) + end return requests end +""" +Whether the view `cv` waits for a full result: a view without `preview` (see `CardView`) is not +computed from the hits of a preview solve or while objects move (a coarse result), but once the +movement pauses, see `_on_idle!`. +""" +_waits(gui::LiveView, cv::CardView) = !cv.preview && (gui.trace.coarse || gui.trace.preview) +_waits(::LiveView, ::Nothing) = false + """ Whether the view of the `state` must be computed on a grid of `n` points: it is stale (and no zoom is waiting for the mouse to rest, see `_set_view!`), or its result is of a coarser grid, e.g. of a @@ -146,29 +211,87 @@ _window_pending(gui::LiveView, state::_DetectorState) = state.window_changed > 0 && time() - state.window_changed < gui.trace.idle_delay """ - _view_field(r::_ViewRequest, coarse::Bool, preview::Bool) -> _ViewResult + _view_field(r::_ViewRequest, coarse::Bool, preview::Bool) -> Union{Nothing, _ViewResult} -Computes the view of the request `r` from the hits of its detector, without changing any plot, such -that it can run in a background task, see `_compute` and `_view_result`. +Computes the view of the request `r` from the current state of its object, e.g. the hits of a +detector, without changing any plot, such that it can run in a background task, see `_compute` and +`_view_result`. `nothing` for a view without `preview` (see `CardView`) after a preview solve or +while objects move (`coarse`): it is computed once the movement pauses. """ -_view_field(r::_ViewRequest, coarse::Bool, preview::Bool) = - _view_result(r.pd, r.opts; n = r.n, coarse, preview) +function _view_field(r::_ViewRequest, coarse::Bool, preview::Bool) + if coarse || preview + cv = _shown_card_view(r.pd, r.opts) + (isnothing(cv) || cv.preview) || return nothing + end + return _view_result(r.pd, r.opts; n = r.n, coarse, preview) +end """ _apply_view!(gui, r::_ViewRequest, result) -Stores the `result` of the request `r` in the state of its detector and shows it in all views of -the detector, see `_redraw_views!`. The state stays stale if its options changed while the result -was computed. +Stores the `result` of the request `r` in the state of its object and shows it in all views of +the object, see `_redraw_views!`; the value of a series is recorded first, see `_record_series`. +The state stays stale if its options changed while the result was computed, and for a result that +was not computed (`nothing`, see `_view_field`). A request that is not shown only records. """ function _apply_view!(gui::LiveView, r::_ViewRequest, result) + isnothing(result) && return nothing state = r.state + result = _record_series(gui, state, result) + r.shown || return nothing state.result = result state.stale = state.opts !== r.opts _redraw_views!(gui, r.pd) return nothing end +# The most values of a series that are kept, the oldest are dropped +const _SERIES_MAX = 10_000 + +""" + _record_series(gui, state, result) -> _ViewResult + +The `result` of a series view with the values that the `state` recorded: the value of `result` is +appended once per solve of the `gui` (see `_DetectorStates`), not from a preview or a coarse +result, at most `_SERIES_MAX` values. Other results are returned as they are. +""" +function _record_series(gui::LiveView, state::_DetectorState, r::_ViewResult) + cv = r.view + (isnothing(cv) || cv.form !== :series) && return r + values = get!(() -> Float64[], state.series, cv.name) + if isnothing(r.error) && !r.preview && !r.coarse && get(state.recorded, cv.name, -1) != gui.detectors.solve + push!(values, Float64(r.data)) + length(values) > _SERIES_MAX && deleteat!(values, 1:(length(values) - _SERIES_MAX)) + state.recorded[cv.name] = gui.detectors.solve + end + return _series_result(r, values) +end + +# The result `r` of a series with its recorded `values` +_series_result(r::_ViewResult, values::Vector{Float64}) = + _ViewResult(r.views, r.view, r.n, r.coarse, r.preview; data = copy(values), text = r.text, error = r.error, + metrics = _form_metrics(_SeriesForm(), r.view, values)) + +# Whether the result `r` is that of a series view +_shows_series(r::_ViewResult) = !isnothing(r.view) && r.view.form === :series +_shows_series(_) = false + +""" + _clear_series!(gui, obj) + +Empties the series that the view of `obj` shows (the button "clear" of its view) and shows it again. +""" +function _clear_series!(gui::LiveView, @nospecialize(obj)) + state = _detector_state(gui, obj) + r = state.result + _shows_series(r) || return nothing + values = get!(() -> Float64[], state.series, r.view.name) + empty!(values) + state.result = _series_result(r, values) + _redraw_views!(gui, obj) + return nothing +end + """ _redraw_views!(gui, pd) @@ -185,11 +308,13 @@ function _redraw_views!(gui::LiveView, pd) end """ -Marks the views of all detectors of the `gui` stale before a solve empties the detectors; until its -result is shown, the hits are incomplete, see `_views_applied!`. +Marks the views of all objects of the `gui` stale before a solve empties the detectors; until its +result is shown, the hits are incomplete, see `_views_applied!`. Counts the solve, see +`_record_series`. """ function _views_solve_started!(gui::LiveView) gui.detectors.hits_valid = false + gui.detectors.solve += 1 for state in values(gui.detectors.states) state.stale = true end @@ -221,7 +346,7 @@ function _detectors_moved!(gui::LiveView, @nospecialize(obj)) isnothing(obj) && return nothing moved = false for leaf in _leaves(obj) - state = leaf isa BMO.Detector ? get(gui.detectors.states, leaf, nothing) : nothing + state = get(gui.detectors.states, leaf, nothing) isnothing(state) && continue state.stale = moved = true end @@ -280,19 +405,26 @@ function _views_idle!(gui::LiveView) end """ - _set_view!(gui, pd; kind, colorscale, colorbar, profiles, window) + _set_view!(gui, obj; kind, colorscale, colorbar, profiles, window, spot_colors, clear) -Sets options of the view of the detector `pd` from its widgets, only those given: the `kind` -(`:auto` or the name of a kind, see `_view_kinds`) and the `window` of a field view (the visible -rectangle `(x_min, x_max, z_min, z_max)` [m] after zoom or pan, `nothing` for the automatic one) -need a new result, the `colorscale`, the `colorbar` and the `profiles` only show the result again. A new window is -computed once the mouse rests for `idle_delay`, see `_views_idle!`, not per step of the wheel. +Sets options of the view of `obj` from its widgets, only those given: the `kind` (`:auto` or the +`name` of a `CardView`, see `card_views`), the `window` of a field view (the visible rectangle +`(x_min, x_max, z_min, z_max)` [m] after zoom or pan, `nothing` for the automatic one) and the +`spot_colors` need a new result, the `colorscale`, the `colorbar` and the `profiles` only show the +result again. A new window is computed once the mouse rests for `idle_delay`, see `_views_idle!`, +not per step of the wheel. `clear = true` (the button "clear" of a series) empties the series that +is shown, see `_clear_series!`. """ -function _set_view!(gui::LiveView, pd; changes...) +function _set_view!(gui::LiveView, @nospecialize(pd); changes...) + if haskey(changes, :clear) + changes[:clear] && _clear_series!(gui, pd) + changes = Base.structdiff(NamedTuple(changes), NamedTuple{(:clear,)}) + end state = _detector_state(gui, pd) changes = (; (k => v for (k, v) in pairs(changes) if getfield(state.opts, k) != v)...) isempty(changes) && return nothing state.opts = _ViewOptions(state.opts; changes...) + haskey(changes, :spot_colors) && (state.stale = true; _refresh_views!(gui)) if haskey(changes, :window) state.stale = true state.window_changed = time() @@ -314,7 +446,7 @@ The `detectors` kwarg of `live_view` const _DETECTOR_KINDS = (:auto, :spot, :psf, :intensity) # Options of a view in the kwargs of a detector; `num_points` is passed on to its `RegularGrid`, the others are not keywords of it -const _VIEW_OPTION_KEYS = (:num_points, :colorscale, :colorrange, :colorbar, :profiles, :expanded) +const _VIEW_OPTION_KEYS = (:num_points, :colorscale, :colorrange, :colorbar, :profiles, :expanded, :spot_colors) const _DETECTOR_SPEC_HINT = "use `pd`, `pd => kind` or `pd => (kind, kwargs)`" @@ -332,7 +464,7 @@ The detectors of the `detectors` kwarg of [`live_view`](@ref) whose cards start `(; pd, opts, expanded)` with the options of its view (a `_ViewOptions`) and whether the view starts expanded: none for `:auto`, otherwise an entry per `pd`, `pd => kind` or `pd => (kind, kwargs)`. The `kwargs` hold the options `num_points`, `colorscale`, `colorrange`, `colorbar`, -`profiles` and `expanded`; all others are keywords of the sampling `BeamletOptics.RegularGrid`. +`profiles`, `expanded` and `spot_colors`; all others are keywords of the sampling `BeamletOptics.RegularGrid`. """ function _detector_specs(detectors) if detectors isa Symbol @@ -370,7 +502,7 @@ function _view_spec(pd, kind::Symbol, kwargs::NamedTuple) rest = NamedTuple(k => v for (k, v) in pairs(kwargs) if !(k in _VIEW_OPTION_KEYS)) opts = _ViewOptions(; kind, num_points = Int(get(kwargs, :num_points, 100)), kwargs = rest, colorscale, colorrange = get(kwargs, :colorrange, nothing), colorbar = Bool(get(kwargs, :colorbar, true)), - profiles = Bool(get(kwargs, :profiles, false))) + profiles = Bool(get(kwargs, :profiles, false)), spot_colors = get(kwargs, :spot_colors, nothing)) return (; pd, opts, expanded = Bool(get(kwargs, :expanded, true))) end diff --git a/src/LiveLinks.jl b/src/LiveLinks.jl index b5c2ced..91211da 100644 --- a/src/LiveLinks.jl +++ b/src/LiveLinks.jl @@ -654,9 +654,10 @@ function _follow_solved!(gui::LiveView, from::LiveView, systems, @nospecialize(o trace.pending = false gui.status.text[] = _LINK_TRACED end - states = _DetectorState[gui.detectors.states[pd] for pd in _find_detectors(systems) - if haskey(gui.detectors.states, pd)] + states = _view_states(gui, systems) foreach(state -> state.stale = true, states) + # A series gets the value of the solve of `from`, see `_record_series` + preview || (gui.detectors.solve += 1) # the beams of a preview, which the layers of the debug mode show as well preview && return _debug_solved!(gui) if !trace.stale diff --git a/src/LiveResultsView.jl b/src/LiveResultsView.jl new file mode 100644 index 0000000..1c106ac --- /dev/null +++ b/src/LiveResultsView.jl @@ -0,0 +1,191 @@ +#= +The views of the page "Results" of a card in a figure of its own: `results_view!`, `ResultsView` +and `refresh!`. The widget is the one of the cards (`_DetectorView`); with a live view it shares +the options and the result of the object with its cards, without one it keeps its own options and +computes only when it is built, on `refresh!` and on its own widgets +=# + +""" + ResultsView{G} + +A view of the page "Results" of a card in a figure of its own, built by [`results_view!`](@ref): +it shows one of the [`card_views`](@ref) of an object, with the switch between them, zoom, pan and +the controls of a field like the card. `G` is the type of the [`live_view`](@ref) window that +updates it after each of its solves, or `Nothing` for a view that changes only on +[`refresh!`](@ref) and on its own widgets. + +It only draws: it has no public fields and hands back no data or metrics of what it shows; a +script that wants the numbers calls the function of the [`CardView`](@ref) itself. `close(view)` +removes it from its figure, after which it is neither computed nor drawn. +""" +mutable struct ResultsView{G} + const obj::Any + const gui::G + const widget::_DetectorView + # the options of a view without a live view; those of a live view are in its state + opts::_ViewOptions + closed::Bool +end + +Base.propertynames(::ResultsView) = () + +Base.show(io::IO, v::ResultsView) = + print(io, "ResultsView(", repr(getfield(v, :widget).name), getfield(v, :closed) ? ", closed)" : ")") + +function results_view!(position, gui::LiveView, @nospecialize(obj); kwargs...) + return _rv_build!(position, gui, gui.layout.theme, _label(gui, obj), obj; kwargs...) +end + +function results_view!(position, @nospecialize(obj); theme::Symbol = :light, + name::AbstractString = "Results", kwargs...) + return _rv_build!(position, nothing, _app_theme(theme), String(name), obj; kwargs...) +end + +function refresh!(v::ResultsView) + v.closed && throw(ArgumentError("the results view $(repr(v.widget.name)) is closed")) + _rv_refresh!(v) + return v +end + +function Base.close(v::ResultsView) + v.closed && return nothing + v.closed = true + _rv_detach!(v) + _delete_view!(v.widget) + return nothing +end + +#= +Building +=# + +function _rv_build!(position, gui, theme::NamedTuple, name::String, @nospecialize(obj); + view::Symbol = :auto, expanded::Bool = true, width::Real = 280, height::Real = 280, + spot_colors = nothing) + _rv_check_view(obj, view) + grid = _rv_grid(position) + ev = _rv_events(grid) + widget = _DetectorView(grid, theme; expanded, width, height) + widget.name = name + v = ResultsView(obj, gui, widget, _ViewOptions(; kind = view, spot_colors), false) + _connect_view!(widget, ev; on_options = (; changes...) -> _rv_on_options!(v; changes...), + on_expanded = e -> _rv_on_expanded!(v, e)) + _rv_attach!(v, view, spot_colors) + return v +end + +# The names of the views of `obj`, see `card_views` +_rv_view_names(@nospecialize(obj)) = Symbol[cv.name for cv in card_views(obj)] + +""" +Throws an `ArgumentError` if `obj` has no views, or if `view` is neither `:auto` nor the name of one +of them. An object with the page "Results" (`_has_view`) that offers no views now, e.g. a +`Detector` without hits, is not checked by name. +""" +function _rv_check_view(@nospecialize(obj), view::Symbol) + names = _rv_view_names(obj) + if isempty(names) + _has_view(obj) && return nothing + throw(ArgumentError("$(nameof(typeof(obj))) has no views on the page \"Results\", see `card_views`")) + end + (view === :auto || view in names) && return nothing + throw(ArgumentError("$(nameof(typeof(obj))) has no view :$view, its views are " * + join((":" * string(n) for n in names), ", "))) +end + +# The layout of the widget in the `position` +_rv_grid(position::GridLayout) = GridLayout(position[1, 1]) +_rv_grid(position) = GridLayout(position) + +# The events of the figure of the layout `grid` +function _rv_events(grid::GridLayout) + fig = Makie.GridLayoutBase.top_parent(grid) + fig isa Figure || throw(ArgumentError("the position of a results view must be in a Figure")) + return events(Makie.get_scene(fig)) +end + +# The options of the view of a live view are those of its object: the `view` and the +# `spot_colors` are set for all views of the object unless they are `:auto` and `nothing`; then it +# is computed with the shown views of the window +function _rv_attach!(v::ResultsView{<:LiveView}, view::Symbol, spot_colors) + gui = v.gui + view === :auto || _set_view!(gui, v.obj; kind = view) + isnothing(spot_colors) || _set_view!(gui, v.obj; spot_colors) + _register_view!(gui, v.obj, v.widget) + _redraw_views!(gui, v.obj) + _view_needed!(gui, v.obj) + return nothing +end +_rv_attach!(v::ResultsView{Nothing}, _, _) = (_rv_refresh!(v); nothing) + +_rv_detach!(::ResultsView{Nothing}) = nothing +_rv_detach!(v::ResultsView{<:LiveView}) = _rv_unregister_view!(v.gui, v.widget) + +""" + _rv_unregister_view!(gui, view) + +Removes the `view` (a `_DetectorView`) from the views that are registered in the `gui` (see +`_register_view!`), by identity: it is no longer computed after a solve. +""" +function _rv_unregister_view!(gui::LiveView, view) + registered = gui.detectors.registered + for i in length(registered):-1:1 + registered[i][2] === view && deleteat!(registered, i) + end + return nothing +end + +#= +Computing and showing +=# + +# The grid that the view of `v` needs with the options `opts`: of a thumbnail while it is collapsed +_rv_n(v::ResultsView, opts::_ViewOptions) = + v.widget.expanded ? opts.num_points : min(opts.num_points, _THUMB_N) + +# Without a live view: computed now from the current state of the object +function _rv_refresh!(v::ResultsView{Nothing}) + result = _view_result(v.obj, v.opts; n = _rv_n(v, v.opts)) + _show_result!(v.widget, v.widget.name, result, v.opts) + return nothing +end + +# With a live view: computed from the results of its last solve, like after a change of options +function _rv_refresh!(v::ResultsView{<:LiveView}) + _detector_state(v.gui, v.obj).stale = true + _view_needed!(v.gui, v.obj) + return nothing +end + +# The options that only show the result again, see `_set_view!` +const _RV_REDRAW_ONLY = (:colorscale, :colorbar, :profiles) + +# Called by the widget for the options that the user changed +_rv_on_options!(v::ResultsView{<:LiveView}; changes...) = _set_view!(v.gui, v.obj; changes...) +# Without a live view there is no series to `clear` (see `_set_view!`) +function _rv_on_options!(v::ResultsView{Nothing}; changes...) + v.closed && return nothing + opts = v.opts + changed = (; (k => x for (k, x) in pairs(changes) if k !== :clear && getfield(opts, k) != x)...) + isempty(changed) && return nothing + v.opts = _ViewOptions(opts; changed...) + if all(in(_RV_REDRAW_ONLY), keys(changed)) + _show_result!(v.widget, v.widget.name, v.widget.result, v.opts) + else + _rv_refresh!(v) + end + return nothing +end + +# Called by the widget when the user expands or collapses it +function _rv_on_expanded!(v::ResultsView{<:LiveView}, expanded::Bool) + _set_expanded!(v.widget, expanded) + _view_needed!(v.gui, v.obj) + return nothing +end +function _rv_on_expanded!(v::ResultsView{Nothing}, expanded::Bool) + v.closed && return nothing + _set_expanded!(v.widget, expanded) + expanded && _rv_refresh!(v) + return nothing +end diff --git a/src/LiveView.jl b/src/LiveView.jl index 1de86af..1e04a43 100644 --- a/src/LiveView.jl +++ b/src/LiveView.jl @@ -539,35 +539,45 @@ end """ _DetectorState -The view of the detector `pd` of a `LiveView`, shared by all cards that show it (see +The view of the page "Results" of the object `pd` of a `LiveView` (an object with +[`card_views`](@ref), e.g. a `Detector`), shared by all cards that show it (see `LiveDetectors.jl`): its options `opts` (a `_ViewOptions`), the `result` of its last computation (a `_ViewResult`, `nothing` before the first one) and whether it is `stale`, i.e. the hits of the detector or the options changed since. `window_changed` is the time of the last zoom or pan of a field view, whose recomputation waits until the mouse rests (`0.0` if none is pending), see -`_set_view!`. +`_set_view!`. `series` holds the values of each series view of the object (by its `name`), one per +full solve, and `recorded` the number of the solve (see `_DetectorStates`) of its last value, see +`_record_series`. """ mutable struct _DetectorState - const pd::BMO.Detector + const pd::Any opts::Any result::Any stale::Bool window_changed::Float64 + const series::Dict{Symbol, Vector{Float64}} + const recorded::Dict{Symbol, Int} end +_DetectorState(@nospecialize(obj), opts) = + _DetectorState(obj, opts, nothing, true, 0.0, Dict{Symbol, Vector{Float64}}(), Dict{Symbol, Int}()) + """ _DetectorStates -The detector views of a `LiveView`: the `states` per detector, created on demand (see +The views of the pages "Results" of a `LiveView`: the `states` per object, created on demand (see `_detector_state`); `hits_valid` is `true` while the hits of the detectors are those of a complete solve, i.e. stale views can be computed from them; `start` holds the specs of the `detectors` kwarg of [`live_view`](@ref), whose cards start pinned, see `_detector_specs`; `registered` the -views `(pd, view)` that are shown outside the cards and the layout, see `_register_view!`. +views `(obj, view)` that are shown outside the cards and the layout, see `_register_view!`; +`solve` counts the solves, such that a series gets one value per solve, see `_record_series`. """ Base.@kwdef mutable struct _DetectorStates states::IdDict{Any, _DetectorState} = IdDict{Any, _DetectorState}() hits_valid::Bool = false start::Vector{Any} = Any[] registered::Vector{Tuple{Any, Any}} = Tuple{Any, Any}[] + solve::Int = 0 end """ diff --git a/src/LiveWidgets.jl b/src/LiveWidgets.jl index a515ed5..9ff6368 100644 --- a/src/LiveWidgets.jl +++ b/src/LiveWidgets.jl @@ -191,9 +191,12 @@ function _Segmented(parent, options::Vector{Pair{Symbol, String}}; theme, grid = GridLayout(parent; default_colgap = 2, halign = :left, tellwidth = false, attributes...) sel = Observable(selected) ks = first.(options) - # the colors of the theme also where the figure has Makie's theme, e.g. on a floating card + # the colors of the theme also where the figure has Makie's theme, e.g. on a floating card; the + # stroke color is a color from the start, since a plot keeps the type of an attribute (Makie's + # default `:transparent` would fail once `look!` sets a color before the first draw) buttons = [Button(grid[1, i]; label, fontsize = 12, padding = (7, 7, 4, 4), cornerradius = 4, - buttoncolor_hover = t.hover, buttoncolor_active = t.accent_soft, strokewidth = 1) + buttoncolor_hover = t.hover, buttoncolor_active = t.accent_soft, strokewidth = 1, + strokecolor = t.border) for (i, (_, label)) in enumerate(options)] function look!(k) for (key, b) in zip(ks, buttons) diff --git a/test/TestLiveApp.jl b/test/TestLiveApp.jl index 8bec62c..d7a8f36 100644 --- a/test/TestLiveApp.jl +++ b/test/TestLiveApp.jl @@ -125,7 +125,7 @@ const GUI = BeamletOpticsGUI dark.controls.selected[] = pd view = dark.layout.inspector.card.view @test view isa GUI._DetectorView && view.theme == t - @test all(p -> p.color[] == t.text, view.spots) + @test all(p -> all(==(RGBAf(Makie.to_color(t.text))), p.color[]), view.spots) @test (view.profile_x.color[], view.profile_z.color[]) == (t.gizmo[1], t.gizmo[3]) close(dark) # the light theme keeps the colors of the compact layout diff --git a/test/TestLiveCard.jl b/test/TestLiveCard.jl index ea43bc5..8c52dce 100644 --- a/test/TestLiveCard.jl +++ b/test/TestLiveCard.jl @@ -739,7 +739,7 @@ BeamletOpticsGUI.card_actions(::CardTestObject) = () # computed from the hits of the last solve and shown state = GUI._detector_state(gui, pd) @test !state.stale && state.result.n == state.opts.num_points - @test v.result === state.result && GUI._kind_name(v.kind) == :intensity && v.name == "PD" + @test v.result === state.result && v.view.name == :intensity && v.name == "PD" # the view below the page bar, inside the card, at its left edge below the icon r, bg = Rect2f(v.full.layoutobservables.computedbbox[]), _rect(c.background) @test maximum(r)[2] < minimum(_rect(c.bar_part))[2] @@ -749,9 +749,9 @@ BeamletOpticsGUI.card_actions(::CardTestObject) = () @test _inside(gui) # its switch takes the clicks: the kind is an option of the detector _click!(gui, _center(_rect(v.switch.buttons[2]))) - @test state.opts.kind == :spot && GUI._kind_name(v.kind) == :spot && ctrl.selected[] === pd + @test state.opts.kind == :spot && v.view.name == :spot && ctrl.selected[] === pd GUI._set_view!(gui, pd; kind = :auto) - @test GUI._kind_name(v.kind) == :intensity + @test v.view.name == :intensity v.log_toggle.active[] = true @test state.opts.colorscale == :log v.log_toggle.active[] = false @@ -863,7 +863,7 @@ BeamletOpticsGUI.card_actions(::CardTestObject) = () @test c2 !== c && c2.page == :results && v2 isa GUI._DetectorView && v2 !== v && v2.expanded @test c2.view_size == GUI._CARD_VIEW_SIZE @test Set(GUI._shown_views(gui)) == Set([(pd, v), (pd2, v2)]) - @test isnothing(v2.kind) && isnothing(GUI._detector_state(gui, pd2).result.kind) + @test isnothing(v2.view) && isnothing(GUI._detector_state(gui, pd2).result.view) @test !GUI._overlaps(_rect(c.background), _rect(c2.background)) # a press on the view of a pinned card does not select its object _click!(gui, _axis_center(v)) @@ -900,9 +900,9 @@ BeamletOpticsGUI.card_actions(::CardTestObject) = () _select!(gui, pd) _tick!(gui) v = c.view - @test GUI._kind_name(v.kind) == :spot && v.switch.keys == [:spot, :psf] + @test v.view.name == :spot && v.switch.keys == [:spot, :psf] _click!(gui, _center(_rect(v.switch.buttons[2]))) - @test GUI._kind_name(v.kind) == :psf && GUI._detector_state(gui, pd).opts.kind == :psf + @test v.view.name == :psf && GUI._detector_state(gui, pd).opts.kind == :psf close(gui) end diff --git a/test/TestLiveCardViews.jl b/test/TestLiveCardViews.jl new file mode 100644 index 0000000..13c3807 --- /dev/null +++ b/test/TestLiveCardViews.jl @@ -0,0 +1,197 @@ +module TestLiveCardViews + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using Makie +using Test, Logging + +const BMO = BeamletOptics +const GUI = BeamletOpticsGUI + +GLMakie.activate!(; visible = false) + +# An own type with views: a beam block that counts the rays it absorbs +struct Counter{T} <: BMO.AbstractObject{T} + shape::BMO.PlanoSurfaceSDF{T} + count::Threads.Atomic{Int} +end + +Counter(width, thickness) = Counter(BMO.PlanoSurfaceSDF(thickness, width), Threads.Atomic{Int}(0)) + +BMO.interact3d(::BMO.AbstractSystem, c::Counter, ::Beam, ::Ray) = (Threads.atomic_add!(c.count, 1); nothing) +BMO.initialize!(c::Counter) = (c.count[] = 0; nothing) + +# A field of the count on a grid of `num_points` in the `window` +function _count_field(c::Counter; num_points, window) + x0, x1, z0, z1 = something(window, (-1e-3, 1e-3, -1e-3, 1e-3)) + x, z = range(x0, x1; length = num_points), range(z0, z1; length = num_points) + return (x, z, [c.count[] * exp(-(xi^2 + zi^2) / 1e-7) for xi in x, zi in z]) +end + +# Points with a color each, from the count +_count_points(::Counter) = ([Point2(1e-3 * i, 0.0) for i in 1:3], [:red, :green, :blue]) + +GUI.card_views(::Counter) = ( + CardView(c -> c.count[], :series; name = :count, label = "Count", unit = "rays", + text = (c, v) -> "count $(Int(v))"), + CardView(_count_field, :field; name = :map, label = "Map", unit = "rays/m²"), + CardView(_count_points, :points; name = :marks, label = "Marks"), + CardView(_ -> error("boom"), :points; name = :bad, label = "Bad")) + +@testset "Own views on the page Results" begin + + _live_view(args...; kwargs...) = + live_view(args...; merge((; trace_budget = Inf, throttle = false, detectors = [], + progress_delay = Inf), kwargs)...) + _retrace!(gui) = (retrace!(gui); wait_solve(gui)) + + _rays() = CollimatedSource([0.0, 0, 0], [0.0, 1, 0], 4e-3, 1e-6; num_rings = 2, num_rays = 40) + function _counter() + c = Counter(20e-3, 2e-3) + translate3d!(c, [0, 0.1, 0]) + return c + end + + @testset "the page and the views" begin + c = _counter() + @test GUI._has_view(c) && :results in GUI._card_pages(c) && GUI._default_page(c) == :results + # the forms: what the functions return, with the display helpers of the window + solve_system!(System([c]), _rays()) + n = c.count[] + @test n > 0 + r = GUI._view_result(c, GUI._ViewOptions()) + @test r.view.name == :count && r.data == n && r.text == "count $n" && isnothing(r.error) + r = GUI._view_result(c, GUI._ViewOptions(; kind = :map, num_points = 21)) + @test r.view.name == :map && size(r.data[3]) == (21, 21) && r.metrics.peak ≈ n + @test abs(r.metrics.cx) < 1e-12 && isfinite(r.metrics.wx) + r = GUI._view_result(c, GUI._ViewOptions(; kind = :map, window = (0.0, 1e-3, 0.0, 1e-3), num_points = 5)) + @test collect(extrema(r.data[1])) ≈ [0, 1e-3] + r = GUI._view_result(c, GUI._ViewOptions(; kind = :marks)) + @test length(r.data) == 3 && r.colors == [:red, :green, :blue] && r.metrics.n == 3 + # an error of a function is shown in the view + r = GUI._view_result(c, GUI._ViewOptions(; kind = :bad)) + @test r.view.name == :bad && r.error isa ErrorException && isnothing(r.data) + # the texts of the forms + cv = first(card_views(c)) + @test GUI._metrics_text(GUI._SeriesForm(), cv, (; n = 3, last = 40.0), "count 40") == + "count 40, last 40 rays\n3 solves" + @test GUI._thumb_text(GUI._SeriesForm(), cv, (; n = 0, last = NaN), nothing) == "no values\n0 solves" + end + + @testset "drawn by the widget" begin + c = _counter() + solve_system!(System([c]), _rays()) + fig = Figure(; size = (600, 600)) + v = GUI._DetectorView(GridLayout(fig[1, 1]), GUI._APP_THEMES[:light]) + show!(o) = GUI._show_result!(v, "C", GUI._view_result(c, o), o) + show!(GUI._ViewOptions(; kind = :marks)) + @test v.spots[2].visible[] && v.spots[2].color[] == RGBAf.(Makie.to_color.([:red, :green, :blue])) + @test v.names == GUI._POSITION_NAMES && v.ax.autolimitaspect[] == 1 + show!(GUI._ViewOptions()) + @test v.lines[2].visible[] && !v.spots[2].visible[] && v.lines[2][1][] == [Point2f(1, c.count[])] + @test v.names == ("solve", "rays") && isnothing(v.ax.autolimitaspect[]) + @test v.series_shown && !v.fields_shown && v.switch.keys == [:count, :map, :marks, :bad] + show!(GUI._ViewOptions(; kind = :map, num_points = 11)) + @test v.images[2].visible[] && !v.lines[2].visible[] && v.fields_shown && !v.series_shown + @test v.bar_unit.text[] == "rays/m²" + @test_logs (:error,) match_mode = :any show!(GUI._ViewOptions(; kind = :bad)) + @test v.metrics_label.text[] == "error" + + # a view that starts collapsed builds its switch when it is expanded: drawn without errors + fig = Figure() + rv = results_view!(fig[1, 1], c; view = :map, expanded = false) + screen = display(GLMakie.Screen(; visible = false), fig) + try + colorbuffer(screen) + @test_logs min_level = Logging.Error begin + GUI._rv_on_expanded!(rv, true) + colorbuffer(screen) + GUI._rv_on_options!(rv; kind = :marks) + colorbuffer(screen) + end + @test getfield(rv, :widget).view.name == :marks + finally + close(rv) + close(screen) + end + end + + @testset "in the live view: a series per solve" begin + c = _counter() + gui = _live_view(System([c]), _rays()) + state = GUI._detector_state(gui, c) + # recorded after each solve also while no card shows it + values = state.series[:count] + k = length(values) + @test k >= 1 && last(values) == c.count[] + _retrace!(gui) + _retrace!(gui) + @test length(values) == k + 2 + # not while objects move (a coarse solve), but once the movement pauses + GUI._resolve!(gui, nothing; coarse = true) + @test length(values) == k + 2 && gui.trace.coarse + gui.trace.last_change = 0.0 + GUI._on_idle!(gui) + wait_solve(gui) + @test length(values) == k + 3 && !gui.trace.coarse + # the view of a card shows the recorded values, "clear" empties them + GUI._set_view!(gui, c; kind = :count) + GUI._pin_view!(gui, c) + _retrace!(gui) + r = state.result + @test r.view.name == :count && r.data == values && length(values) == k + 4 + GUI._set_view!(gui, c; clear = true) + @test isempty(values) && isempty(state.result.data) && state.result.metrics.n == 0 + _retrace!(gui) + @test length(values) == 1 + # a view without `preview` waits for a full result while objects move + GUI._set_view!(gui, c; kind = :map) + _retrace!(gui) + before = state.result + @test before.view.name == :map && !before.coarse + GUI._resolve!(gui, nothing; coarse = true) + @test state.result === before && state.stale + gui.trace.last_change = 0.0 + GUI._on_idle!(gui) + wait_solve(gui) + @test state.result !== before && !state.result.coarse && !state.stale + # removing the object drops its series + remove_component!(gui, c) + @test !haskey(gui.detectors.states, c) + close(gui) + end + + @testset "spot colors of a detector" begin + pd = Detector(25e-3) + translate3d!(pd, [0, 0.05, 0]) + solve_system!(System([pd]), _rays()) + r = GUI._view_result(pd, GUI._ViewOptions(; kind = :spot, spot_colors = hit -> :red)) + @test length(r.colors) == length(r.data) == 40 && all(==(RGBAf(1, 0, 0, 1)), r.colors) + # beamlets: the contour of each hit in its color + pd = Detector(10e-3) + translate3d!(pd, [0, 0.2, 0]) + solve_system!(System([pd]), GaussianBeamlet([0.0, 0, 0], [0.0, 1, 0], 1e-6, 0.5e-3)) + r = GUI._view_result(pd, GUI._ViewOptions(; kind = :spot, spot_colors = hit -> :blue)) + @test length(r.colors) == length(r.data) > 1 && all(==(RGBAf(0, 0, 1, 1)), r.colors) + # through the `detectors` kwarg, and changed later + m = RoundPlanoMirror(25e-3, 5e-3) + pd = Detector(25e-3) + translate3d!(pd, [0, 0.05, 0]) + gui = _live_view(System([pd]), _rays(); detectors = [pd => (:spot, (; spot_colors = _ -> :green))]) + state = GUI._detector_state(gui, pd) + wait_solve(gui) + @test all(==(RGBAf(Makie.to_color(:green))), state.result.colors) + GUI._set_view!(gui, pd; spot_colors = _ -> :red) + wait_solve(gui) + @test all(==(RGBAf(1, 0, 0, 1)), state.result.colors) + close(gui) + end + + @testset "the GUI hands no results back" begin + # the public API: no function or field of a view returns its data + @test fieldnames(CardView) == (:f, :form, :name, :label, :unit, :normalize, :preview, :text) + @test propertynames(ResultsView) isa Tuple + @test !any(n -> occursin(r"metric|result_data|data", lowercase(string(n))), names(BeamletOpticsGUI)) + end +end + +end diff --git a/test/TestLiveDetectorView.jl b/test/TestLiveDetectorView.jl index 7903632..5b0743c 100644 --- a/test/TestLiveDetectorView.jl +++ b/test/TestLiveDetectorView.jl @@ -11,7 +11,7 @@ GLMakie.activate!(; visible = false) @testset "Detector view" begin - Spot, PSF, Intensity = GUI._SpotKind(), GUI._PSFKind(), GUI._IntensityKind() + Spot, PSF, Intensity = GUI._SPOT_VIEW, GUI._PSF_VIEW, GUI._INTENSITY_VIEW theme = GUI._APP_THEMES[:light] # 40 rays along +y on a detector @@ -85,23 +85,30 @@ GLMakie.activate!(; visible = false) _overlap(a, b) = all(minimum(a) .< maximum(b)) && all(minimum(b) .< maximum(a)) _any_overlap(boxes) = any(_overlap(boxes[i], boxes[j]) for i in eachindex(boxes) for j in 1:(i - 1)) - @testset "kinds" begin + @testset "views of a detector" begin for T in (BMO.RayHit{Float64}, BMO.PolarizedRayHit{Float64}) - @test GUI._view_kinds(T[]) == (Spot, PSF) + @test GUI._detector_views(T[]) === (Spot, PSF) end for T in (BMO.GaussianBeamletHit{Float64}, BMO.AstigmaticGaussianBeamletHit{Float64}) - @test GUI._view_kinds(T[]) == (Intensity, Spot) + @test GUI._detector_views(T[]) === (Intensity, Spot) end - @test GUI._view_kinds(nothing) == () - @test GUI._view_kinds(BMO.hits(_rays())) == (Spot, PSF) - - @test GUI._view_kinds(BMO.hits(_gauss())) == (Intensity, Spot) - @test map(GUI._kind_name, (Spot, PSF, Intensity)) == (:spot, :psf, :intensity) - @test map(GUI._kind_label, (Spot, PSF, Intensity)) == ("Spot", "PSF", "Intensity") + @test GUI._detector_views(nothing) == () + @test card_views(_rays()) === (Spot, PSF) + @test card_views(_gauss()) === (Intensity, Spot) + # a detector without hits has none, but keeps its page "Results" + @test card_views(Detector(1e-3)) == () && GUI._has_view(Detector(1e-3)) + @test card_views(1.0) == () && !GUI._has_view(1.0) + @test map(cv -> cv.name, (Spot, PSF, Intensity)) == (:spot, :psf, :intensity) + @test map(cv -> cv.label, (Spot, PSF, Intensity)) == ("Spot", "PSF", "Intensity") + @test map(cv -> cv.form, (Spot, PSF, Intensity)) == (:points, :field, :field) + @test PSF.normalize && !Intensity.normalize && Intensity.unit == "W/m²" + @test all(cv -> cv.preview, (Spot, PSF, Intensity)) @test GUI._THUMB_N == 48 - @test GUI._resolve_kind((Spot, PSF), :psf) == PSF - @test GUI._resolve_kind((Spot, PSF), :auto) == Spot - @test GUI._resolve_kind((Intensity, Spot), :psf) == Intensity + @test GUI._resolve_view((Spot, PSF), :psf) === PSF + @test GUI._resolve_view((Spot, PSF), :auto) === Spot + @test GUI._resolve_view((Intensity, Spot), :psf) === Intensity + @test_throws ArgumentError CardView(identity, :image; name = :x) + @test sprint(show, Spot) == "CardView(:spot, :points)" # dispatch instead of `isa` chains, images instead of heatmaps, only the declared BMO functions source = read(joinpath(pkgdir(GUI), "src", "LiveDetectorView.jl"), String) @@ -109,12 +116,17 @@ GLMakie.activate!(; visible = false) @test !occursin("heatmap", source) @test !occursin("BMO._", source) called = Set(m.captures[1] for m in eachmatch(r"BMO\.(\w+)\(", source)) - @test called ⊆ Set(["spot_diagram", "intensity", "hits", "is_cancelled", "RegularGrid", - "RectilinearGrid", "sample_points"]) + @test called ⊆ Set(["hits", "is_cancelled"]) + # the power is integrated by BeamletOptics: no weighted sum of an intensity in the GUI + views = read(joinpath(pkgdir(GUI), "src", "LiveCardViews.jl"), String) + @test !occursin(".weights", source * views) + called = Set(m.captures[1] for m in eachmatch(r"BMO\.(\w+)\(", views)) + @test called ⊆ Set(["spot_diagram", "intensity", "optical_power", "sample_points", "hits", + "RegularGrid", "RectilinearGrid"]) end @testset "scale of the colorbar" begin - I, psf = GUI._IntensityKind(), GUI._PSFKind() + I, psf = Intensity, PSF # linear: the color range in a unit in which the largest tick has two to four digits s = GUI._bar_scale(I, (0, 1350), :linear) @test s.limits == (0.0, 1350.0) && s.unit == "W/m²" && s.ticks === GUI._VIEW_BAR_TICKS @@ -157,15 +169,15 @@ GLMakie.activate!(; visible = false) @testset "results" begin pd = _rays() r = GUI._view_result(pd, GUI._ViewOptions()) - @test r.kinds == (Spot, PSF) && r.kind == Spot - @test r.data == BMO.spot_diagram(pd) && r.metrics.n == 40 + @test r.views === (Spot, PSF) && r.view === Spot + @test r.data == BMO.spot_diagram(pd) && r.metrics.n == 40 && isnothing(r.colors) @test isnothing(r.error) && !r.coarse && !r.preview && r.n == 100 # the PSF: the intensity of the rays, normalized to its peak, without a power o = GUI._ViewOptions(; kind = :psf, num_points = 20) r = GUI._view_result(pd, o) x, z, I = BMO.intensity(pd, RegularGrid(; num_points = 20)) - @test r.kind == PSF && r.n == 20 + @test r.view === PSF && r.n == 20 && isnothing(r.text) @test r.data[1] == x && r.data[2] == z && r.data[3] ≈ I ./ maximum(I) @test maximum(r.data[3]) ≈ 1 @test !haskey(r.metrics, :P) && isfinite(r.metrics.wx) @@ -184,34 +196,36 @@ GLMakie.activate!(; visible = false) # Gaussian beamlet: the intensity, a choice that the hits do not offer falls back pd = _gauss() r = GUI._view_result(pd, GUI._ViewOptions(; num_points = 40)) - @test r.kinds == (Intensity, Spot) && r.kind == Intensity + @test r.views === (Intensity, Spot) && r.view === Intensity @test r.data[3] == BMO.intensity(pd, RegularGrid(; num_points = 40))[3] - @test isapprox(r.metrics.P, optical_power(pd, RegularGrid(; num_points = 40)); rtol = 1e-9) + # the power of BeamletOptics on the grid of the view, from the intensity that was computed + x, z, I = r.data + P = BMO.optical_power(BMO.sample_points(pd, RectilinearGrid(x, z)), I) + @test abs(P - optical_power(pd, RegularGrid(; num_points = 40))) / P < 1e-12 + @test r.text == "P = $(GUI._fmt3(1e3 * P)) mW" # zoomed: the power in the window, that of `optical_power` with the same sampling w = (-0.2e-3, 0.3e-3, -0.25e-3, 0.25e-3) r = GUI._view_result(pd, GUI._ViewOptions(; num_points = 40, window = w)) zoomed = RegularGrid(; num_points = 40, x_min = w[1], x_max = w[2], z_min = w[3], z_max = w[4]) @test r.data[3] == BMO.intensity(pd, zoomed)[3] - @test isapprox(r.metrics.P, optical_power(pd, zoomed); rtol = 1e-12) - @test r.metrics.P < optical_power(pd, RegularGrid(; num_points = 40)) - @test GUI._view_result(pd, GUI._ViewOptions(; kind = :psf, num_points = 20)).kind == Intensity + @test r.text == "P = $(GUI._fmt3(1e3 * optical_power(pd, zoomed))) mW" + @test optical_power(pd, zoomed) < optical_power(pd, RegularGrid(; num_points = 40)) + @test GUI._view_result(pd, GUI._ViewOptions(; kind = :psf, num_points = 20)).view === Intensity r = GUI._view_result(pd, GUI._ViewOptions(; kind = :spot)) - @test r.kind == Spot && haskey(r.metrics, :rms) + @test r.view === Spot && haskey(r.metrics, :rms) # no hits, an error, a background task r = GUI._view_result(Detector(1e-3), GUI._ViewOptions()) - @test r.kinds == () && isnothing(r.kind) && isnothing(r.data) && isnothing(r.metrics) && isnothing(r.error) - r = GUI._view_result(pd, GUI._ViewOptions(; kwargs = (; crop_factor = "wide"))) - @test r.error isa Exception && r.kinds == (Intensity, Spot) && r.kind == Intensity && isnothing(r.data) - @test fetch(Threads.@spawn GUI._view_result(pd, GUI._ViewOptions(; num_points = 20))).kind == Intensity - # hits whose field can not be evaluated: the spot diagram, with the kind :auto only - astigmatic = BMO.AstigmaticGaussianBeamletHit{Float64}[] - # an invalid sampling keyword makes the field fail + @test r.views == () && isnothing(r.view) && isnothing(r.data) && isnothing(r.metrics) && isnothing(r.error) + # an invalid sampling keyword makes the field fail: an error for the chosen view failing = (; num_points = 20, kwargs = (; crop_factor = "wide")) - @test GUI._shown_data(Intensity, pd, astigmatic, GUI._ViewOptions(; failing...), 20, false)[1] == Spot - @test_throws TypeError GUI._shown_data(Intensity, pd, astigmatic, - GUI._ViewOptions(; kind = :intensity, failing...), 20, false) - @test GUI._shown_data(Intensity, pd, astigmatic, GUI._ViewOptions(; num_points = 20), 20, false)[1] == Intensity + r = GUI._view_result(pd, GUI._ViewOptions(; kind = :intensity, failing...)) + @test r.error isa TypeError && r.views === (Intensity, Spot) && r.view === Intensity && isnothing(r.data) + @test fetch(Threads.@spawn GUI._view_result(pd, GUI._ViewOptions(; num_points = 20))).view === Intensity + # with the kind :auto, the next view if the first one fails, e.g. hits whose field can not + # be evaluated + r = GUI._view_result(pd, GUI._ViewOptions(; failing...)) + @test r.view === Spot && isnothing(r.error) && r.metrics.n > 0 end @testset "metrics" begin @@ -223,8 +237,9 @@ GLMakie.activate!(; visible = false) @test abs(mt.cx - c[1]) < 1e-9 && abs(mt.cz - c[2]) < 1e-9 @test abs(mt.rms - sqrt(2.5) * 1e-3) < 1e-9 @test abs(mt.rmax - 2e-3) < 1e-9 - @test startswith(GUI._metrics_string(mt), "N = 4, c = (") - @test occursin("rms ", GUI._metrics_string(mt)) + text = GUI._metrics_text(GUI._PointsForm(), Spot, mt, nothing) + @test startswith(text, "N = 4, c = (") && occursin("\nrms ", text) + @test GUI._spot_metrics(Point2[]).n == 0 && isnan(GUI._spot_metrics(Point2[]).cx) # ray hits pd = _rays() @@ -238,8 +253,7 @@ GLMakie.activate!(; visible = false) # Gaussian beamlet: power and 1/e² radius pd = _gauss() m = GUI._view_result(pd, GUI._ViewOptions()).metrics - @test m == GUI._intensity_metrics(pd, BMO.intensity(pd)...) - @test isapprox(m.P, optical_power(pd); rtol = 1e-3) + @test m == GUI._field_metrics(BMO.intensity(pd)...) && !haskey(m, :P) # waist 0.5 mm at the source, 200 mm to the detector zR = π * 0.5e-3^2 / 1e-6 w = 0.5e-3 * sqrt(1 + (0.2 / zR)^2) @@ -247,9 +261,13 @@ GLMakie.activate!(; visible = false) @test isapprox(m.wz, w; rtol = 0.02) @test abs(m.cx) < 1e-6 && abs(m.cz) < 1e-6 @test m.peak ≈ maximum(BMO.intensity(pd)[3]) - @test startswith(GUI._metrics_string(m), "P = ") - @test GUI._metrics_string((; P = 1e-3, cx = NaN, cz = NaN, wx = NaN, wz = NaN, peak = 0.0)) == + r = GUI._view_result(pd, GUI._ViewOptions()) + @test startswith(GUI._metrics_text(GUI._FieldForm(), Intensity, m, r.text), "P = ") + @test isapprox(parse(Float64, split(r.text)[3]), 1e3 * optical_power(pd); rtol = 1e-3) + empty = (; cx = NaN, cz = NaN, wx = NaN, wz = NaN, peak = 0.0) + @test GUI._metrics_text(GUI._FieldForm(), Intensity, empty, "P = 1.000 mW") == "P = 1.000 mW, peak 0 W/m²\nno intensity" + @test GUI._metrics_text(GUI._FieldForm(), PSF, empty, nothing) == "PSF, normalized to its peak\nno intensity" # logarithmic color scale with a floor _, _, I = BMO.intensity(pd, RegularGrid(; num_points = 20, x_min = -2.5e-3, x_max = 2.5e-3, z_min = -2.5e-3, z_max = 2.5e-3)) @@ -343,7 +361,7 @@ GLMakie.activate!(; visible = false) @test abs(minimum(bar)[1] - minimum(frame)[1]) < 1 && maximum(bar)[1] < minimum(unit)[1] @test abs(maximum(unit)[1] - maximum(frame)[1]) < 1 # its limits are the color range of the image in its unit, the colormap spans them - s = GUI._bar_scale(GUI._IntensityKind(), v.images[2].colorrange[], :linear) + s = GUI._bar_scale(Intensity, v.images[2].colorrange[], :linear) @test endswith(s.unit, "W/m²") && v.bar_unit.text[] == s.unit @test collect(_limits(v.bar_ax)) ≈ [s.limits..., 0, 1] && collect(v.bar_image[1][]) ≈ collect(s.limits) @test v.bar_image.transformation.translation[][3] > 0 @@ -509,7 +527,7 @@ GLMakie.activate!(; visible = false) # no hits: no plots, no switch _show!(v, Detector(1e-3)) @test v.kind_label.text[] == "no hits" && v.metrics_label.text[] == "no hits" - @test isnothing(v.switch) && isnothing(v.kind) + @test isnothing(v.switch) && isnothing(v.view) @test !v.spots[2].visible[] && !v.images[2].visible[] && isempty(v.crosses[2][1][]) @test isempty(v.xlabels.text[]) && !v.xname.visible[] && v.status.text[] == ["no hits"] @test !first(v.switches[(:spot, :psf)].buttons).blockscene.visible[] @@ -522,7 +540,7 @@ GLMakie.activate!(; visible = false) @test v.metrics_label.text[] == "no result" # an error: shown and logged once, the switch stays for another choice - bad = GUI._ViewOptions(; kwargs = (; crop_factor = "wide")) + bad = GUI._ViewOptions(; kind = :intensity, kwargs = (; crop_factor = "wide")) @test_logs (:error,) match_mode = :any _show!(v, gauss, bad) @test v.metrics_label.text[] == "error" && v.kind_label.text[] == "error" @test v.switch.keys == [:intensity, :spot] && !v.images[2].visible[] diff --git a/test/TestLiveDetectors.jl b/test/TestLiveDetectors.jl index 7128545..961810b 100644 --- a/test/TestLiveDetectors.jl +++ b/test/TestLiveDetectors.jl @@ -117,7 +117,7 @@ BMO.initialize!(b::CountingBlock) = (b.count[] = 0; nothing) state = GUI._detector_state(gui, pd) # a view that is shown after the solve is computed from its hits @test GUI._shown_views(gui) == [(pd, view)] - @test !state.stale && GUI._kind_name(state.result.kind) == :spot + @test !state.stale && state.result.view.name == :spot @test state.result.metrics.n == 40 @test GUI._signal_text(gui, pd) == "N = 40" @test isnothing(gui.trace.job) @@ -132,7 +132,7 @@ BMO.initialize!(b::CountingBlock) = (b.count[] = 0; nothing) r = state.result GUI._set_view!(gui, pd; kind = :psf) @test state.opts.kind == :psf && state.result !== r && !state.stale - @test GUI._kind_name(state.result.kind) == :psf + @test state.result.view.name == :psf r = state.result GUI._set_view!(gui, pd; colorscale = :log) @test state.opts.colorscale == :log && state.result === r && !state.stale @@ -146,7 +146,7 @@ BMO.initialize!(b::CountingBlock) = (b.count[] = 0; nothing) @test state.stale && state.result === r @test GUI._signal_text(gui, pd) == "40 hits" GUI._resolve!(gui, nothing) - @test !state.stale && GUI._kind_name(state.result.kind) == :spot + @test !state.stale && state.result.view.name == :spot close(gui) end @@ -155,7 +155,7 @@ BMO.initialize!(b::CountingBlock) = (b.count[] = 0; nothing) gui = _live_view(System([m, pd]), _gauss()) view = _view!(gui, pd; expanded = false) state = GUI._detector_state(gui, pd) - @test GUI._kind_name(state.result.kind) == :intensity + @test state.result.view.name == :intensity @test state.result.n == GUI._THUMB_N < state.opts.num_points @test startswith(GUI._signal_text(gui, pd), "P = ") # the expanded view needs the grid of the options @@ -170,7 +170,7 @@ BMO.initialize!(b::CountingBlock) = (b.count[] = 0; nothing) @test state.result.n == state.opts.num_points # the beams of a Gaussian beamlet as a spot diagram GUI._set_view!(gui, pd; kind = :spot) - @test GUI._kind_name(state.result.kind) == :spot + @test state.result.view.name == :spot close(gui) end @@ -252,7 +252,7 @@ BMO.initialize!(b::CountingBlock) = (b.count[] = 0; nothing) @test state.result.metrics.n == 40 && view.kind_label.text[] != "no hits" translate3d!(gui, pd, [0, 0, 0.5e-3]) @test length(pd) == 0 && gui.trace.stale - @test !state.stale && isempty(state.result.kinds) && view.kind_label.text[] == "no hits" + @test !state.stale && isempty(state.result.views) && view.kind_label.text[] == "no hits" @test GUI._signal_text(gui, pd) == "no hits" GUI._trace!(gui) @test state.result.metrics.n == 40 && view.kind_label.text[] != "no hits" diff --git a/test/TestLiveInspector.jl b/test/TestLiveInspector.jl index 35fcae3..2cdcba5 100644 --- a/test/TestLiveInspector.jl +++ b/test/TestLiveInspector.jl @@ -306,8 +306,8 @@ const GUI = BeamletOpticsGUI @test card.page == GUI._default_page(o.pd) == :results @test view isa GUI._DetectorView && view.expanded && card.view_expanded @test GUI._layout_views(gui) == [(o.pd, view)] && GUI._shown_views(gui) == [(o.pd, view)] - @test !state.stale && GUI._kind_name(state.result.kind) == :spot - @test view.result === state.result && view.name == "PD1" && view.kind == GUI._SpotKind() + @test !state.stale && state.result.view.name == :spot + @test view.result === state.result && view.name == "PD1" && view.view.name == :spot @test _visible(view.ax) && view.switch.keys == [:spot, :psf] # as wide as the sidebar, its axis as high as wide; below the page bar w = Makie.widths(_rect(insp.grid))[1] @@ -324,10 +324,10 @@ const GUI = BeamletOpticsGUI view.log_toggle.active[] = true @test state.opts.colorscale == :log view.switch.selected[] = :psf - @test state.opts.kind == :psf && GUI._kind_name(state.result.kind) == :psf - @test view.kind == GUI._PSFKind() + @test state.opts.kind == :psf && state.result.view.name == :psf + @test view.view.name == :psf view.switch.selected[] = :spot - @test view.kind == GUI._SpotKind() + @test view.view.name == :spot # the wheel over the view zooms its axis, not the camera of the 3D view cam = cameracontrols(gui.ax.scene) eye, limits = cam.eyeposition[], view.ax.finallimits[] @@ -359,8 +359,8 @@ const GUI = BeamletOpticsGUI ctrl.selected[] = o.pd2 @test card.view === view && card.page == :results @test GUI._layout_views(gui) == [(o.pd2, view)] - @test view.name == GUI._label(gui, o.pd2) != "PD1" && isnothing(view.kind) && isnothing(view.switch) - @test isempty(GUI._detector_state(gui, o.pd2).result.kinds) + @test view.name == GUI._label(gui, o.pd2) != "PD1" && isnothing(view.view) && isnothing(view.switch) + @test isempty(GUI._detector_state(gui, o.pd2).result.views) # an object without a view hides it, no selection too ctrl.selected[] = o.m @test isempty(GUI._layout_views(gui)) && !_visible(view.ax) diff --git a/test/TestLiveResultsView.jl b/test/TestLiveResultsView.jl new file mode 100644 index 0000000..4b55f52 --- /dev/null +++ b/test/TestLiveResultsView.jl @@ -0,0 +1,262 @@ +module TestLiveResultsView + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using Makie +using Test + +const BMO = BeamletOptics +const GUI = BeamletOpticsGUI + +GLMakie.activate!(; visible = false) + +# An own type with views: a beam block that counts the rays it absorbs +struct CountingBlock{T} <: BMO.AbstractObject{T} + shape::BMO.PlanoSurfaceSDF{T} + count::Threads.Atomic{Int} +end + +CountingBlock(width, thickness) = + CountingBlock(BMO.PlanoSurfaceSDF(thickness, width), Threads.Atomic{Int}(0)) + +BMO.interact3d(::BMO.AbstractSystem, b::CountingBlock, ::Beam, ::Ray) = + (Threads.atomic_add!(b.count, 1); nothing) + +BMO.initialize!(b::CountingBlock) = (b.count[] = 0; nothing) + +# A field of the count on a grid of `num_points` in the `window` +function _count_field(b::CountingBlock; num_points, window) + x0, x1, z0, z1 = something(window, (-1e-3, 1e-3, -1e-3, 1e-3)) + x, z = range(x0, x1; length = num_points), range(z0, z1; length = num_points) + return (x, z, [b.count[] * exp(-(xi^2 + zi^2) / 1e-7) for xi in x, zi in z]) +end + +GUI.card_views(::CountingBlock) = ( + CardView(b -> b.count[], :series; name = :count, label = "Count", unit = "rays"), + CardView(_count_field, :field; name = :map, label = "Map")) + +@testset "Results views in a figure of their own" begin + + _live_view(args...; kwargs...) = + live_view(args...; merge((; trace_budget = Inf, throttle = false, detectors = [], + progress_delay = Inf), kwargs)...) + + # Beam along +y, mirror at 45° reflects it along +x onto the detector + function _fixture() + m = RoundPlanoMirror(25e-3, 5e-3) + zrotate3d!(m, deg2rad(45)) + translate3d!(m, [0, 0.1, 0]) + pd = Detector(5e-3) + zrotate3d!(pd, -π / 2) + translate3d!(pd, [0.1, 0.1, 0]) + return m, pd + end + _rays() = CollimatedSource([0.0, 0, 0], [0.0, 1, 0], 2e-3, 1e-6; num_rings = 2, num_rays = 40) + + # What the widget of the view `v` shows: its result, the name of the view shown, whether it + # shows data without an error + _widget(v) = getfield(v, :widget) + _result(v) = _widget(v).result + _shown(v) = _shown_name(_result(v)) + _shown_name(::Nothing) = nothing + _shown_name(r) = isnothing(r.view) ? nothing : r.view.name + _ok(v) = (r = _result(v); !isnothing(r) && isnothing(r.error) && !isnothing(r.data)) + _registered(gui, v) = any(((_, w),) -> w === _widget(v), gui.detectors.registered) + _retrace!(gui) = (retrace!(gui); wait_solve(gui)) + + @testset "without a live view" begin + m, pd = _fixture() + sys = System([m, pd]) + fig = Figure() + # no hits yet: shown as such + v = results_view!(fig[1, 1], pd) + @test v isa ResultsView{Nothing} + @test isempty(propertynames(v)) + @test occursin("ResultsView(\"Results\"", sprint(show, v)) + @test isnothing(_shown(v)) + # it changes only on `refresh!` + solve_system!(sys, _rays()) + @test isnothing(_shown(v)) + @test refresh!(v) === v + @test _shown(v) == :spot && _ok(v) + r = _result(v) + BMO.initialize!(pd) + solve_system!(sys, _rays()) + @test _result(v) === r + refresh!(v) + @test _result(v) !== r && _shown(v) == :spot + + # the view by its name, the options of its own widgets + v2 = results_view!(fig[1, 2], pd; view = :psf, name = "PD", theme = :dark) + @test _shown(v2) == :psf && _widget(v2).name == "PD" + @test _widget(v2).theme === GUI._app_theme(:dark) + r = _result(v2) + _widget(v2).on_options(; colorscale = :log) + @test _result(v2) === r && _widget(v2).opts.colorscale == :log + _widget(v2).on_options(; kind = :spot) + @test _shown(v2) == :spot && _result(v2) !== r + window = (-1e-3, 1e-3, -1e-3, 1e-3) + _widget(v2).on_options(; kind = :psf) + _widget(v2).on_options(; window) + @test _shown(v2) == :psf && first(_result(v2).data[1]) ≈ -1e-3 + # expanding computes the grid of the options, a thumbnail a coarser one + _widget(v2).on_expanded(false) + @test !_widget(v2).expanded + r = _result(v2) + _widget(v2).on_expanded(true) + @test _widget(v2).expanded && _result(v2) !== r && _result(v2).n == getfield(v2, :opts).num_points + v3 = results_view!(fig[2, 1], pd; view = :psf, expanded = false) + @test !_widget(v3).expanded && _result(v3).n == GUI._THUMB_N + + # into a `GridLayout`, of the given size + v4 = results_view!(GridLayout(fig[2, 2]), pd; width = 200, height = 150) + @test _ok(v4) && _widget(v4).ax.width[] == 200 && _widget(v4).ax.height[] == 150 + + # closed: removed, neither computed nor drawn + close(v) + @test occursin("closed", sprint(show, v)) + @test isnothing(close(v)) + @test_throws ArgumentError refresh!(v) + r = _result(v2) + close(v2) + _widget(v2).on_options(; kind = :spot) + @test _result(v2) === r + close(v3); close(v4) + + # arguments + @test_throws ArgumentError results_view!(Figure()[1, 1], pd; theme = :blue) + @test_throws ArgumentError results_view!(Figure()[1, 1], m) + # a name that is not a view of the detector with its hits (`card_views(::Detector)`) + err = try + results_view!(Figure()[1, 1], pd; view = :intensity) + catch e + e + end + @test err isa ArgumentError && occursin(":spot, :psf", err.msg) + # a detector without hits offers no views yet, any name is accepted + v5 = results_view!(Figure()[1, 1], Detector(5e-3); view = :intensity) + @test isnothing(_shown(v5)) + close(v5) + + # spot colors per hit + red = Makie.to_color(:red) + v6 = results_view!(Figure()[1, 1], pd; view = :spot, spot_colors = _ -> :red) + @test _ok(v6) && length(_result(v6).colors) == length(_result(v6).data) && + all(==(red), _result(v6).colors) + close(v6) + end + + @testset "with a live view" begin + m, pd = _fixture() + gui = _live_view(System([m, pd]), _rays(); layout = :compact) + state = GUI._detector_state(gui, pd) + fig = Figure() + v = results_view!(fig[1, 1], gui, pd; view = :spot) + @test v isa ResultsView{typeof(gui)} + @test _registered(gui, v) && _widget(v).name == GUI._label(gui, pd) + # shown at once from the last solve, the view is set for the object + @test state.opts.kind == :spot && !state.stale + @test _result(v) === state.result && _shown(v) == :spot + # updated after each solve + r = state.result + _retrace!(gui) + @test state.result !== r && _result(v) === state.result + # `refresh!` computes it from the last solve of the window + r = state.result + @test refresh!(v) === v + @test state.result !== r && _result(v) === state.result + + # it shares the view, the grid, the scale and the zoom with a pinned card + GUI._pin_view!(gui, pd) + c = only(GUI._floating_cards(gui, pd)) + @test c.page == :results + _widget(v).on_options(; kind = :psf) + @test state.opts.kind == :psf && _shown(v) == :psf + _widget(v).on_options(; colorscale = :log) + @test state.opts.colorscale == :log && _widget(v).opts.colorscale == :log + window = (-1e-3, 1e-3, -1e-3, 1e-3) + _widget(v).on_options(; window) + @test state.opts.window == window + # a second view of the window: options given are set, the others stay + v2 = results_view!(fig[1, 2], gui, pd; expanded = false) + @test state.opts.kind == :psf && state.opts.colorscale == :log && _widget(v2).opts === state.opts + # a zoom is computed once the mouse rests for `idle_delay` + gui.trace.idle_delay = 0.0 + GUI._set_view!(gui, pd; kind = :spot, window = nothing) + @test _shown(v) == :spot && _shown(v2) == :spot + # the card's view, once it is laid out, shows the same + cv = c.view + @test !isnothing(cv) && cv.opts === state.opts && cv.result === state.result + # the spot colors are an option of the object, too + v3 = results_view!(fig[2, 1], gui, pd; spot_colors = _ -> :red) + @test !isnothing(state.opts.spot_colors) && state.opts.kind == :spot + @test all(==(Makie.to_color(:red)), _result(v3).colors) + close(v3) + # the switch of the card changes the views in the figure + cv.on_options(; kind = :psf) + @test _shown(v) == :psf && _shown(v2) == :psf + # expanding + _widget(v2).on_expanded(true) + @test _widget(v2).expanded + + # closed: no longer registered nor updated + close(v) + @test !_registered(gui, v) && _registered(gui, v2) + r = _result(v) + _retrace!(gui) + @test _result(v) === r && _result(v2) === state.result !== r + @test_throws ArgumentError refresh!(v) + close(v2) + @test isempty(gui.detectors.registered) + @test_throws ArgumentError results_view!(Figure()[1, 1], gui, m) + close(gui) + end + + @testset "an own type with card_views" begin + b = CountingBlock(20e-3, 2e-3) + translate3d!(b, [0, 0.1, 0]) + sys = System([b]) + rays = _rays() + # by name: an unknown one throws with the names of the views + err = try + results_view!(Figure()[1, 1], b; view = :nope) + catch e + e + end + @test err isa ArgumentError && occursin(":count", err.msg) && occursin(":map", err.msg) + # without a live view: the field of the current state, the zoom computes it again + solve_system!(sys, rays) + v = results_view!(Figure()[1, 1], b; view = :map) + @test _ok(v) && _shown(v) == :map && maximum(_result(v).data[3]) > 0 + window = (-0.5e-3, 0.5e-3, -0.5e-3, 0.5e-3) + _widget(v).on_options(; window) + @test first(_result(v).data[1]) ≈ -0.5e-3 + # the series: only its current value, nothing to clear + _widget(v).on_options(; kind = :count) + @test _ok(v) && _shown(v) == :count + r = _result(v) + _widget(v).on_options(; clear = true) + @test _result(v) === r + close(v) + + # with a live view: the series gets a value after each solve + gui = _live_view(sys, _rays()) + v = results_view!(Figure()[1, 1], gui, b) + @test _ok(v) && _shown(v) == :count + n = length(_result(v).data) + _retrace!(gui) + @test _ok(v) && length(_result(v).data) == n + 1 + _retrace!(gui) + @test length(_result(v).data) == n + 2 + # "clear" of the widget empties it + _widget(v).on_options(; clear = true) + @test isempty(_result(v).data) + # the field shares its window with the cards of the window + _widget(v).on_options(; kind = :map) + @test _shown(v) == :map && GUI._detector_state(gui, b).opts.kind == :map + close(v) + @test !_registered(gui, v) + close(gui) + end +end + +end diff --git a/test/TestLiveSplitter.jl b/test/TestLiveSplitter.jl index 6de17e2..6f343e0 100644 --- a/test/TestLiveSplitter.jl +++ b/test/TestLiveSplitter.jl @@ -594,7 +594,7 @@ const GUI = BeamletOpticsGUI views = (card.view, pinned.view) # the state of the views: the kind and the limits that the mouse set card.view.zoomed = true - kind = card.view.kind + kind = card.view.view b = _right_edge(gui) _mouse!(gui, b) _press!(gui) @@ -608,7 +608,7 @@ const GUI = BeamletOpticsGUI @test right.size == Fixed(400) && GUI._docked_width(gui) ≈ w + 100 @test all(v -> v.ax.width[] == w + 100 && v.ax.height[] == w + 100, views) @test (card.view, pinned.view) === views - @test card.view.zoomed && card.view.kind == kind && card.page == :results + @test card.view.zoomed && card.view.view == kind && card.page == :results @test card.view_obj === pd && pinned.view_obj === pd # narrower, and the double click b = _right_edge(gui) diff --git a/test/TestLiveView.jl b/test/TestLiveView.jl index 53aaad7..6f15e53 100644 --- a/test/TestLiveView.jl +++ b/test/TestLiveView.jl @@ -62,7 +62,7 @@ _points(h) = only(render_plots(h))[1][] # What the view of the `k`-th detector shows: the result, its kind, the points of a spot # diagram [mm], the field `(x, z, I)` and whether it is a preview _result(gui, k = 1) = GUI._detector_state(gui, _detectors(gui)[k]).result - _kind(gui, k = 1) = GUI._kind_name(_result(gui, k).kind) + _kind(gui, k = 1) = _result(gui, k).view.name _xy(gui, k = 1) = [Point2f(1e3 * q[1], 1e3 * q[2]) for q in _result(gui, k).data] _field(gui, k = 1) = _result(gui, k).data _is_preview(gui, k = 1) = _result(gui, k).coarse || _result(gui, k).preview @@ -76,7 +76,7 @@ _points(h) = only(render_plots(h))[1][] @test gui isa GUI.LiveView @test !hasfield(typeof(gui), :panels) @test _kind(gui) == :intensity - @test _result(gui).metrics.P ≈ optical_power(pd) + @test _result(gui).text == "P = $(GUI._fmt3(1e3 * optical_power(pd))) mW" # no card is pinned at the start @test !any(c -> c.pinned, gui.cards.all) && isempty(gui.detectors.start) @test sprint(show, gui) == "LiveView(1 systems, 1 sources, 1 detectors)" @@ -179,7 +179,7 @@ _points(h) = only(render_plots(h))[1][] gui.controls.fine_step = 0.1 @test_logs _key!(gui, Keyboard.page_up) @test isnothing(BMO.hits(pd)) - @test isnothing(_result(gui).kind) && isempty(_result(gui).kinds) + @test isnothing(_result(gui).view) && isempty(_result(gui).views) @test isnothing(_result(gui).error) # close removes all listeners @@ -642,7 +642,7 @@ _points(h) = only(render_plots(h))[1][] window = (; x_min = -0.3e-3, x_max = 0.3e-3, z_min = -0.3e-3, z_max = 0.3e-3) gui = _live_view(System([m, pd]), _gauss(); detectors = [pd => (:intensity, (; num_points = 5, window...))]) P = optical_power(pd, RegularGrid(; num_points = 5, window...)) - @test _result(gui).metrics.P ≈ P + @test _result(gui).text == "P = $(GUI._fmt3(1e3 * P)) mW" @test GUI._signal_text(gui, pd) == "P = $(GUI._fmt3(1e3 * P)) mW" close(gui) end @@ -1655,7 +1655,7 @@ _points(h) = only(render_plots(h))[1][] m, pd = _fixture() gui = _live_view(System([m, pd]), _gauss()) mt = _result(gui).metrics - @test isapprox(mt.P, optical_power(pd); rtol = 1e-3) + @test isapprox(parse(Float64, split(_result(gui).text)[3]), 1e3 * optical_power(pd); rtol = 1e-3) # waist 0.5 mm at the source, 200 mm to the detector zR = π * 0.5e-3^2 / 1e-6 w = 0.5e-3 * sqrt(1 + (0.2 / zR)^2)