From d599beb0ae543bbe91bedb851b56252d9752e7d9 Mon Sep 17 00:00:00 2001 From: uitt_hu Date: Sun, 15 Nov 2026 20:27:33 +0100 Subject: [PATCH] Objects that the files add fade in, removed ones fade out (#89) An object that another participant adds to the files is transparent before it is drawn and fades in over 0.3 s, as long as a glide; a removed object leaves the view and its systems at once, and a ghost of it, drawn by BeamletOptics, fades out and is removed. The fade changes the opacity of the plots like the see-through highlight and gives it back also when it is interrupted. Objects of the person's own window and windows that are not shown do not fade. A step of the project session adds and removes a mirror in the file. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 + docs/src/projects.md | 2 +- ext/BeamletOpticsGUIGLMakieExt/session.jl | 21 ++++ src/BeamletOpticsGUI.jl | 1 + src/LiveProjectFade.jl | 67 +++++++++++++ src/LiveProjectSync.jl | 18 +++- test/TestLiveProjectFade.jl | 117 ++++++++++++++++++++++ test/TestPrecompileSession.jl | 2 +- 8 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 src/LiveProjectFade.jl create mode 100644 test/TestLiveProjectFade.jl diff --git a/AGENTS.md b/AGENTS.md index b4d3ce6..da6f204 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,6 +141,8 @@ describe it as available in docs or the skill. (`_ViewLinks`) and show the same objects; the view that changes solves, the others follow (`_sync_links!` after a solve and when the beams become outdated, `_sync_structure!` after adding and removing, which `_attach!` and `_detach!` do for a view without changing the system). +- `src/LiveProjectFade.jl`: objects that the files of a project add fade in, removed ones fade out + (`_fade!`, started by `_adopt!` in `src/LiveProjectSync.jl`). - `src/LiveMarkers.jl`: clip planes and source markers. - `src/LiveDebug.jl`: the debug mode (`debug` kwarg, tool "Debug", taken over by `open_system`): its state `_Debug`, the stats (`_DebugStats`), the event log (`_debug_event!`) and the layer protocol diff --git a/docs/src/projects.md b/docs/src/projects.md index 69b0bb0..3667aa7 100644 --- a/docs/src/projects.md +++ b/docs/src/projects.md @@ -73,7 +73,7 @@ Several windows and agents can work on one project at once, also from a shared d - **What the others changed.** A change of another participant is shown where it happened: a thin frame in the participant's colour at the new place, a dashed ghost at the old one and a soft arc between them, a small chip with the name and the change ("claude · moved M2 by 12 mm"); new - objects settle into a frame, removed ones leave a ghost. What an agent says it does next + objects fade in and settle into a frame, removed ones fade out and leave a ghost. What an agent says it does next (`announce(...; doing)`) appears as a bubble at its object. The cues fade after about 3 s. When an agent changes things faster than one can follow, the setup changes at once, but the cues play one after the other at a readable pace; more than 3 s behind, the waiting ones become one cue diff --git a/ext/BeamletOpticsGUIGLMakieExt/session.jl b/ext/BeamletOpticsGUIGLMakieExt/session.jl index 20d61bd..0287442 100644 --- a/ext/BeamletOpticsGUIGLMakieExt/session.jl +++ b/ext/BeamletOpticsGUIGLMakieExt/session.jl @@ -1373,6 +1373,27 @@ function _project_session(step; dir = mktempdir()) _check(_project_pos(gui, mirror) ≈ target && _pos(mirror) ≈ target, "the mirror did not glide") settle!() end + # an object that the files add fades in, one that they remove fades out (see `_fade!`) + step("project: an agent adds a mirror, then removes it") do + text = read(file, String) + merge!() = (t = time(); GUI._check_files!(gui; now = t); + _check(GUI._check_files!(gui; now = t + 1) === :applied, "the window did not merge the file")) + write(file, text * "@component M9 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.3, 0.05)\n") + merge!() + m9 = findfirst(((name, _),) -> name === :M9, gui.sync.ids) + _check(!isnothing(m9), "the mirror of the file was not added") + opaque() = all(p -> GUI._plot_alpha(p) == 1, GUI._object_plots(gui.controls.h, m9)) + timedwait(opaque, 60.0; pollint = 0.01) + frame!() + _check(opaque(), "the added mirror did not fade in") + n = length(gui.ax.scene.plots) + write(file, text) + merge!() + timedwait(() -> length(gui.ax.scene.plots) < n, 60.0; pollint = 0.01) + frame!() + _check(!haskey(gui.sync.ids, m9), "the mirror of the file was not removed") + settle!() + end step("project: the activity of the others, its cues and its timeline") do a = GUI._activity(gui) # following the agent moves the camera: the steps after this one find the view as it was diff --git a/src/BeamletOpticsGUI.jl b/src/BeamletOpticsGUI.jl index 2e3f772..d527d0b 100644 --- a/src/BeamletOpticsGUI.jl +++ b/src/BeamletOpticsGUI.jl @@ -119,6 +119,7 @@ include("LiveProject.jl") include("LiveProjectSync.jl") include("LiveProjectWatch.jl") include("LiveProjectGlide.jl") +include("LiveProjectFade.jl") include("LiveProjectSession.jl") include("LiveProjectPresence.jl") include("LiveProjectClaims.jl") diff --git a/src/LiveProjectFade.jl b/src/LiveProjectFade.jl new file mode 100644 index 0000000..24ecfdd --- /dev/null +++ b/src/LiveProjectFade.jl @@ -0,0 +1,67 @@ +#= +Objects that the files of a project add fade in, and those that they remove fade out, see `_fade!` +=# + +# The duration of a fade [s], as long as a moved object glides, see `_GLIDE` +const _FADE = _GLIDE + +""" + _fade_start!(fades, gui, obj) + +Makes the object `obj`, which the files of the project just added to the `gui`, transparent before +it is drawn, and adds its plots with their opacity as rendered (see `_plot_base`) to `fades`, which +`_fade!` brings up to that opacity. A source is not faded: its beam comes with the next solve. +""" +function _fade_start!(fades, gui::LiveView, @nospecialize(obj::BMO.ObjectOrGroup)) + for p in _object_plots(gui.controls.h, obj) + base = _plot_base(p) + push!(fades, (p, base)) + _apply_opacity!(p, base..., 0.0) + end + return fades +end +_fade_start!(fades, ::LiveView, ::_Source) = fades + +""" + _fade_ghost!(ghosts, gui, obj) + +Renders the object `obj`, which the files removed from the `gui`, once more where it was and adds the +render handle to `ghosts`, which `_fade!` fades out and removes: BeamletOptics draws it, like the +ghost of a placement (see `_ghost!`), while the object itself is gone from the view and its systems +already. Not for a source, whose marker and beam are gone with it. +""" +function _fade_ghost!(ghosts, gui::LiveView, @nospecialize(obj::BMO.ObjectOrGroup)) + push!(ghosts, live_render!(gui.ax, obj; gui.components.render_kwargs...)) + return ghosts +end +_fade_ghost!(ghosts, ::LiveView, ::_Source) = ghosts + +# The plots of the render handle `h` and of its children, e.g. of a group +_handle_plots(h) = AbstractPlot[render_plots(h)..., (p for c in AbstractTrees.children(h) for p in _handle_plots(c))...] + +""" + _fade!(fades, ghosts) + +Brings the plots of the objects that the files added (`fades`, see `_fade_start!`) up to their +opacity as rendered and the `ghosts` of the removed ones (see `_fade_ghost!`) down to nothing, +together over `_FADE` seconds with the ease of a glide (see `_glide_all!`), then gives the plots +their opacity back and removes the ghosts, also when the fade is interrupted. Runs in a task of its +own, started by `_adopt!`. +""" +function _fade!(fades, ghosts) + outs = [(p, _plot_base(p)) for h in ghosts for p in _handle_plots(h)] + n = max(1, round(Int, _FADE * _GLIDE_FPS)) + try + for i in 1:(n - 1) + f = i / n + f = f * f * (3 - 2f) + foreach(((p, b),) -> _apply_opacity!(p, b..., f), fades) + foreach(((p, b),) -> _apply_opacity!(p, b..., 1 - f), outs) + sleep(1 / _GLIDE_FPS) + end + finally + foreach(((p, b),) -> _apply_opacity!(p, b..., 1.0), fades) + foreach(remove_render!, ghosts) + end + return nothing +end diff --git a/src/LiveProjectSync.jl b/src/LiveProjectSync.jl index 786cc61..37f0888 100644 --- a/src/LiveProjectSync.jl +++ b/src/LiveProjectSync.jl @@ -1071,7 +1071,8 @@ end Makes the entries, systems and groups of the `keys` in the open `gui` (whose state is the project `mine`, see `_collect_project`) as in the files `disk`, without undo entries: removes, adds, rebuilds (other code, or calling own code in `rebuild`), moves (gliding if `animate`, see -`_glide_all!`), renames and relabels; the systems and groups of `merged` get the merged members (see +`_glide_all!`), renames and relabels; with `animate`, added objects fade in and removed ones fade +out (see `_fade!`); the systems and groups of `merged` get the merged members (see `_sync_members!`). A group whose members change is made anew with the window groups around and inside it (see `_group_plan`), one that only moved or was renamed is changed in place. The changed objects get a box for a moment. Returns what was done, e.g. `"moved M1"`. @@ -1081,6 +1082,8 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima done = String[] touched = Any[] glides = Tuple{Any, Any, Any}[] + # the objects that fade in and the ghosts of those that fade out, see `_fade!` + fades, ghosts = Tuple{Any, Any}[], AbstractObjectRenderHandle[] m = BMOP.match_entries(disk.entries, mine.entries) # The trees of groups that change are dissolved first and made again after the entries and the # systems, so that removed and rebuilt members are top-level objects then @@ -1098,6 +1101,7 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima for e in m.added _key(e) in keys || continue _remove_entry!(gui, e.object) + animate && _fade_ghost!(ghosts, gui, e.object) push!(done, "removed $(_shown_label(e))") end ms = BMOP.match_entries(disk.systems, mine.systems) @@ -1110,7 +1114,10 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima # In the files only: added for e in m.removed _key(e) in keys || continue - push!(touched, _add_entry!(gui, e)) + obj = _add_entry!(gui, e) + # transparent before it is drawn, see `_fade_start!` + animate && _fade_start!(fades, gui, obj) + push!(touched, obj) push!(done, "added $(_shown_label(e))") end for (e, mine_e) in m.pairs @@ -1153,6 +1160,13 @@ function _adopt!(gui::LiveView, mine, disk, keys; rebuild = Set{Symbol}(), anima _key(se) in keys || continue (se.name !== mine_se.name || se.label != mine_se.label) && _name!(gui, mine_se.object, se) end + if !(isempty(fades) && isempty(ghosts)) + @async try + _fade!(fades, ghosts) + catch err + gui.last_error = _log_once(gui, err, gui.last_error, "fade of the objects of the files") + end + end # The table fits the setup of the files, see `_table_fit!` isempty(glides) && (_flash!(gui, touched); _table_fit!(gui); return done) # The autosave waits until they landed, a save lands them at once diff --git a/test/TestLiveProjectFade.jl b/test/TestLiveProjectFade.jl new file mode 100644 index 0000000..5885a37 --- /dev/null +++ b/test/TestLiveProjectFade.jl @@ -0,0 +1,117 @@ +module TestLiveProjectFade + +using GLMakie, BeamletOptics, BeamletOpticsGUI +using BeamletOpticsProjects +using Makie +using Test + +const BMO = BeamletOptics +const GUI = BeamletOpticsGUI +const const_kw = (; trace_budget = Inf, throttle = false, detectors = []) + +const START = """ + @component M1 = RoundPlanoMirror(0.0254, 0.006) pos = (0.0, 0.2, 0.0) + @component D1 = Detector(0.02) pos = (0.0, 0.5, 0.0) + @source B1 = Beam([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0e-6) + @system S1 = [M1, D1] sources = [B1] + """ + +"""Two windows (not shown) on one project with `START`, saved once by the first.""" +function two_windows() + dir = mktempdir() + new_project(dir; name = "Bench") + write(joinpath(dir, "system.jl"), START) + a = open_project(dir; display = false, const_kw...) + save_project(a) + b = open_project(dir; display = false, const_kw...) + return a, b +end + +"""The object of the readable id `id` in the window.""" +function obj(gui, id) + for (x, (name, _)) in gui.sync.ids + name === id && return x + end + return nothing +end + +function autosave!(gui) + t = time() + first = GUI._autosave!(gui; now = t) + first === :saved && return first + return GUI._autosave!(gui; now = t + 1) +end + +"""Waits (yielding to the fades) until `cond()` holds or `timeout` seconds passed.""" +function wait_for(cond; timeout = 30.0, step = 0.002) + t0 = time() + while !cond() + time() - t0 > timeout && return false + sleep(step) + end + return true +end + +alphas(gui, o) = Float32[GUI._plot_alpha(p) for p in GUI._object_plots(gui.controls.h, o)] + +@testset "Objects of the files fade in and out" begin + @testset "an added object fades in, a removed one fades out" begin + a, b = two_windows() + m = RoundPlanoMirror(0.0254, 0.006) + add_component!(a, m; code = "RoundPlanoMirror(0.0254, 0.006)", system = only(GUI._systems(a))) + translate3d!(a, m, [0.0, 0.35, 0.0]) + @test autosave!(a) === :saved + n0 = length(b.ax.scene.plots) + @test GUI._sync!(b; animate = true) === :applied + new = obj(b, :M2) + @test !isnothing(new) + # transparent at first, as rendered after the fade + @test all(<(0.5), alphas(b, new)) + @test wait_for(() -> all(==(1.0f0), alphas(b, new))) + # in its system from the start, as without a fade + @test any(o -> o === new, only(GUI._systems(b)).objects) + + # removed in the files: gone from the view and its systems at once, its ghost fades out + remove_component!(a, m) + @test autosave!(a) === :saved + n1 = length(b.ax.scene.plots) + @test GUI._sync!(b; animate = true) === :applied + @test isnothing(obj(b, :M2)) && !any(o -> o === new, only(GUI._systems(b)).objects) + @test length(b.ax.scene.plots) == n1 + @test wait_for(() -> length(b.ax.scene.plots) < n1) + @test length(b.ax.scene.plots) == n0 + close(b) + close(a) + end + + @testset "not without animation, not in the person's own window" begin + a, b = two_windows() + m = RoundPlanoMirror(0.0254, 0.006) + add_component!(a, m; code = "RoundPlanoMirror(0.0254, 0.006)", system = only(GUI._systems(a))) + @test autosave!(a) === :saved + @test GUI._sync!(b; animate = false) === :applied + @test all(==(1.0f0), alphas(b, obj(b, :M2))) + # the person's own addition + own = RoundPlanoMirror(0.0254, 0.006) + add_component!(b, own; system = only(GUI._systems(b))) + @test all(==(1.0f0), alphas(b, own)) + close(b) + close(a) + end + + @testset "a fade gives the plots back also when it is interrupted" begin + a, b = two_windows() + m1 = obj(b, :M1) + fades = GUI._fade_start!(Tuple{Any, Any}[], b, m1) + @test all(==(0.0f0), alphas(b, m1)) + t = @async GUI._fade!(fades, BeamletOptics.AbstractObjectRenderHandle[]) + sleep(0.05) + schedule(t, InterruptException(); error = true) + @test wait_for(() -> istaskdone(t)) + @test all(==(1.0f0), alphas(b, m1)) + close(b) + close(a) + end +end + +end diff --git a/test/TestPrecompileSession.jl b/test/TestPrecompileSession.jl index 046dd8a..d5707e2 100644 --- a/test/TestPrecompileSession.jl +++ b/test/TestPrecompileSession.jl @@ -69,7 +69,7 @@ using Test dir = joinpath(outer, "project") gui, screen = ext._project_session(step; dir) @test isempty(failed) - @test length(names) == 14 + @test length(names) == 15 @test LibGit2.with(LibGit2.isorphan, LibGit2.GitRepo(outer)) @test LibGit2.with(r -> realpath(LibGit2.path(r)) == realpath(dir), LibGit2.GitRepoExt(dir)) # the steps call the watcher themselves: none runs alongside them