diff --git a/src/OpticsLabStrings.ts b/src/OpticsLabStrings.ts index 0794a17..e308527 100644 --- a/src/OpticsLabStrings.ts +++ b/src/OpticsLabStrings.ts @@ -36,9 +36,12 @@ export const ELEMENT_TYPE_SINGLE_RAY = "SingleRay"; export const ELEMENT_TYPE_ARC_SOURCE = "ArcSource"; /** * Serialization type key for {@link ContinuousSpectrumSource}. - * Note: intentionally camelCase (historical; changing would break saved scenes). + * Canonical value is PascalCase. Scenes saved with the old camelCase value + * ("continuousSpectrumSource") are migrated transparently by a shim in + * elementSerialization.ts — the shim maps the old key to the new constructor + * so existing saved files continue to load without modification. */ -export const ELEMENT_TYPE_CONTINUOUS_SPECTRUM_SOURCE = "continuousSpectrumSource"; +export const ELEMENT_TYPE_CONTINUOUS_SPECTRUM_SOURCE = "ContinuousSpectrumSource"; /** Serialization type key for {@link SegmentMirror} (flat mirror). */ export const ELEMENT_TYPE_SEGMENT_MIRROR = "Mirror"; diff --git a/src/common/model/detectors/DetectorElement.ts b/src/common/model/detectors/DetectorElement.ts index f43e656..0df5373 100644 --- a/src/common/model/detectors/DetectorElement.ts +++ b/src/common/model/detectors/DetectorElement.ts @@ -29,6 +29,7 @@ import { import { MIN_RAY_LENGTH_SQ } from "../optics/OpticsConstants.js"; import type { ElementCategory, + IAcquirable, IntersectionResult, RayInteractionResult, SimulationRay, @@ -40,7 +41,7 @@ export const DETECTOR_MAX_HITS = 2000; export type DetectorHit = { t: number; brightness: number }; -export class DetectorElement extends BaseSegmentElement { +export class DetectorElement extends BaseSegmentElement implements IAcquirable { public readonly type = ELEMENT_TYPE_DETECTOR; public readonly category: ElementCategory = ELEMENT_CATEGORY_BLOCKER; diff --git a/src/common/model/fiber/FiberOpticElement.ts b/src/common/model/fiber/FiberOpticElement.ts index 3fd63ec..0de279c 100644 --- a/src/common/model/fiber/FiberOpticElement.ts +++ b/src/common/model/fiber/FiberOpticElement.ts @@ -35,6 +35,7 @@ import { ELEMENT_TYPE_FIBER_CORE_GLASS, ELEMENT_TYPE_FIBER_OPTIC } from "../../. import { Glass, type GlassPathPoint } from "../glass/Glass.js"; import type { Point } from "../optics/Geometry.js"; import type { + ICompound, IntersectionResult, OpticalElement, RayCallConfig, @@ -255,7 +256,7 @@ function buildRibbonPath(samples: Array<{ point: Point; tangent: Point }>, r: nu // ── Model class ─────────────────────────────────────────────────────────────── -export class FiberOpticElement extends Glass { +export class FiberOpticElement extends Glass implements ICompound { public override readonly type = ELEMENT_TYPE_FIBER_OPTIC; /** Start endpoint (anchor, does not affect tangent direction). */ diff --git a/src/common/model/optics/OpticsScene.ts b/src/common/model/optics/OpticsScene.ts index 2c0c5b5..572470f 100644 --- a/src/common/model/optics/OpticsScene.ts +++ b/src/common/model/optics/OpticsScene.ts @@ -34,13 +34,11 @@ import { RAY_DENSITY_MIN, } from "../../../OpticsLabConstants.js"; import { VIEW_MODE_OBSERVER, VIEW_MODE_RAYS } from "../../../OpticsLabStrings.js"; -import { DetectorElement } from "../detectors/DetectorElement.js"; -import { FiberOpticElement } from "../fiber/FiberOpticElement.js"; import { ARCHETYPE_ELEMENT_STATE, deserializeElement, LIVE_ELEMENT_STATE_KEY } from "./elementSerialization.js"; import type { Point } from "./Geometry.js"; import { point } from "./Geometry.js"; import OpticalElementPhetioObject from "./OpticalElementPhetioObject.js"; -import type { Observer, OpticalElement, ViewMode } from "./OpticsTypes.js"; +import { isAcquirable, isCompound, type Observer, type OpticalElement, type ViewMode } from "./OpticsTypes.js"; import { RayTracer, type RayTracerConfig, type TraceResult } from "./RayTracer.js"; // ── Scene Settings ─────────────────────────────────────────────────────────── @@ -107,6 +105,9 @@ export class OpticsScene extends PhetioObject { /** Undo/redo history for add/remove element commands. */ public readonly history: CommandHistory = new CommandHistory(); + /** O(1) element lookup by id. Kept in sync with opticalElementsGroup. */ + private readonly _elementById = new Map(); + private cachedResult: TraceResult | null = null; private dirty = true; @@ -253,6 +254,7 @@ export class OpticsScene extends PhetioObject { id: element.id, [LIVE_ELEMENT_STATE_KEY]: element, }); + this._elementById.set(element.id, element); }; if (recordHistory) { @@ -283,6 +285,7 @@ export class OpticsScene extends PhetioObject { return false; } this.opticalElementsGroup.disposeElement(wrapper); + this._elementById.delete(elementId); return true; }; @@ -302,7 +305,7 @@ export class OpticsScene extends PhetioObject { } public getElement(elementId: string): OpticalElement | undefined { - return this.getElementsArray().find((e) => e.id === elementId); + return this._elementById.get(elementId); } public getAllElements(): ReadonlyArray { @@ -311,6 +314,7 @@ export class OpticsScene extends PhetioObject { public clearElements(): void { this.opticalElementsGroup.clear(); + this._elementById.clear(); } /** @@ -515,14 +519,14 @@ export class OpticsScene extends PhetioObject { public simulate(): TraceResult { const elements = this.getElementsArray(); - const anyAcquiring = elements.some((el) => el instanceof DetectorElement && el.isAcquiring); + const anyAcquiring = elements.some((el) => isAcquirable(el) && el.isAcquiring); if (!(anyAcquiring || this.dirty) && this.cachedResult) { return this.cachedResult; } for (const el of elements) { - if (el instanceof DetectorElement) { + if (isAcquirable(el)) { el.clearHits(); } } @@ -538,9 +542,7 @@ export class OpticsScene extends PhetioObject { }; // Expand elements that expose multiple physics objects (e.g. fiber optic core + cladding). - const physicsElements = elements.flatMap((el) => - el instanceof FiberOpticElement ? el.getPhysicsElements() : [el], - ); + const physicsElements = elements.flatMap((el) => (isCompound(el) ? el.getPhysicsElements() : [el])); const tracer = new RayTracer(physicsElements, config); this.cachedResult = tracer.trace(); this.dirty = false; diff --git a/src/common/model/optics/OpticsTypes.ts b/src/common/model/optics/OpticsTypes.ts index ad729c8..6110db4 100644 --- a/src/common/model/optics/OpticsTypes.ts +++ b/src/common/model/optics/OpticsTypes.ts @@ -117,6 +117,32 @@ export interface ISerializable { serialize(): Record; } +/** + * An element that accumulates measurements across simulation frames. + * + * Implemented by DetectorElement. OpticsScene uses this interface instead of + * `instanceof DetectorElement` so that future detector-like elements do not + * require changes inside the core scene logic. + */ +export interface IAcquirable { + /** True while the element is actively accumulating a histogram pass. */ + readonly isAcquiring: boolean; + /** Discard all hit data accumulated so far (called at the start of each trace). */ + clearHits(): void; +} + +/** + * An element that expands into multiple physics objects for ray tracing. + * + * Implemented by FiberOpticElement (outer cladding + inner core). The tracer + * receives the expanded list so it handles both boundaries automatically. + * OpticsScene uses this interface instead of `instanceof FiberOpticElement`. + */ +export interface ICompound { + /** Return the concrete physics elements that this composite element comprises. */ + getPhysicsElements(): OpticalElement[]; +} + // ── Type guard helpers ─────────────────────────────────────────────────────── /** Returns true when the element actively emits rays (non-empty emitRays). */ @@ -124,6 +150,16 @@ export function isEmitter(element: OpticalElement): element is OpticalElement & return element.category === ELEMENT_CATEGORY_LIGHT_SOURCE; } +/** Returns true when the element implements IAcquirable (accumulates measurements). */ +export function isAcquirable(element: OpticalElement): element is OpticalElement & IAcquirable { + return "isAcquiring" in element && "clearHits" in element; +} + +/** Returns true when the element implements ICompound (expands to multiple physics objects). */ +export function isCompound(element: OpticalElement): element is OpticalElement & ICompound { + return "getPhysicsElements" in element; +} + // ── Base Optical Element ───────────────────────────────────────────────────── export interface OpticalElement extends IEmitter, IIntersectable, ISerializable { diff --git a/src/common/model/optics/SpatialIndex.ts b/src/common/model/optics/SpatialIndex.ts index 9855a8a..3920daf 100644 --- a/src/common/model/optics/SpatialIndex.ts +++ b/src/common/model/optics/SpatialIndex.ts @@ -111,15 +111,14 @@ export class SpatialIndex { /** Collect all finite-bounded elements (for the fast path with few elements). */ private getAllElements(): OpticalElement[] { - const all: OpticalElement[] = [...this.unbounded]; + // Use a Set for O(1) deduplication instead of the previous O(n²) all.includes() scan. + const seen = new Set(this.unbounded); for (const list of this.grid.values()) { for (const el of list) { - if (!all.includes(el)) { - all.push(el); - } + seen.add(el); } } - return all; + return [...seen]; } /** Collect elements in a single grid cell into the result set. */ diff --git a/src/common/model/optics/elementSerialization.ts b/src/common/model/optics/elementSerialization.ts index 7e025e7..b82b88c 100644 --- a/src/common/model/optics/elementSerialization.ts +++ b/src/common/model/optics/elementSerialization.ts @@ -173,6 +173,9 @@ export function deserializeElement(obj: Record): OpticalElement assignElementId(el, obj["id"]); return el; } + // Legacy camelCase key written by versions prior to the PascalCase rename. + // Both spellings construct the same element; no data migration needed. + case "continuousSpectrumSource": case ELEMENT_TYPE_CONTINUOUS_SPECTRUM_SOURCE: { const el = new ContinuousSpectrumSource( asPoint(obj["p1"], "p1"), diff --git a/src/common/view/EditContainerNode.ts b/src/common/view/EditContainerNode.ts index 7fe585e..70fb9e3 100644 --- a/src/common/view/EditContainerNode.ts +++ b/src/common/view/EditContainerNode.ts @@ -19,7 +19,6 @@ import { HBox, Node, Text } from "scenerystack/scenery"; import { CloseButton, TrashButton } from "scenerystack/scenery-phet"; import { FlatAppearanceStrategy, Panel } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; -import { StringManager } from "../../i18n/StringManager.js"; import OpticsLabColors from "../../OpticsLabColors.js"; import { FONT_BOLD_12PX, @@ -33,42 +32,12 @@ import opticsLab from "../../OpticsLabNamespace.js"; import type { SignConvention } from "../../preferences/OpticsLabPreferencesModel.js"; import type { OpticalElement } from "../model/optics/OpticsTypes.js"; import { buildEditControls } from "./EditControlFactory.js"; +import { getElementLabel } from "./ElementRegistry.js"; // ── Constants ───────────────────────────────────────────────────────────────── const TITLE_FONT = FONT_BOLD_12PX; -// Human-readable labels for each element type string. -// Keys must match the `type` field on each model class. -function buildTypeLabels(): Partial>> { - const c = StringManager.getInstance().getComponentStrings(); - return { - ArcSource: c.arcSourceStringProperty, - PointSource: c.pointSourceStringProperty, - Beam: c.beamSourceStringProperty, - DivergentBeam: c.divergentBeamSourceStringProperty, - SingleRay: c.singleRayStringProperty, - continuousSpectrumSource: c.continuousSpectrumStringProperty, - IdealLens: c.idealLensStringProperty, - IdealMirror: c.idealMirrorStringProperty, - SphericalLens: c.sphericalLensStringProperty, - CircleGlass: c.circleGlassStringProperty, - Glass: c.glassPrismStringProperty, - PlaneGlass: c.halfPlaneGlassStringProperty, - Mirror: c.flatMirrorStringProperty, - ArcMirror: c.arcMirrorStringProperty, - ParabolicMirror: c.parabolicMirrorStringProperty, - BeamSplitter: c.beamSplitterStringProperty, - Blocker: c.lineBlockerStringProperty, - Detector: c.detectorStringProperty, - Aperture: c.apertureStringProperty, - TransmissionGrating: c.transmissionGratingStringProperty, - ReflectionGrating: c.reflectionGratingStringProperty, - Track: c.trackStringProperty, - }; -} -const TYPE_LABELS = buildTypeLabels(); - // ── EditContainerNode ──────────────────────────────────────────────────────── export class EditContainerNode extends Node { @@ -196,7 +165,10 @@ export class EditContainerNode extends Node { }; // ── Title ────────────────────────────────────────────────────────────── - const typeLabel: TReadOnlyProperty | string = TYPE_LABELS[element.type] ?? element.type; + // Label comes from the single registry source of truth; falls back to the + // raw type string when the element is not registered (should never happen + // in production, but keeps the panel usable during development). + const typeLabel: TReadOnlyProperty | string = getElementLabel(element.type) ?? element.type; const titleText = new Text(typeLabel, { font: TITLE_FONT, fill: OpticsLabColors.overlayValueFillProperty }); const dismissBtn = new CloseButton({ @@ -215,12 +187,10 @@ export class EditContainerNode extends Node { }); // ── Type-specific controls ───────────────────────────────────────────── - const { controls, refreshCallback } = buildEditControls( - element, - triggerRebuild, - this._signConventionProperty.value, - this._useCurvatureDisplayProperty.value, - ); + const { controls, refreshCallback } = buildEditControls(element, triggerRebuild, { + signConvention: this._signConventionProperty.value, + useCurvatureDisplay: this._useCurvatureDisplayProperty.value, + }); this._refreshCallback = refreshCallback; // ── Assemble panel — dismiss left, title, controls, trash right ───────── diff --git a/src/common/view/EditControlFactory.ts b/src/common/view/EditControlFactory.ts index a82977f..e114dfd 100644 --- a/src/common/view/EditControlFactory.ts +++ b/src/common/view/EditControlFactory.ts @@ -13,14 +13,13 @@ */ import opticsLab from "../../OpticsLabNamespace.js"; -import type { SignConvention } from "../../preferences/OpticsLabPreferencesModel.js"; import type { OpticalElement } from "../model/optics/OpticsTypes.js"; import { buildEditControls as buildEditControlsFromRegistry } from "./ElementRegistry.js"; -import type { EditControlsResult } from "./edit-controls/EditControlsResult.js"; +import type { EditControlContext, EditControlsResult } from "./edit-controls/EditControlsResult.js"; -// Re-export so callers that previously imported EditControlsResult from this -// file continue to work without any import-path changes. -export type { EditControlsResult } from "./edit-controls/EditControlsResult.js"; +// Re-export so callers that previously imported these types from this file +// continue to work without any import-path changes. +export type { EditControlContext, EditControlsResult } from "./edit-controls/EditControlsResult.js"; /** * Build the property controls appropriate for the given optical element. @@ -30,10 +29,9 @@ export type { EditControlsResult } from "./edit-controls/EditControlsResult.js"; export function buildEditControls( element: OpticalElement, triggerRebuild: () => void, - signConvention: SignConvention, - useCurvatureDisplay: boolean, + context: EditControlContext, ): EditControlsResult { - return buildEditControlsFromRegistry(element, triggerRebuild, signConvention, useCurvatureDisplay); + return buildEditControlsFromRegistry(element, triggerRebuild, context); } opticsLab.register("buildEditControls", buildEditControls); diff --git a/src/common/view/ElementRegistry.ts b/src/common/view/ElementRegistry.ts index 488b6ef..e80a6f0 100644 --- a/src/common/view/ElementRegistry.ts +++ b/src/common/view/ElementRegistry.ts @@ -14,10 +14,11 @@ * entries may share the same typeKey — a startup assertion enforces this. */ +import type { TReadOnlyProperty } from "scenerystack/axon"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; import type { Node, RichDragListener } from "scenerystack/scenery"; import type { Tandem } from "scenerystack/tandem"; -import type { SignConvention } from "../../preferences/OpticsLabPreferencesModel.js"; +import { StringManager } from "../../i18n/StringManager.js"; import type { ApertureElement } from "../model/blockers/ApertureElement.js"; import type { LineBlocker } from "../model/blockers/LineBlocker.js"; import type { DetectorElement } from "../model/detectors/DetectorElement.js"; @@ -57,7 +58,7 @@ import type { OpticalElement } from "../model/optics/OpticsTypes.js"; import { ApertureView } from "./blockers/ApertureView.js"; import { LineBlockerView } from "./blockers/LineBlockerView.js"; import { DetectorView } from "./detectors/DetectorView.js"; -import type { EditControlsResult } from "./edit-controls/EditControlsResult.js"; +import type { EditControlContext, EditControlsResult } from "./edit-controls/EditControlsResult.js"; import { buildDovePrismControls, buildEquilateralPrismControls, @@ -126,6 +127,13 @@ interface ElementDescriptor { * Used for O(1) dispatch — no ordered instanceof chains required. */ readonly typeKey: string; + /** + * Localised human-readable label for this element type. + * Shown in the edit panel title and any other UI that names elements by type. + * Single source of truth — EditContainerNode reads this instead of maintaining + * its own parallel TYPE_LABELS map. + */ + readonly label: TReadOnlyProperty; /** Create the Scenery view for this element type, or null if not applicable. */ readonly createView: ( element: OpticalElement, @@ -135,12 +143,13 @@ interface ElementDescriptor { /** * Build the edit-panel controls for this element type. * Absent for elements with no editable properties (e.g. ApertureElement). + * Display-preference options are forwarded via `context` so the signature + * stays stable when new preferences are added. */ readonly buildEditControls?: ( element: OpticalElement, triggerRebuild: () => void, - signConvention: SignConvention, - useCurvatureDisplay: boolean, + context: EditControlContext, ) => EditControlsResult; } @@ -148,236 +157,280 @@ interface ElementDescriptor { * Registry of element descriptors, keyed by each type's `type` string. * Order no longer matters for dispatch — a Map lookup is used instead of a * sequential instanceof scan. A startup assertion catches duplicate keys. + * + * Each entry includes a `label` (localised display name) so that the edit panel + * and any other UI that names elements by type has a single source of truth here, + * rather than maintaining a parallel type→label map elsewhere. */ -export const ELEMENT_REGISTRY: ElementDescriptor[] = [ - // ── Light Sources ────────────────────────────────────────────────────────── - { - typeKey: "ArcSource", - createView: (element, modelViewTransform, tandem) => - new ArcLightSourceView(element as ArcLightSource, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildArcLightSourceControls(element as ArcLightSource, rebuild), - }, - { - typeKey: "PointSource", - createView: (element, modelViewTransform, tandem) => - new PointSourceView(element as PointSourceElement, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildPointSourceControls(element as PointSourceElement, rebuild), - }, - { - typeKey: "Beam", - createView: (element, modelViewTransform, tandem) => - new BeamSourceView(element as BeamSource, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildBeamSourceControls(element as BeamSource, rebuild), - }, - { - typeKey: "DivergentBeam", - createView: (element, modelViewTransform, tandem) => - new DivergentBeamView(element as DivergentBeam, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildDivergentBeamControls(element as DivergentBeam, rebuild), - }, - { - typeKey: "SingleRay", - createView: (element, modelViewTransform, tandem) => - new SingleRaySourceView(element as SingleRaySource, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildSingleRaySourceControls(element as SingleRaySource, rebuild), - }, - { - typeKey: "continuousSpectrumSource", - createView: (element, modelViewTransform, tandem) => - new ContinuousSpectrumSourceView(element as ContinuousSpectrumSource, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => - buildContinuousSpectrumSourceControls(element as ContinuousSpectrumSource, rebuild), - }, +export const ELEMENT_REGISTRY: ElementDescriptor[] = (() => { + // Labels are resolved once at module-init time via the StringManager singleton. + const c = StringManager.getInstance().getComponentStrings(); + + return [ + // ── Light Sources ──────────────────────────────────────────────────────── + { + typeKey: "ArcSource", + label: c.arcSourceStringProperty, + createView: (element, modelViewTransform, tandem) => + new ArcLightSourceView(element as ArcLightSource, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildArcLightSourceControls(element as ArcLightSource, rebuild), + }, + { + typeKey: "PointSource", + label: c.pointSourceStringProperty, + createView: (element, modelViewTransform, tandem) => + new PointSourceView(element as PointSourceElement, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildPointSourceControls(element as PointSourceElement, rebuild), + }, + { + typeKey: "Beam", + label: c.beamSourceStringProperty, + createView: (element, modelViewTransform, tandem) => + new BeamSourceView(element as BeamSource, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildBeamSourceControls(element as BeamSource, rebuild), + }, + { + typeKey: "DivergentBeam", + label: c.divergentBeamSourceStringProperty, + createView: (element, modelViewTransform, tandem) => + new DivergentBeamView(element as DivergentBeam, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildDivergentBeamControls(element as DivergentBeam, rebuild), + }, + { + typeKey: "SingleRay", + label: c.singleRayStringProperty, + createView: (element, modelViewTransform, tandem) => + new SingleRaySourceView(element as SingleRaySource, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildSingleRaySourceControls(element as SingleRaySource, rebuild), + }, + { + typeKey: "ContinuousSpectrumSource", + label: c.continuousSpectrumStringProperty, + createView: (element, modelViewTransform, tandem) => + new ContinuousSpectrumSourceView(element as ContinuousSpectrumSource, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => + buildContinuousSpectrumSourceControls(element as ContinuousSpectrumSource, rebuild), + }, - // ── Mirrors ──────────────────────────────────────────────────────────────── - { - typeKey: "AperturedParabolicMirror", - createView: (element, modelViewTransform, tandem) => - new AperturedParabolicMirrorView(element as AperturedParabolicMirror, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildAperturedMirrorControls(element as AperturedParabolicMirror, rebuild), - }, - { - typeKey: "Mirror", - createView: (element, modelViewTransform, tandem) => - new SegmentMirrorView(element as SegmentMirror, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildSegmentControls(element as SegmentMirror, rebuild), - }, - { - typeKey: "ArcMirror", - createView: (element, modelViewTransform, tandem) => - new ArcMirrorView(element as ArcMirror, modelViewTransform, tandem), - buildEditControls: (element, rebuild, _signConvention, useCurvatureDisplay) => - buildArcMirrorControls(element as ArcMirror, rebuild, useCurvatureDisplay), - }, - { - typeKey: "ParabolicMirror", - createView: (element, modelViewTransform, tandem) => - new ParabolicMirrorView(element as ParabolicMirror, modelViewTransform, tandem), - // No editable properties for ParabolicMirror. - }, - { - typeKey: "IdealMirror", - createView: (element, modelViewTransform, tandem) => - new IdealCurvedMirrorView(element as IdealCurvedMirror, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildIdealCurvedMirrorControls(element as IdealCurvedMirror, rebuild), - }, - { - typeKey: "BeamSplitter", - createView: (element, modelViewTransform, tandem) => - new BeamSplitterView(element as BeamSplitterElement, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildBeamSplitterControls(element as BeamSplitterElement, rebuild), - }, + // ── Mirrors ────────────────────────────────────────────────────────────── + { + typeKey: "AperturedParabolicMirror", + label: c.aperturedMirrorStringProperty, + createView: (element, modelViewTransform, tandem) => + new AperturedParabolicMirrorView(element as AperturedParabolicMirror, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => + buildAperturedMirrorControls(element as AperturedParabolicMirror, rebuild), + }, + { + typeKey: "Mirror", + label: c.flatMirrorStringProperty, + createView: (element, modelViewTransform, tandem) => + new SegmentMirrorView(element as SegmentMirror, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildSegmentControls(element as SegmentMirror, rebuild), + }, + { + typeKey: "ArcMirror", + label: c.arcMirrorStringProperty, + createView: (element, modelViewTransform, tandem) => + new ArcMirrorView(element as ArcMirror, modelViewTransform, tandem), + buildEditControls: (element, rebuild, { useCurvatureDisplay }) => + buildArcMirrorControls(element as ArcMirror, rebuild, useCurvatureDisplay), + }, + { + typeKey: "ParabolicMirror", + label: c.parabolicMirrorStringProperty, + createView: (element, modelViewTransform, tandem) => + new ParabolicMirrorView(element as ParabolicMirror, modelViewTransform, tandem), + // No editable properties for ParabolicMirror. + }, + { + typeKey: "IdealMirror", + label: c.idealMirrorStringProperty, + createView: (element, modelViewTransform, tandem) => + new IdealCurvedMirrorView(element as IdealCurvedMirror, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildIdealCurvedMirrorControls(element as IdealCurvedMirror, rebuild), + }, + { + typeKey: "BeamSplitter", + label: c.beamSplitterStringProperty, + createView: (element, modelViewTransform, tandem) => + new BeamSplitterView(element as BeamSplitterElement, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildBeamSplitterControls(element as BeamSplitterElement, rebuild), + }, - // ── Glass / Lenses ───────────────────────────────────────────────────────── - { - typeKey: "IdealLens", - createView: (element, modelViewTransform, tandem) => - new IdealLensView(element as IdealLens, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildIdealLensControls(element as IdealLens, rebuild), - }, - { - typeKey: "CircleGlass", - createView: (element, modelViewTransform, tandem) => - new CircleGlassView(element as CircleGlass, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildRefractiveIndexControls(element as CircleGlass, rebuild), - }, - { - typeKey: "BiconvexLens", - createView: (element, modelViewTransform, tandem) => - new SymmetricLensView(element as BiconvexLens, modelViewTransform, tandem), - buildEditControls: (element, rebuild, signConvention, useCurvatureDisplay) => - buildSymmetricLensControls(element as BiconvexLens, rebuild, signConvention, useCurvatureDisplay), - }, - { - typeKey: "BiconcaveLens", - createView: (element, modelViewTransform, tandem) => - new SymmetricLensView(element as BiconcaveLens, modelViewTransform, tandem), - buildEditControls: (element, rebuild, signConvention, useCurvatureDisplay) => - buildSymmetricLensControls(element as BiconcaveLens, rebuild, signConvention, useCurvatureDisplay), - }, - { - typeKey: "PlanoConvexLens", - createView: (element, modelViewTransform, tandem) => - new PlanoLensView(element as PlanoConvexLens, modelViewTransform, tandem), - buildEditControls: (element, rebuild, signConvention, useCurvatureDisplay) => - buildPlanoLensControls(element as PlanoConvexLens, rebuild, signConvention, useCurvatureDisplay), - }, - { - typeKey: "PlanoConcaveLens", - createView: (element, modelViewTransform, tandem) => - new PlanoLensView(element as PlanoConcaveLens, modelViewTransform, tandem), - buildEditControls: (element, rebuild, signConvention, useCurvatureDisplay) => - buildPlanoLensControls(element as PlanoConcaveLens, rebuild, signConvention, useCurvatureDisplay), - }, - { - typeKey: "SphericalLens", - createView: (element, modelViewTransform, tandem) => - new SphericalLensView(element as SphericalLens, modelViewTransform, tandem), - buildEditControls: (element, rebuild, signConvention, useCurvatureDisplay) => - buildSphericalLensControls(element as SphericalLens, rebuild, signConvention, useCurvatureDisplay), - }, - // Typed prisms — each has its own typeKey, no ordering constraints needed. - { - typeKey: "EquilateralPrism", - createView: (element, modelViewTransform, tandem) => - new TypedPrismView(element as EquilateralPrism, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildEquilateralPrismControls(element as EquilateralPrism, rebuild), - }, - { - typeKey: "RightAnglePrism", - createView: (element, modelViewTransform, tandem) => - new TypedPrismView(element as RightAnglePrism, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildRightAnglePrismControls(element as RightAnglePrism, rebuild), - }, - { - typeKey: "PorroPrism", - createView: (element, modelViewTransform, tandem) => - new TypedPrismView(element as PorroPrism, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildPorroPrismControls(element as PorroPrism, rebuild), - }, - { - typeKey: "SlabGlass", - createView: (element, modelViewTransform, tandem) => - new TypedPrismView(element as SlabGlass, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildSlabGlassControls(element as SlabGlass, rebuild), - }, - { - typeKey: "ParallelogramPrism", - createView: (element, modelViewTransform, tandem) => - new TypedPrismView(element as ParallelogramPrism, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildParallelogramPrismControls(element as ParallelogramPrism, rebuild), - }, - { - typeKey: "DovePrism", - createView: (element, modelViewTransform, tandem) => - new TypedPrismView(element as DovePrism, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildDovePrismControls(element as DovePrism, rebuild), - }, - { - typeKey: "Glass", - createView: (element, modelViewTransform, tandem) => new GlassView(element as Glass, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildRefractiveIndexControls(element as BaseGlass, rebuild), - }, - { - typeKey: "PlaneGlass", - createView: (element, modelViewTransform, tandem) => - new HalfPlaneGlassView(element as HalfPlaneGlass, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildHalfPlaneGlassControls(element as HalfPlaneGlass, rebuild), - }, + // ── Glass / Lenses ─────────────────────────────────────────────────────── + { + typeKey: "IdealLens", + label: c.idealLensStringProperty, + createView: (element, modelViewTransform, tandem) => + new IdealLensView(element as IdealLens, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildIdealLensControls(element as IdealLens, rebuild), + }, + { + typeKey: "CircleGlass", + label: c.circleGlassStringProperty, + createView: (element, modelViewTransform, tandem) => + new CircleGlassView(element as CircleGlass, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildRefractiveIndexControls(element as CircleGlass, rebuild), + }, + { + typeKey: "BiconvexLens", + label: c.biconvexLensStringProperty, + createView: (element, modelViewTransform, tandem) => + new SymmetricLensView(element as BiconvexLens, modelViewTransform, tandem), + buildEditControls: (element, rebuild, { signConvention, useCurvatureDisplay }) => + buildSymmetricLensControls(element as BiconvexLens, rebuild, signConvention, useCurvatureDisplay), + }, + { + typeKey: "BiconcaveLens", + label: c.biconcaveLensStringProperty, + createView: (element, modelViewTransform, tandem) => + new SymmetricLensView(element as BiconcaveLens, modelViewTransform, tandem), + buildEditControls: (element, rebuild, { signConvention, useCurvatureDisplay }) => + buildSymmetricLensControls(element as BiconcaveLens, rebuild, signConvention, useCurvatureDisplay), + }, + { + typeKey: "PlanoConvexLens", + label: c.planoConvexLensStringProperty, + createView: (element, modelViewTransform, tandem) => + new PlanoLensView(element as PlanoConvexLens, modelViewTransform, tandem), + buildEditControls: (element, rebuild, { signConvention, useCurvatureDisplay }) => + buildPlanoLensControls(element as PlanoConvexLens, rebuild, signConvention, useCurvatureDisplay), + }, + { + typeKey: "PlanoConcaveLens", + label: c.planoConcaveLensStringProperty, + createView: (element, modelViewTransform, tandem) => + new PlanoLensView(element as PlanoConcaveLens, modelViewTransform, tandem), + buildEditControls: (element, rebuild, { signConvention, useCurvatureDisplay }) => + buildPlanoLensControls(element as PlanoConcaveLens, rebuild, signConvention, useCurvatureDisplay), + }, + { + typeKey: "SphericalLens", + label: c.sphericalLensStringProperty, + createView: (element, modelViewTransform, tandem) => + new SphericalLensView(element as SphericalLens, modelViewTransform, tandem), + buildEditControls: (element, rebuild, { signConvention, useCurvatureDisplay }) => + buildSphericalLensControls(element as SphericalLens, rebuild, signConvention, useCurvatureDisplay), + }, + // Typed prisms — each has its own typeKey, no ordering constraints needed. + { + typeKey: "EquilateralPrism", + label: c.equilateralPrismStringProperty, + createView: (element, modelViewTransform, tandem) => + new TypedPrismView(element as EquilateralPrism, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildEquilateralPrismControls(element as EquilateralPrism, rebuild), + }, + { + typeKey: "RightAnglePrism", + label: c.rightAnglePrismStringProperty, + createView: (element, modelViewTransform, tandem) => + new TypedPrismView(element as RightAnglePrism, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildRightAnglePrismControls(element as RightAnglePrism, rebuild), + }, + { + typeKey: "PorroPrism", + label: c.porroPrismStringProperty, + createView: (element, modelViewTransform, tandem) => + new TypedPrismView(element as PorroPrism, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildPorroPrismControls(element as PorroPrism, rebuild), + }, + { + typeKey: "SlabGlass", + label: c.slabGlassStringProperty, + createView: (element, modelViewTransform, tandem) => + new TypedPrismView(element as SlabGlass, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildSlabGlassControls(element as SlabGlass, rebuild), + }, + { + typeKey: "ParallelogramPrism", + label: c.parallelogramPrismStringProperty, + createView: (element, modelViewTransform, tandem) => + new TypedPrismView(element as ParallelogramPrism, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildParallelogramPrismControls(element as ParallelogramPrism, rebuild), + }, + { + typeKey: "DovePrism", + label: c.dovePrismStringProperty, + createView: (element, modelViewTransform, tandem) => + new TypedPrismView(element as DovePrism, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildDovePrismControls(element as DovePrism, rebuild), + }, + { + typeKey: "Glass", + label: c.glassPrismStringProperty, + createView: (element, modelViewTransform, tandem) => new GlassView(element as Glass, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildRefractiveIndexControls(element as BaseGlass, rebuild), + }, + { + typeKey: "PlaneGlass", + label: c.halfPlaneGlassStringProperty, + createView: (element, modelViewTransform, tandem) => + new HalfPlaneGlassView(element as HalfPlaneGlass, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildHalfPlaneGlassControls(element as HalfPlaneGlass, rebuild), + }, - // ── Gratings ─────────────────────────────────────────────────────────────── - { - typeKey: "TransmissionGrating", - createView: (element, modelViewTransform, tandem) => - new TransmissionGratingView(element as TransmissionGrating, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildGratingControls(element as TransmissionGrating, rebuild), - }, - { - typeKey: "ReflectionGrating", - createView: (element, modelViewTransform, tandem) => - new ReflectionGratingView(element as ReflectionGrating, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildGratingControls(element as ReflectionGrating, rebuild), - }, + // ── Gratings ───────────────────────────────────────────────────────────── + { + typeKey: "TransmissionGrating", + label: c.transmissionGratingStringProperty, + createView: (element, modelViewTransform, tandem) => + new TransmissionGratingView(element as TransmissionGrating, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildGratingControls(element as TransmissionGrating, rebuild), + }, + { + typeKey: "ReflectionGrating", + label: c.reflectionGratingStringProperty, + createView: (element, modelViewTransform, tandem) => + new ReflectionGratingView(element as ReflectionGrating, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildGratingControls(element as ReflectionGrating, rebuild), + }, - // ── Detectors ────────────────────────────────────────────────────────────── - { - typeKey: "Detector", - createView: (element, modelViewTransform, tandem) => - new DetectorView(element as DetectorElement, modelViewTransform, tandem), - buildEditControls: (element, rebuild, _signConvention, useCurvatureDisplay) => - buildDetectorControls(element as DetectorElement, rebuild, useCurvatureDisplay), - }, + // ── Detectors ──────────────────────────────────────────────────────────── + { + typeKey: "Detector", + label: c.detectorStringProperty, + createView: (element, modelViewTransform, tandem) => + new DetectorView(element as DetectorElement, modelViewTransform, tandem), + buildEditControls: (element, rebuild, { useCurvatureDisplay }) => + buildDetectorControls(element as DetectorElement, rebuild, useCurvatureDisplay), + }, - // ── Blockers ─────────────────────────────────────────────────────────────── - { - typeKey: "Aperture", - createView: (element, modelViewTransform, tandem) => - new ApertureView(element as ApertureElement, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildApertureControls(element as ApertureElement, rebuild), - }, - { - typeKey: "Blocker", - createView: (element, modelViewTransform, tandem) => - new LineBlockerView(element as LineBlocker, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildSegmentControls(element as LineBlocker, rebuild), - }, + // ── Blockers ───────────────────────────────────────────────────────────── + { + typeKey: "Aperture", + label: c.apertureStringProperty, + createView: (element, modelViewTransform, tandem) => + new ApertureView(element as ApertureElement, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildApertureControls(element as ApertureElement, rebuild), + }, + { + typeKey: "Blocker", + label: c.lineBlockerStringProperty, + createView: (element, modelViewTransform, tandem) => + new LineBlockerView(element as LineBlocker, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildSegmentControls(element as LineBlocker, rebuild), + }, - // ── Fiber Optic ──────────────────────────────────────────────────────────── - { - typeKey: "FiberOptic", - createView: (element, modelViewTransform, tandem) => - new FiberOpticView(element as FiberOpticElement, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildFiberOpticControls(element as FiberOpticElement, rebuild), - }, + // ── Fiber Optic ────────────────────────────────────────────────────────── + { + typeKey: "FiberOptic", + label: c.fiberOpticStringProperty, + createView: (element, modelViewTransform, tandem) => + new FiberOpticView(element as FiberOpticElement, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildFiberOpticControls(element as FiberOpticElement, rebuild), + }, - // ── Guides ───────────────────────────────────────────────────────────────── - { - typeKey: "Track", - createView: (element, modelViewTransform, tandem) => - new TrackView(element as TrackElement, modelViewTransform, tandem), - buildEditControls: (element, rebuild) => buildSegmentControls(element as TrackElement, rebuild), - }, -]; + // ── Guides ─────────────────────────────────────────────────────────────── + { + typeKey: "Track", + label: c.trackStringProperty, + createView: (element, modelViewTransform, tandem) => + new TrackView(element as TrackElement, modelViewTransform, tandem), + buildEditControls: (element, rebuild) => buildSegmentControls(element as TrackElement, rebuild), + }, + ]; +})(); // Build a Map for O(1) dispatch and validate that no two entries share a typeKey. const RegistryMap = new Map(); @@ -409,12 +462,21 @@ export function createOpticalElementView( export function buildEditControls( element: OpticalElement, triggerRebuild: () => void, - signConvention: SignConvention, - useCurvatureDisplay: boolean, + context: EditControlContext, ): EditControlsResult { const descriptor = RegistryMap.get(element.type); if (descriptor?.buildEditControls) { - return descriptor.buildEditControls(element, triggerRebuild, signConvention, useCurvatureDisplay); + return descriptor.buildEditControls(element, triggerRebuild, context); } return { controls: [], refreshCallback: null }; } + +/** + * Return the localised label for the given element type, or undefined when + * the type has no registry entry. EditContainerNode uses this as the single + * source of truth for element display names — no parallel TYPE_LABELS map + * needed elsewhere. + */ +export function getElementLabel(typeKey: string): TReadOnlyProperty | undefined { + return RegistryMap.get(typeKey)?.label; +} diff --git a/src/common/view/SimScreenView.ts b/src/common/view/SimScreenView.ts index 73527c2..baab622 100644 --- a/src/common/view/SimScreenView.ts +++ b/src/common/view/SimScreenView.ts @@ -89,6 +89,41 @@ import { } from "./ToolsPanelIcons.js"; import { viewSnapState } from "./ViewSnapState.js"; +/** + * Wire two properties so that a change in either is immediately reflected in + * the other, without triggering an infinite re-entrant loop. + * + * This replaces the repetitive `let blockXSync = false` pattern that appeared + * three times in the constructor. The sync is bidirectional because: + * - Preferences → scene keeps the simulation up-to-date when the user opens + * the preferences panel. + * - Scene → preferences keeps the preferences panel up-to-date when PhET-iO + * sets the scene property directly (e.g. via the studio UI or saved state). + * + * `propA.value` is written to `propB` on the first call so both start in sync. + */ +function syncBidirectional( + propA: { value: T; lazyLink: (cb: (v: T) => void) => void }, + propB: { value: T; lazyLink: (cb: (v: T) => void) => void }, +): void { + propB.value = propA.value; + let syncing = false; + propA.lazyLink((v) => { + if (!syncing) { + syncing = true; + propB.value = v; + syncing = false; + } + }); + propB.lazyLink((v) => { + if (!syncing) { + syncing = true; + propA.value = v; + syncing = false; + } + }); +} + /** * Single-letter shortcuts for the Tools panel checkboxes (when not typing in a field). * Returns true if the key was handled (caller should preventDefault). @@ -269,43 +304,20 @@ export class RayTracingCommonView extends ScreenView { }); // ── Grid (model + preferences stay in sync for PhET-iO and global prefs) ─ + // showGrid is initialised from a query parameter (one-time override only). model.scene.showGridProperty.value = opticsLabQueryParameters.showGrid; - model.scene.gridSizeProperty.value = _opticsLabPreferences.gridSpacingProperty.value; - - let blockSnapSync = false; - _opticsLabPreferences.snapToGridProperty.lazyLink((v) => { - if (!blockSnapSync) { - blockSnapSync = true; - model.scene.snapToGridProperty.value = v; - blockSnapSync = false; - } - }); - model.scene.snapToGridProperty.lazyLink((v) => { - if (!blockSnapSync) { - blockSnapSync = true; - _opticsLabPreferences.snapToGridProperty.value = v; - blockSnapSync = false; - } - }); - let blockGridSync = false; - _opticsLabPreferences.gridSpacingProperty.lazyLink((v) => { - if (!blockGridSync) { - blockGridSync = true; - model.scene.gridSizeProperty.value = v; - blockGridSync = false; - } - }); - model.scene.gridSizeProperty.lazyLink((v) => { - if (!blockGridSync) { - blockGridSync = true; - _opticsLabPreferences.gridSpacingProperty.value = v; - blockGridSync = false; - } - }); + + // snapToGrid, gridSpacing, and maxRayDepth are duplicated between the + // preferences model and the scene so that both the preferences panel UI + // and PhET-iO state capture see up-to-date values. syncBidirectional + // handles the mutual update without re-entrant loops. + syncBidirectional(_opticsLabPreferences.snapToGridProperty, model.scene.snapToGridProperty); + syncBidirectional(_opticsLabPreferences.gridSpacingProperty, model.scene.gridSizeProperty); + syncBidirectional(_opticsLabPreferences.maxRayDepthProperty, model.scene.maxRayDepthProperty); // ── Partial reflection (global toggle via preferences) ────────────────── - // Drive the scene's property from the preference so the model layer controls - // the flag, avoiding direct view→model static mutation. + // These are preferences-only toggles; there is no PhET-iO override path + // for the scene copies, so a one-directional link is sufficient. _opticsLabPreferences.partialReflectionEnabledProperty.link((v) => { model.scene.partialReflectionEnabledProperty.value = v; }); @@ -314,24 +326,6 @@ export class RayTracingCommonView extends ScreenView { model.scene.lensRimBlockingProperty.value = v; }); - // ── Max ray depth (bidirectional sync for PhET-iO) ────────────────────── - model.scene.maxRayDepthProperty.value = _opticsLabPreferences.maxRayDepthProperty.value; - let blockRayDepthSync = false; - _opticsLabPreferences.maxRayDepthProperty.lazyLink((v) => { - if (!blockRayDepthSync) { - blockRayDepthSync = true; - model.scene.maxRayDepthProperty.value = v; - blockRayDepthSync = false; - } - }); - model.scene.maxRayDepthProperty.lazyLink((v) => { - if (!blockRayDepthSync) { - blockRayDepthSync = true; - _opticsLabPreferences.maxRayDepthProperty.value = v; - blockRayDepthSync = false; - } - }); - const gridVisibleProperty = model.scene.showGridProperty; const gridContainer = new Node(); gridVisibleProperty.linkAttribute(gridContainer, "visible"); @@ -1145,7 +1139,7 @@ export class RayTracingCommonView extends ScreenView { } /** - * Convert an element.type string (e.g. "IdealLens", "continuousSpectrumSource") + * Convert an element.type string (e.g. "IdealLens", "ContinuousSpectrumSource") * to a human-readable accessible name ("Ideal Lens", "Continuous Spectrum Source"). */ function ElementTypeToAccessibleName(type: string): string { diff --git a/src/common/view/edit-controls/EditControlsResult.ts b/src/common/view/edit-controls/EditControlsResult.ts index 22aebd8..2cec594 100644 --- a/src/common/view/edit-controls/EditControlsResult.ts +++ b/src/common/view/edit-controls/EditControlsResult.ts @@ -1,13 +1,29 @@ /** * EditControlsResult.ts * - * Shared result type returned by all per-element edit-control builders. + * Shared result type returned by all per-element edit-control builders, + * and the context bag that carries per-call display-preference options. */ import type { Node } from "scenerystack/scenery"; +import type { SignConvention } from "../../../preferences/OpticsLabPreferencesModel.js"; export type EditControlsResult = { controls: Node[]; /** Called by EditContainerNode.refresh() to sync controls after a geometry drag. */ refreshCallback: (() => void) | null; }; + +/** + * Display-preference context forwarded to element edit-control builders. + * + * Collected into a single bag so that adding a new display preference only + * requires updating this interface and the call site in EditContainerNode — + * not the signature of every builder function. + */ +export interface EditControlContext { + /** Sign convention used when displaying radii of curvature. */ + signConvention: SignConvention; + /** When true, builders show curvature κ = 1/R instead of radius R. */ + useCurvatureDisplay: boolean; +}