Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions ext/BeamletOpticsGUIGLMakieExt/session.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/BeamletOpticsGUI.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
67 changes: 67 additions & 0 deletions src/LiveProjectFade.jl
Original file line number Diff line number Diff line change
@@ -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
18 changes: 16 additions & 2 deletions src/LiveProjectSync.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions test/TestLiveProjectFade.jl
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion test/TestPrecompileSession.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading