@@ -212,7 +212,12 @@ export class RayTracingCommonView extends ScreenView {
212212 ) ;
213213 const modelViewTransform = this . modelViewTransform ;
214214
215- this . selectedElementProperty = new Property < OpticalElement | null > ( null ) ;
215+ this . selectedElementProperty = new Property < OpticalElement | null > ( null , {
216+ // The fuzz tester can fire overlapping input events (e.g. carousel drag-create
217+ // sets null while a simultaneous down event selects an element) during a single
218+ // dispatch cycle, causing reentrant sets. Allow reentry with queued notifications.
219+ reentrant : true ,
220+ } ) ;
216221
217222 // ── Grid (model + preferences stay in sync for PhET-iO and global prefs) ─
218223 model . scene . showGridProperty . value = opticsLabQueryParameters . showGrid ;
@@ -366,16 +371,20 @@ export class RayTracingCommonView extends ScreenView {
366371 // Deselect any currently selected element so the edit panel hides.
367372 this . selectedElementProperty . value = null ;
368373
369- // Add to model
370- model . scene . addElement ( element ) ;
371-
372- // Create and add corresponding view
374+ // Create and register the view BEFORE adding to the model so that
375+ // the elementCreatedEmitter listener (used for undo/redo) sees the
376+ // view already exists and skips duplicate creation.
373377 const tandemName = element . id . replace ( / - ( \d + ) $ / , ( _ , n ) => n ) ;
374378 const elementTandem = tandem ?. createTandem ( tandemName ) ?? Tandem . OPTIONAL ;
375379 const view = createOpticalElementView ( element , modelViewTransform , elementTandem ) ;
376380 if ( view ) {
377381 this . _setupView ( element , view ) ;
378382 }
383+
384+ // Add to model (fires elementCreatedEmitter — listener will no-op
385+ // because the view is already in elementViewMap).
386+ model . scene . addElement ( element ) ;
387+
379388 return view ;
380389 } ,
381390 carouselComponents ,
@@ -449,6 +458,42 @@ export class RayTracingCommonView extends ScreenView {
449458 }
450459 }
451460
461+ // ── Model→View synchronization (undo/redo) ─────────────────────────────
462+ // When the model adds an element (e.g. redo of a remove, or undo of a
463+ // delete), create the corresponding view if one doesn't already exist.
464+ model . scene . opticalElementsGroup . elementCreatedEmitter . addListener ( ( wrapper ) => {
465+ const element = wrapper . opticalElement ;
466+ if ( this . elementViewMap . has ( element . id ) ) {
467+ return ; // view already exists (normal add via carousel)
468+ }
469+ const tn = element . id . replace ( / - ( \d + ) $ / , ( _ , n : string ) => n ) ;
470+ const et = tandem ?. createTandem ( tn ) ?? Tandem . OPTIONAL ;
471+ const view = createOpticalElementView ( element , modelViewTransform , et ) ;
472+ if ( view ) {
473+ this . _setupView ( element , view ) ;
474+ }
475+ } ) ;
476+
477+ // When the model removes an element (e.g. undo of an add), dispose the
478+ // corresponding view so it can be garbage collected.
479+ model . scene . opticalElementsGroup . elementDisposedEmitter . addListener ( ( wrapper ) => {
480+ const element = wrapper . opticalElement ;
481+ const view = this . elementViewMap . get ( element . id ) ;
482+ if ( ! view ) {
483+ return ; // already cleaned up (normal delete via _deleteElement)
484+ }
485+ if ( this . selectedElementProperty . value === element ) {
486+ this . selectedElementProperty . value = null ;
487+ }
488+ if ( this . elementsLayer . children . includes ( view ) ) {
489+ this . elementsLayer . removeChild ( view ) ;
490+ } else if ( this . dragLayer . children . includes ( view ) ) {
491+ this . dragLayer . removeChild ( view ) ;
492+ }
493+ this . elementViewMap . delete ( element . id ) ;
494+ view . dispose ( ) ;
495+ } ) ;
496+
452497 // ── Tools ─────────────────────────────────────────────────────────────────
453498 const measuringTapeVisibleProperty = new BooleanProperty ( opticsLabQueryParameters . showMeasuringTape ) ;
454499 const protractorVisibleProperty = new BooleanProperty ( opticsLabQueryParameters . showProtractor ) ;
@@ -866,9 +911,17 @@ export class RayTracingCommonView extends ScreenView {
866911 // For views that can change geometry via drag handles, sync the edit panel
867912 // and clear any completed detector acquisitions (scene geometry changed).
868913 if ( view instanceof BaseOpticalElementView ) {
869- view . rebuildEmitter . addListener ( ( ) => {
914+ const rebuildListener = ( ) : void => {
870915 this . editContainerNode . refresh ( ) ;
871916 this . _clearAllDetectorAcquisitions ( ) ;
917+ // Element positions are plain objects (not axon Properties), so dragging
918+ // does not automatically mark the scene dirty. Invalidate here so the
919+ // ray tracer re-runs on the next step() rather than showing a stale result.
920+ this . model . scene . invalidate ( ) ;
921+ } ;
922+ view . rebuildEmitter . addListener ( rebuildListener ) ;
923+ view . disposeEmitter . addListener ( ( ) => {
924+ view . rebuildEmitter . removeListener ( rebuildListener ) ;
872925 } ) ;
873926 }
874927
@@ -890,7 +943,7 @@ export class RayTracingCommonView extends ScreenView {
890943 // reparenting and for the return-to-carousel detection.
891944 let inDragLayer = false ;
892945
893- view . bodyDragListener . isPressedProperty . lazyLink ( ( isPressed ) => {
946+ const pressedListener = ( isPressed : boolean ) : void => {
894947 if ( isPressed ) {
895948 // Reparent to the drag layer so the element renders above the carousel.
896949 this . elementsLayer . removeChild ( view ) ;
@@ -917,6 +970,13 @@ export class RayTracingCommonView extends ScreenView {
917970 this . elementsLayer . addChild ( view ) ;
918971 inDragLayer = false ;
919972 }
973+ } ;
974+ view . bodyDragListener . isPressedProperty . lazyLink ( pressedListener ) ;
975+
976+ // Unlink when the view is disposed so the closure (which captures view,
977+ // element, and this SimScreenView) doesn't prevent garbage collection.
978+ view . disposeEmitter . addListener ( ( ) => {
979+ view . bodyDragListener . isPressedProperty . unlink ( pressedListener ) ;
920980 } ) ;
921981
922982 // Give each element an accessible name so screen readers can identify it.
@@ -956,13 +1016,18 @@ export class RayTracingCommonView extends ScreenView {
9561016 . some ( ( el ) => el instanceof DetectorElement && el . isAcquiring ) ;
9571017 if ( anyAcquiring ) {
9581018 for ( let i = 0 ; i < ACQUISITION_PASSES_PER_FRAME ; i ++ ) {
959- this . model . scene . invalidate ( ) ;
1019+ // No invalidate() needed: simulate() bypasses its cache when anyAcquiring,
1020+ // and jitter is applied fresh each call via the jitter: anyAcquiring config flag.
9601021 this . model . scene . simulate ( ) ;
9611022 }
9621023 }
9631024
9641025 // Final pass: simulate and update the view with the result.
965- this . model . scene . invalidate ( ) ;
1026+ // No unconditional invalidate() here — the scene marks itself dirty via
1027+ // rebuildEmitter (drag) or its Multilink (property changes). Removing the
1028+ // forced invalidation allows the cached TraceResult to be reused for static
1029+ // scenes, eliminating per-frame allocation of TracedSegment arrays and the
1030+ // deduplication Sets in findImagesInSequence.
9661031 const result = this . model . scene . simulate ( ) ;
9671032 const currentMode = this . model . scene . modeProperty . value ;
9681033 this . rayPropagationView . setSegments ( result . segments , currentMode ) ;
0 commit comments