Skip to content

Commit da220e8

Browse files
authored
Merge pull request #51 from veillette/claude/refactor-view-state-model-CI5az
Refactor view options into per-screen ViewOptionsModel
2 parents ec43f6c + e8c9f14 commit da220e8

39 files changed

Lines changed: 541 additions & 192 deletions

src/OpticsLabConstants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ export const RAY_ALPHA_BUCKETS = 20;
133133
/** Distance threshold (pixels) for image-convergence grid quantization. */
134134
export const RAY_CONVERGENCE_THRESHOLD = 5;
135135

136+
/** Default length (px) of a ray stub in "ray stubs" display mode. */
137+
export const RAY_STUB_LENGTH_DEFAULT_PX = 50;
138+
/** Minimum allowed ray-stub length (px). */
139+
export const RAY_STUB_LENGTH_MIN_PX = 10;
140+
/** Maximum allowed ray-stub length (px). */
141+
export const RAY_STUB_LENGTH_MAX_PX = 200;
142+
136143
// ── 5. Edit-panel UI ──────────────────────────────────────────────────────────
137144

138145
export const PANEL_BOTTOM_MARGIN = 10;

src/common/view/BaseOpticalElementView.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,15 @@ export abstract class BaseOpticalElementView extends Node {
114114
*/
115115
// biome-ignore lint/suspicious/noExplicitAny: linkAttribute requires any for the target object
116116
protected trackLinkAttribute<T>(property: TReadOnlyProperty<T>, object: any, attributeName: string): void {
117-
const handle = property.linkAttribute(object, attributeName) as unknown as (value: unknown) => void;
118-
this._linkedAttributes.push({ property: property as TReadOnlyProperty<unknown>, handle });
117+
// linkAttribute() returns void, so we must store the listener explicitly.
118+
const listener = (value: T) => {
119+
object[attributeName] = value;
120+
};
121+
property.link(listener as unknown as (value: unknown) => void);
122+
this._linkedAttributes.push({
123+
property: property as TReadOnlyProperty<unknown>,
124+
handle: listener as unknown as (value: unknown) => void,
125+
});
119126
}
120127

121128
/**

src/common/view/ElementRegistry.ts

Lines changed: 70 additions & 66 deletions
Large diffs are not rendered by default.

src/common/view/OpticalElementViewFactory.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import opticsLab from "../../OpticsLabNamespace.js";
1212
import type { OpticalElement } from "../model/optics/OpticsTypes.js";
1313
import type { OpticalElementView } from "./ElementRegistry.js";
1414
import { createOpticalElementView as createFromRegistry } from "./ElementRegistry.js";
15+
import type { ViewOptionsModel } from "./ViewOptionsModel.js";
1516

1617
// Re-export so callers that previously imported OpticalElementView from this
1718
// file continue to work without any import-path changes.
@@ -25,8 +26,9 @@ export function createOpticalElementView(
2526
element: OpticalElement,
2627
modelViewTransform: ModelViewTransform2,
2728
tandem: Tandem,
29+
viewOptions: ViewOptionsModel,
2830
): OpticalElementView | null {
29-
return createFromRegistry(element, modelViewTransform, tandem);
31+
return createFromRegistry(element, modelViewTransform, tandem, viewOptions);
3032
}
3133

3234
opticsLab.register("createOpticalElementView", createOpticalElementView);

src/common/view/RayPropagationView.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ import {
3434
import opticsLab from "../../OpticsLabNamespace.js";
3535
import type { ViewMode } from "../model/optics/OpticsTypes.js";
3636
import type { TracedSegment } from "../model/optics/RayTracer.js";
37-
import { rayArrowsVisibleProperty } from "./RayArrowsVisibleProperty.js";
38-
import { rayStubLengthPxProperty, rayStubsEnabledProperty } from "./RayStubsProperty.js";
37+
import type { ViewOptionsModel } from "./ViewOptionsModel.js";
3938

4039
// Cohen–Sutherland region codes
4140
const CS_INSIDE = 0;
@@ -122,17 +121,33 @@ export class RayPropagationView extends CanvasNode {
122121
private segments: TracedSegment[] = [];
123122
private mode: ViewMode = "rays";
124123
private readonly modelViewTransform: ModelViewTransform2;
125-
126-
public constructor(canvasBounds: Bounds2, modelViewTransform: ModelViewTransform2, options?: CanvasNodeOptions) {
124+
private readonly viewOptions: ViewOptionsModel;
125+
/** Shared listener stored so it can be unlinked in dispose(). */
126+
private readonly _invalidatePaintListener = () => this.invalidatePaint();
127+
128+
public constructor(
129+
canvasBounds: Bounds2,
130+
modelViewTransform: ModelViewTransform2,
131+
viewOptions: ViewOptionsModel,
132+
options?: CanvasNodeOptions,
133+
) {
127134
super({
128135
canvasBounds,
129136
pickable: false, // rays are non-interactive
130137
...options,
131138
});
132139
this.modelViewTransform = modelViewTransform;
133-
rayArrowsVisibleProperty.lazyLink(() => this.invalidatePaint());
134-
rayStubsEnabledProperty.lazyLink(() => this.invalidatePaint());
135-
rayStubLengthPxProperty.lazyLink(() => this.invalidatePaint());
140+
this.viewOptions = viewOptions;
141+
viewOptions.rayArrowsVisibleProperty.lazyLink(this._invalidatePaintListener);
142+
viewOptions.rayStubsEnabledProperty.lazyLink(this._invalidatePaintListener);
143+
viewOptions.rayStubLengthPxProperty.lazyLink(this._invalidatePaintListener);
144+
}
145+
146+
public override dispose(): void {
147+
this.viewOptions.rayArrowsVisibleProperty.unlink(this._invalidatePaintListener);
148+
this.viewOptions.rayStubsEnabledProperty.unlink(this._invalidatePaintListener);
149+
this.viewOptions.rayStubLengthPxProperty.unlink(this._invalidatePaintListener);
150+
super.dispose();
136151
}
137152

138153
/**
@@ -176,15 +191,15 @@ export class RayPropagationView extends CanvasNode {
176191
} else {
177192
this.paintExtensionRays(context, segments, clipRect);
178193
this.paintForwardRays(context, segments, clipRect);
179-
if (rayArrowsVisibleProperty.value && !rayStubsEnabledProperty.value) {
194+
if (this.viewOptions.rayArrowsVisibleProperty.value && !this.viewOptions.rayStubsEnabledProperty.value) {
180195
this.paintArrowheads(context, segments, clipRect);
181196
}
182197
}
183198
}
184199

185200
private paintExtensionRays(context: CanvasRenderingContext2D, segments: TracedSegment[], clipRect: ClipRect): void {
186201
// When ray stubs are enabled, suppress extension rays entirely.
187-
if (rayStubsEnabledProperty.value) {
202+
if (this.viewOptions.rayStubsEnabledProperty.value) {
188203
return;
189204
}
190205
const modelViewTransform = this.modelViewTransform;
@@ -241,8 +256,8 @@ export class RayPropagationView extends CanvasNode {
241256
const modelViewTransform = this.modelViewTransform;
242257
context.lineWidth = RAY_LINE_WIDTH;
243258

244-
const stubsEnabled = rayStubsEnabledProperty.value;
245-
const stubLengthPx = rayStubLengthPxProperty.value;
259+
const stubsEnabled = this.viewOptions.rayStubsEnabledProperty.value;
260+
const stubLengthPx = this.viewOptions.rayStubLengthPxProperty.value;
246261

247262
const paintSegment = (seg: TracedSegment, additive: boolean): void => {
248263
// In stub mode, only draw segments emitted directly from a light source.

src/common/view/SceneSVGExporter.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ import {
2525
} from "../../OpticsLabConstants.js";
2626
import type { OpticalElement } from "../model/optics/OpticsTypes.js";
2727
import type { TracedSegment } from "../model/optics/RayTracer.js";
28-
import { handlesVisibleProperty } from "./HandlesVisibleProperty.js";
2928
import { createOpticalElementView, type OpticalElementView } from "./OpticalElementViewFactory.js";
29+
import { ViewOptionsModel } from "./ViewOptionsModel.js";
3030

3131
/**
3232
* Scenery may introduce Canvas layers (full-size, cleared opaque) above SVG layers when the subtree mixes
@@ -291,8 +291,8 @@ export function downloadSceneSVG({
291291
segments,
292292
viewState,
293293
}: SceneSVGExportOptions): void {
294-
const savedHandlesVisible = handlesVisibleProperty.value;
295-
handlesVisibleProperty.value = viewState.showHandles;
294+
const exportViewOptions = new ViewOptionsModel();
295+
exportViewOptions.handlesVisibleProperty.value = viewState.showHandles;
296296

297297
const exportWidth = Math.ceil(visibleBounds.width);
298298
const exportHeight = Math.ceil(visibleBounds.height);
@@ -340,7 +340,7 @@ export function downloadSceneSVG({
340340

341341
const elementsLayer = new Node();
342342
for (const element of elements) {
343-
const view = createOpticalElementView(element, exportMVT, Tandem.OPT_OUT);
343+
const view = createOpticalElementView(element, exportMVT, Tandem.OPT_OUT, exportViewOptions);
344344
if (!view) {
345345
continue;
346346
}
@@ -419,7 +419,6 @@ export function downloadSceneSVG({
419419
display.dispose();
420420
}
421421
} finally {
422-
handlesVisibleProperty.value = savedHandlesVisible;
423422
for (const overlayNode of exportOverlayNodes) {
424423
overlayNode.dispose();
425424
}

src/common/view/SimScreenView.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,15 @@ import { CarouselPanel } from "./CarouselPanel.js";
2121
import type { ComponentKey } from "./ComponentCarousel.js";
2222
import { DetectorView } from "./detectors/DetectorView.js";
2323
import { EditContainerNode } from "./EditContainerNode.js";
24-
import { focalMarkersVisibleProperty } from "./FocalMarkersVisibleProperty.js";
25-
import { handlesVisibleProperty } from "./HandlesVisibleProperty.js";
2624
import { ImageOverlayNode } from "./ImageOverlayNode.js";
2725
import { InfoDialogNode } from "./InfoDialogNode.js";
2826
import { ObserverNode } from "./ObserverNode.js";
2927
import { createOpticalElementView, type OpticalElementView } from "./OpticalElementViewFactory.js";
30-
import { rayArrowsVisibleProperty } from "./RayArrowsVisibleProperty.js";
3128
import { RayPropagationView } from "./RayPropagationView.js";
32-
import { rayStubsEnabledProperty } from "./RayStubsProperty.js";
3329
import { sceneHistoryRegistry } from "./SceneHistoryRegistry.js";
3430
import { downloadSceneSVG } from "./SceneSVGExporter.js";
3531
import { ToolsPanel } from "./ToolsPanel.js";
32+
import { ViewOptionsModel } from "./ViewOptionsModel.js";
3633
import { viewSnapState } from "./ViewSnapState.js";
3734

3835
/**
@@ -83,6 +80,10 @@ function tryHandleToolsPanelShortcut(
8380
extendedRaysProperty: BooleanProperty;
8481
gridVisibleProperty: BooleanProperty;
8582
snapToGridProperty: BooleanProperty;
83+
handlesVisibleProperty: BooleanProperty;
84+
focalMarkersVisibleProperty: BooleanProperty;
85+
rayArrowsVisibleProperty: BooleanProperty;
86+
rayStubsEnabledProperty: BooleanProperty;
8687
},
8788
): boolean {
8889
if (isTextInput || event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) {
@@ -95,6 +96,10 @@ function tryHandleToolsPanelShortcut(
9596
extendedRaysProperty,
9697
gridVisibleProperty,
9798
snapToGridProperty,
99+
handlesVisibleProperty,
100+
focalMarkersVisibleProperty,
101+
rayArrowsVisibleProperty,
102+
rayStubsEnabledProperty,
98103
} = locals;
99104
if (letter === "m") {
100105
measuringTapeVisibleProperty.toggle();
@@ -178,6 +183,9 @@ export class RayTracingCommonView extends ScreenView {
178183
/** Maps element id → view so we can remove views and provide rebuild callbacks. */
179184
private readonly elementViewMap = new Map<string, OpticalElementView>();
180185

186+
/** Per-screen view state (handles visibility, ray arrows, focal markers, etc.). */
187+
protected readonly viewOptions: ViewOptionsModel;
188+
181189
/**
182190
* Maps element id → its Tandem so we can remove it from the global tandem tree on deletion.
183191
* RichDragListener is not a PhetioObject, so its tandem's dispose() is never triggered
@@ -230,6 +238,8 @@ export class RayTracingCommonView extends ScreenView {
230238
const tandem = options?.tandem;
231239
const uiStrings = StringManager.getInstance().getUIStrings();
232240

241+
this.viewOptions = new ViewOptionsModel(tandem?.createTandem("viewOptions"));
242+
233243
// ── Model-View Transform ────────────────────────────────────────────────
234244
// Maps model origin (0, 0) to the centre of the visible play area.
235245
// 100 px = 1 m; y-axis is inverted (model +y = up, view +y = down).
@@ -316,7 +326,11 @@ export class RayTracingCommonView extends ScreenView {
316326
viewSnapState.setSnapToGrid(snapToGridProperty);
317327

318328
// ── Ray Propagation Layer (behind elements so rays don't block handles) ─
319-
this.rayPropagationView = new RayPropagationView(this.visibleBoundsProperty.value, modelViewTransform);
329+
this.rayPropagationView = new RayPropagationView(
330+
this.visibleBoundsProperty.value,
331+
modelViewTransform,
332+
this.viewOptions,
333+
);
320334
this.visibleBoundsProperty.link((visibleBounds) => {
321335
this.rayPropagationView.canvasBounds = visibleBounds;
322336
});
@@ -398,7 +412,7 @@ export class RayTracingCommonView extends ScreenView {
398412
// view already exists and skips duplicate creation.
399413
const tandemName = element.id.replace(/-(\d+)$/, (_, n) => n);
400414
const elementTandem = tandem?.createTandem(tandemName) ?? Tandem.OPTIONAL;
401-
const view = createOpticalElementView(element, modelViewTransform, elementTandem);
415+
const view = createOpticalElementView(element, modelViewTransform, elementTandem, this.viewOptions);
402416
if (view) {
403417
this.elementTandemMap.set(element.id, elementTandem);
404418
this._setupView(element, view);
@@ -435,7 +449,7 @@ export class RayTracingCommonView extends ScreenView {
435449
for (const element of model.scene.getAllElements()) {
436450
const tandemName = element.id.replace(/-(\d+)$/, (_, n) => n);
437451
const elementTandem = tandem?.createTandem(tandemName) ?? Tandem.OPTIONAL;
438-
const elementView = createOpticalElementView(element, modelViewTransform, elementTandem);
452+
const elementView = createOpticalElementView(element, modelViewTransform, elementTandem, this.viewOptions);
439453
if (elementView) {
440454
this.elementTandemMap.set(element.id, elementTandem);
441455
this._setupView(element, elementView);
@@ -452,7 +466,7 @@ export class RayTracingCommonView extends ScreenView {
452466
}
453467
const tn = element.id.replace(/-(\d+)$/, (_, n: string) => n);
454468
const et = tandem?.createTandem(tn) ?? Tandem.OPTIONAL;
455-
const view = createOpticalElementView(element, modelViewTransform, et);
469+
const view = createOpticalElementView(element, modelViewTransform, et, this.viewOptions);
456470
if (view) {
457471
this.elementTandemMap.set(element.id, et);
458472
this._setupView(element, view);
@@ -492,6 +506,7 @@ export class RayTracingCommonView extends ScreenView {
492506
this.selectedElementProperty,
493507
this.visibleBoundsProperty,
494508
_opticsLabPreferences.snapToGridProperty,
509+
this.viewOptions,
495510
tandem,
496511
);
497512
this.addChild(toolsPanel);
@@ -526,7 +541,7 @@ export class RayTracingCommonView extends ScreenView {
526541
viewState: {
527542
showGrid: model.scene.showGridProperty.value,
528543
gridSpacing: model.scene.gridSizeProperty.value,
529-
showHandles: handlesVisibleProperty.value,
544+
showHandles: this.viewOptions.handlesVisibleProperty.value,
530545
measuringTape: {
531546
visible: toolsPanel.measuringTapeVisibleProperty.value,
532547
basePosition: toolsPanel.measuringTapeNode.basePositionProperty.value.copy(),
@@ -618,6 +633,10 @@ export class RayTracingCommonView extends ScreenView {
618633
extendedRaysProperty: toolsPanel.extendedRaysProperty,
619634
gridVisibleProperty: model.scene.showGridProperty,
620635
snapToGridProperty: _opticsLabPreferences.snapToGridProperty,
636+
handlesVisibleProperty: this.viewOptions.handlesVisibleProperty,
637+
focalMarkersVisibleProperty: this.viewOptions.focalMarkersVisibleProperty,
638+
rayArrowsVisibleProperty: this.viewOptions.rayArrowsVisibleProperty,
639+
rayStubsEnabledProperty: this.viewOptions.rayStubsEnabledProperty,
621640
})
622641
) {
623642
event.preventDefault();
@@ -667,6 +686,7 @@ export class RayTracingCommonView extends ScreenView {
667686
public override dispose(): void {
668687
window.removeEventListener("keydown", this._handleKeyDown);
669688
super.dispose();
689+
this.viewOptions.dispose();
670690
}
671691

672692
public override step(_dt: number): void {

src/common/view/ToolsPanel.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,7 @@ import {
5858
import opticsLabQueryParameters from "../../preferences/opticsLabQueryParameters.js";
5959
import type { OpticalElement } from "../model/optics/OpticsTypes.js";
6060
import type { RayTracingCommonModel } from "../model/SimModel.js";
61-
import { focalMarkersVisibleProperty } from "./FocalMarkersVisibleProperty.js";
62-
import { handlesVisibleProperty } from "./HandlesVisibleProperty.js";
6361
import { DEFAULT_OBSERVER } from "./ObserverNode.js";
64-
import { rayArrowsVisibleProperty } from "./RayArrowsVisibleProperty.js";
65-
import { rayStubsEnabledProperty } from "./RayStubsProperty.js";
6662
import {
6763
dragHandleIcon,
6864
extendedRaysIcon,
@@ -77,6 +73,7 @@ import {
7773
showImagesIcon,
7874
snapToGridIcon,
7975
} from "./ToolsPanelIcons.js";
76+
import type { ViewOptionsModel } from "./ViewOptionsModel.js";
8077

8178
export class ToolsPanel extends Node {
8279
/** Draggable measuring-tape overlay – add to scene in the desired z-order. */
@@ -91,6 +88,8 @@ export class ToolsPanel extends Node {
9188
public readonly measuringTapeVisibleProperty: BooleanProperty;
9289
public readonly protractorVisibleProperty: BooleanProperty;
9390

91+
private readonly viewOptions: ViewOptionsModel;
92+
9493
/**
9594
* Whether "Extended Rays" mode is active. Changing this property updates
9695
* model.scene.modeProperty; conversely, external modeProperty changes update
@@ -104,9 +103,11 @@ export class ToolsPanel extends Node {
104103
selectedElementProperty: Property<OpticalElement | null>,
105104
visibleBoundsProperty: ReadOnlyProperty<Bounds2>,
106105
snapToGridProperty: Property<boolean>,
106+
viewOptions: ViewOptionsModel,
107107
tandem: Tandem | undefined,
108108
) {
109109
super();
110+
this.viewOptions = viewOptions;
110111

111112
const strings = StringManager.getInstance();
112113
const uiStrings = strings.getUIStrings();
@@ -274,22 +275,22 @@ export class ToolsPanel extends Node {
274275
{ ...checkboxOptions, ...cbTandem("observerModeCheckbox") },
275276
),
276277
new Checkbox(
277-
handlesVisibleProperty,
278+
viewOptions.handlesVisibleProperty,
278279
makeCheckboxContent(dragHandleIcon(), uiStrings.showHandlesStringProperty, labelOptions),
279280
{ ...checkboxOptions, ...cbTandem("showHandlesCheckbox") },
280281
),
281282
new Checkbox(
282-
focalMarkersVisibleProperty,
283+
viewOptions.focalMarkersVisibleProperty,
283284
makeCheckboxContent(focalPointIcon(), uiStrings.focalMarkersStringProperty, labelOptions),
284285
{ ...checkboxOptions, ...cbTandem("focalMarkersCheckbox") },
285286
),
286287
new Checkbox(
287-
rayArrowsVisibleProperty,
288+
viewOptions.rayArrowsVisibleProperty,
288289
makeCheckboxContent(rayArrowsIcon(), uiStrings.showRayArrowsStringProperty, labelOptions),
289290
{ ...checkboxOptions, ...cbTandem("rayArrowsCheckbox") },
290291
),
291292
new Checkbox(
292-
rayStubsEnabledProperty,
293+
viewOptions.rayStubsEnabledProperty,
293294
makeCheckboxContent(rayStubsIcon(), uiStrings.rayStubsStringProperty, labelOptions),
294295
{ ...checkboxOptions, ...cbTandem("rayStubsCheckbox") },
295296
),
@@ -343,10 +344,7 @@ export class ToolsPanel extends Node {
343344
this.extendedRaysProperty.reset();
344345
this.measuringTapeVisibleProperty.reset();
345346
this.protractorVisibleProperty.reset();
346-
handlesVisibleProperty.reset();
347-
focalMarkersVisibleProperty.reset();
348-
rayArrowsVisibleProperty.reset();
349-
rayStubsEnabledProperty.reset();
347+
this.viewOptions.reset();
350348
this.measuringTapeNode.basePositionProperty.reset();
351349
this.measuringTapeNode.tipPositionProperty.reset();
352350
this.protractorNode.angleProperty.reset();

0 commit comments

Comments
 (0)