From 01684df815ac65449f58714c86fd3b0d7a58aec1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 04:19:21 +0000 Subject: [PATCH] Refactor optical repetition: base view class, shared geometry, and control helpers A. EditContainerNode._buildControls: extract numberControlOptions(delta, decimalPlaces) factory and buildSegmentLengthControl(element, label, triggerRebuild) helper to eliminate copy-pasted NumberControl options (~10 occurrences) and lenProp/lazyLink blocks (5 occurrences for BeamSource, IdealLens, IdealCurvedMirror, SegmentMirror/LineBlocker, TransmissionGrating/ReflectionGrating). B. Create BaseOpticalElementView abstract base class (Node subclass) with abstract bodyDragListener, onRebuild callback, and abstract rebuild(). Update all 19 *View classes to extend it, removing duplicate field declarations and widening rebuild() from private to protected override. GlassView satisfies bodyDragListener via getter; SphericalLensView inherits through GlassView. C. Move circumcenter and sampleArcPoints to Geometry.ts. ArcMirrorView.ts imports them from there instead of defining them locally. ArcMirror.ts replaces its linesIntersection-of-perpendicularBisectors approach with the shared circumcenter(). https://claude.ai/code/session_01Mm6vZucruGjT52Za6wSAQD --- src/common/model/mirrors/ArcMirror.ts | 13 +- src/common/model/optics/Geometry.ts | 70 ++++ src/common/view/BaseOpticalElementView.ts | 39 ++ src/common/view/EditContainerNode.ts | 358 +++++------------- src/common/view/blockers/ApertureView.ts | 8 +- src/common/view/blockers/CircleBlockerView.ts | 8 +- src/common/view/blockers/LineBlockerView.ts | 8 +- src/common/view/glass/CircleGlassView.ts | 8 +- src/common/view/glass/GlassView.ts | 5 +- src/common/view/glass/HalfPlaneGlassView.ts | 8 +- src/common/view/glass/IdealLensView.ts | 8 +- src/common/view/glass/SphericalLensView.ts | 2 - .../view/gratings/ReflectionGratingView.ts | 8 +- .../view/gratings/TransmissionGratingView.ts | 8 +- .../view/light-sources/ArcLightSourceView.ts | 8 +- .../view/light-sources/BeamSourceView.ts | 8 +- .../ContinuousSpectrumSourceView.ts | 8 +- .../view/light-sources/PointSourceView.ts | 8 +- .../view/light-sources/SingleRaySourceView.ts | 8 +- src/common/view/mirrors/ArcMirrorView.ts | 82 +--- src/common/view/mirrors/BeamSplitterView.ts | 8 +- .../view/mirrors/IdealCurvedMirrorView.ts | 8 +- .../view/mirrors/ParabolicMirrorView.ts | 8 +- src/common/view/mirrors/SegmentMirrorView.ts | 8 +- 24 files changed, 291 insertions(+), 414 deletions(-) create mode 100644 src/common/view/BaseOpticalElementView.ts diff --git a/src/common/model/mirrors/ArcMirror.ts b/src/common/model/mirrors/ArcMirror.ts index e81de9c..b7907fb 100644 --- a/src/common/model/mirrors/ArcMirror.ts +++ b/src/common/model/mirrors/ArcMirror.ts @@ -10,16 +10,14 @@ import { BaseElement } from "../optics/BaseElement.js"; import { circle, + circumcenter, distance, distanceSquared, dot, - linesIntersection, normalize, type Point, - perpendicularBisector, point, rayCircleIntersections, - segment, subtract, } from "../optics/Geometry.js"; import { MIN_RAY_LENGTH_SQ } from "../optics/OpticsConstants.js"; @@ -86,14 +84,7 @@ export class ArcMirror extends BaseElement { } private getArcGeometry(): { center: Point; radius: number } | null { - const center = linesIntersection( - perpendicularBisector(segment(this.p1, this.p3)), - perpendicularBisector(segment(this.p2, this.p3)), - ); - if (!(center && Number.isFinite(center.x) && Number.isFinite(center.y))) { - return null; - } - return { center, radius: distance(center, this.p3) }; + return circumcenter(this.p1, this.p2, this.p3); } /** diff --git a/src/common/model/optics/Geometry.ts b/src/common/model/optics/Geometry.ts index e648de5..c4dff01 100644 --- a/src/common/model/optics/Geometry.ts +++ b/src/common/model/optics/Geometry.ts @@ -319,6 +319,76 @@ export function refract(direction: Point, normal: Point, n1: number, n2: number) ); } +// ── Circumcenter / Arc Sampling ────────────────────────────────────────────── + +/** + * Compute the circumcenter (and circumradius) of the triangle formed by + * three points. Returns null if the points are collinear (degenerate arc). + */ +export function circumcenter(p1: Point, p2: Point, p3: Point): { center: Point; radius: number } | null { + const ax = p1.x; + const ay = p1.y; + const bx = p2.x; + const by = p2.y; + const cx = p3.x; + const cy = p3.y; + + const D = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); + if (Math.abs(D) < 1e-10) { + return null; // collinear + } + + const a2 = ax * ax + ay * ay; + const b2 = bx * bx + by * by; + const c2 = cx * cx + cy * cy; + + const ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / D; + const uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / D; + + const center = point(ux, uy); + const radius = Math.sqrt((ax - ux) ** 2 + (ay - uy) ** 2); + return { center, radius }; +} + +/** + * Sample n+1 model-space points along the circular arc from p1 to p2 + * passing through p3. Falls back to a straight line when the three points + * are collinear. + */ +export function sampleArcPoints(p1: Point, p2: Point, p3: Point, n: number): Point[] { + const geo = circumcenter(p1, p2, p3); + if (!geo) { + // Collinear: return a straight-line interpolation + const pts: Point[] = []; + for (let i = 0; i <= n; i++) { + const t = i / n; + pts.push(point(p1.x + (p2.x - p1.x) * t, p1.y + (p2.y - p1.y) * t)); + } + return pts; + } + + const { center, radius } = geo; + const norm = (a: number): number => ((a % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + + const a1 = norm(Math.atan2(p1.y - center.y, p1.x - center.x)); + const a2 = norm(Math.atan2(p2.y - center.y, p2.x - center.x)); + const a3 = norm(Math.atan2(p3.y - center.y, p3.x - center.x)); + + // CCW sweep from a1 to a2; check if a3 is within this sweep + const ccwSweep12 = norm(a2 - a1); + const ccwDist13 = norm(a3 - a1); + + // Go CCW if a3 lies within the CCW arc from a1 to a2; otherwise go CW + const sweepAngle = ccwDist13 < ccwSweep12 ? ccwSweep12 : -(2 * Math.PI - ccwSweep12); + + const pts: Point[] = []; + for (let i = 0; i <= n; i++) { + const angle = a1 + sweepAngle * (i / n); + pts.push(point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))); + } + return pts; +} + // ── Fresnel Equations ──────────────────────────────────────────────────────── /** diff --git a/src/common/view/BaseOpticalElementView.ts b/src/common/view/BaseOpticalElementView.ts new file mode 100644 index 0000000..1600ff2 --- /dev/null +++ b/src/common/view/BaseOpticalElementView.ts @@ -0,0 +1,39 @@ +/** + * BaseOpticalElementView.ts + * + * Abstract base class shared by every optical-element view node. + * Encapsulates the three structural members that are identical across + * all element views: + * + * • bodyDragListener – the drag listener that translates the whole element. + * Declared abstract so each subclass assigns the concrete instance it + * creates with attachTranslationDrag(). + * + * • onRebuild – optional callback invoked at the end of every rebuild(). + * Used by EditContainerNode to push updated model values back into the + * displayed NumberProperty controls after a drag changes the geometry. + * + * • rebuild() – protected abstract template method that updates all visual + * geometry (shapes, handle positions, focal markers, …) to match the + * current model state. Subclasses implement this; it replaces the + * previous private rebuild() pattern, making it properly overridable. + */ + +import { Node, type RichDragListener } from "scenerystack/scenery"; +import opticsLab from "../../OpticsLabNamespace.js"; + +export abstract class BaseOpticalElementView extends Node { + /** Drag listener used to translate the element as a whole. */ + public abstract readonly bodyDragListener: RichDragListener; + + /** + * Optional callback invoked after every rebuild(). + * External observers (e.g. EditContainerNode) set this to sync UI controls. + */ + public onRebuild: (() => void) | null = null; + + /** Recompute all visual geometry to match the current model state. */ + protected abstract rebuild(): void; +} + +opticsLab.register("BaseOpticalElementView", BaseOpticalElementView); diff --git a/src/common/view/EditContainerNode.ts b/src/common/view/EditContainerNode.ts index 7d8a488..14f2ed5 100644 --- a/src/common/view/EditContainerNode.ts +++ b/src/common/view/EditContainerNode.ts @@ -216,6 +216,32 @@ function makeWavelengthControl( }); } +/** + * Shared NumberControl options used throughout the panel. + * delta and decimalPlaces are the only values that vary between controls. + */ +function numberControlOptions(delta: number, decimalPlaces: number) { + return { + delta, + includeArrowButtons: false, + soundGenerator: null, + layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), + titleNodeOptions: { fill: OpticsLabColors.overlayLabelFillProperty, font: LABEL_FONT }, + numberDisplayOptions: { + decimalPlaces, + textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, + backgroundFill: "rgba(0,0,0,0.35)", + backgroundStroke: "rgba(100,100,120,0.6)", + }, + sliderOptions: { + trackSize: SLIDER_TRACK_SIZE, + thumbSize: SLIDER_THUMB_SIZE, + tandem: Tandem.OPT_OUT, + }, + tandem: Tandem.OPT_OUT, + }; +} + /** Euclidean length of a two-point segment in model space. */ function segmentLength(p1: { x: number; y: number }, p2: { x: number; y: number }): number { return Math.hypot(p2.x - p1.x, p2.y - p1.y); @@ -241,6 +267,40 @@ function resizeSegment( }; } +/** + * Build the standard segment-length NumberControl for any element with p1/p2 + * endpoints. Returns the control node and a refresh function that syncs the + * displayed value after an external drag changes the geometry. + */ +function buildSegmentLengthControl( + element: { p1: { x: number; y: number }; p2: { x: number; y: number } }, + label: string | ReadOnlyProperty, + triggerRebuild: () => void, +): { control: Node; refresh: () => void } { + const L_RANGE = new Range(SEGMENT_LENGTH_MIN, SEGMENT_LENGTH_MAX); + const lenProp = new NumberProperty(safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0), { + range: L_RANGE, + tandem: Tandem.OPT_OUT, + }); + let lenDriving = false; + lenProp.lazyLink((v) => { + lenDriving = true; + const resized = resizeSegment(element.p1, element.p2, v); + element.p1 = resized.p1; + element.p2 = resized.p2; + triggerRebuild(); + lenDriving = false; + }); + const control = new NumberControl(label, lenProp, L_RANGE, numberControlOptions(0.05, 2)); + const refresh = (): void => { + if (lenDriving) { + return; + } + lenProp.value = safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0); + }; + return { control, refresh }; +} + // ── EditContainerNode ──────────────────────────────────────────────────────── export class EditContainerNode extends Node { @@ -439,29 +499,12 @@ export class EditContainerNode extends Node { ), ); } else if (element instanceof BeamSource) { - const L_RANGE = new Range(SEGMENT_LENGTH_MIN, SEGMENT_LENGTH_MAX); - const lenProp = new NumberProperty( - safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0), - { - range: L_RANGE, - tandem: Tandem.OPT_OUT, - }, + const { control: heightControl, refresh } = buildSegmentLengthControl( + element, + ctrl.heightStringProperty, + triggerRebuild, ); - let lenDriving = false; - lenProp.lazyLink((v) => { - lenDriving = true; - const resized = resizeSegment(element.p1, element.p2, v); - element.p1 = resized.p1; - element.p2 = resized.p2; - triggerRebuild(); - lenDriving = false; - }); - this._refreshCallback = () => { - if (lenDriving) { - return; - } - lenProp.value = safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0); - }; + this._refreshCallback = refresh; controls.push( makeControl( ctrl.brightnessStringProperty, @@ -491,25 +534,7 @@ export class EditContainerNode extends Node { }, triggerRebuild, ), - new NumberControl(ctrl.heightStringProperty, lenProp, L_RANGE, { - delta: 0.05, - includeArrowButtons: false, - soundGenerator: null, - layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), - titleNodeOptions: { fill: OpticsLabColors.overlayLabelFillProperty, font: LABEL_FONT }, - numberDisplayOptions: { - decimalPlaces: 2, - textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, - backgroundFill: "rgba(0,0,0,0.35)", - backgroundStroke: "rgba(100,100,120,0.6)", - }, - sliderOptions: { - trackSize: SLIDER_TRACK_SIZE, - thumbSize: SLIDER_THUMB_SIZE, - tandem: Tandem.OPT_OUT, - }, - tandem: Tandem.OPT_OUT, - }), + heightControl, ); } else if (element instanceof SingleRaySource) { controls.push( @@ -600,48 +625,10 @@ export class EditContainerNode extends Node { const r2Label = isRIP ? ctrl.r2RightRIPStringProperty : ctrl.r2RightSurfaceStringProperty; - const curvatureControlOptions = { - delta: 0.1, - includeArrowButtons: false, - soundGenerator: null, - layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), - titleNodeOptions: { fill: OpticsLabColors.overlayLabelFillProperty, font: LABEL_FONT }, - numberDisplayOptions: { - decimalPlaces: 1, - textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, - backgroundFill: "rgba(0,0,0,0.35)", - backgroundStroke: "rgba(100,100,120,0.6)", - }, - sliderOptions: { - trackSize: SLIDER_TRACK_SIZE, - thumbSize: SLIDER_THUMB_SIZE, - tandem: Tandem.OPT_OUT, - }, - tandem: Tandem.OPT_OUT, - } as const; - controls.push( - new NumberControl(ctrl.r1LeftSurfaceStringProperty, r1Prop, R_RANGE, curvatureControlOptions), - new NumberControl(r2Label, r2Prop, R_RANGE, curvatureControlOptions), - new NumberControl(ctrl.lengthStringProperty, lenProp, L_RANGE, { - delta: 0.05, - includeArrowButtons: false, - soundGenerator: null, - layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), - titleNodeOptions: { fill: OpticsLabColors.overlayLabelFillProperty, font: LABEL_FONT }, - numberDisplayOptions: { - decimalPlaces: 2, - textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, - backgroundFill: "rgba(0,0,0,0.35)", - backgroundStroke: "rgba(100,100,120,0.6)", - }, - sliderOptions: { - trackSize: SLIDER_TRACK_SIZE, - thumbSize: SLIDER_THUMB_SIZE, - tandem: Tandem.OPT_OUT, - }, - tandem: Tandem.OPT_OUT, - }), + new NumberControl(ctrl.r1LeftSurfaceStringProperty, r1Prop, R_RANGE, numberControlOptions(0.1, 1)), + new NumberControl(r2Label, r2Prop, R_RANGE, numberControlOptions(0.1, 1)), + new NumberControl(ctrl.lengthStringProperty, lenProp, L_RANGE, numberControlOptions(0.05, 2)), makeControl( ctrl.refractiveIndexStringProperty, element.refIndex, @@ -654,29 +641,12 @@ export class EditContainerNode extends Node { ), ); } else if (element instanceof IdealLens) { - const L_RANGE = new Range(SEGMENT_LENGTH_MIN, SEGMENT_LENGTH_MAX); - const lenProp = new NumberProperty( - safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0), - { - range: L_RANGE, - tandem: Tandem.OPT_OUT, - }, + const { control: lenControl, refresh } = buildSegmentLengthControl( + element, + ctrl.lengthStringProperty, + triggerRebuild, ); - let lenDriving = false; - lenProp.lazyLink((v) => { - lenDriving = true; - const resized = resizeSegment(element.p1, element.p2, v); - element.p1 = resized.p1; - element.p2 = resized.p2; - triggerRebuild(); - lenDriving = false; - }); - this._refreshCallback = () => { - if (lenDriving) { - return; - } - lenProp.value = safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0); - }; + this._refreshCallback = refresh; controls.push( makeControl( ctrl.focalLengthStringProperty, @@ -688,25 +658,7 @@ export class EditContainerNode extends Node { }, triggerRebuild, ), - new NumberControl(ctrl.lengthStringProperty, lenProp, L_RANGE, { - delta: 0.05, - includeArrowButtons: false, - soundGenerator: null, - layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), - titleNodeOptions: { fill: OpticsLabColors.overlayLabelFillProperty, font: LABEL_FONT }, - numberDisplayOptions: { - decimalPlaces: 2, - textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, - backgroundFill: "rgba(0,0,0,0.35)", - backgroundStroke: "rgba(100,100,120,0.6)", - }, - sliderOptions: { - trackSize: SLIDER_TRACK_SIZE, - thumbSize: SLIDER_THUMB_SIZE, - tandem: Tandem.OPT_OUT, - }, - tandem: Tandem.OPT_OUT, - }), + lenControl, ); } else if (element instanceof CircleGlass) { controls.push( @@ -766,50 +718,15 @@ export class EditContainerNode extends Node { radiusProp.value = r; }; controls.push( - new NumberControl(ctrl.radiusOfCurvatureStringProperty, radiusProp, R_RANGE, { - delta: 0.1, - includeArrowButtons: false, - soundGenerator: null, - layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), - titleNodeOptions: { fill: OpticsLabColors.overlayLabelFillProperty, font: LABEL_FONT }, - numberDisplayOptions: { - decimalPlaces: 1, - textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, - backgroundFill: "rgba(0,0,0,0.35)", - backgroundStroke: "rgba(100,100,120,0.6)", - }, - sliderOptions: { - trackSize: SLIDER_TRACK_SIZE, - thumbSize: SLIDER_THUMB_SIZE, - tandem: Tandem.OPT_OUT, - }, - tandem: Tandem.OPT_OUT, - }), + new NumberControl(ctrl.radiusOfCurvatureStringProperty, radiusProp, R_RANGE, numberControlOptions(0.1, 1)), ); } else if (element instanceof IdealCurvedMirror) { - const L_RANGE = new Range(SEGMENT_LENGTH_MIN, SEGMENT_LENGTH_MAX); - const lenProp = new NumberProperty( - safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0), - { - range: L_RANGE, - tandem: Tandem.OPT_OUT, - }, + const { control: lenControl, refresh } = buildSegmentLengthControl( + element, + ctrl.lengthStringProperty, + triggerRebuild, ); - let lenDriving = false; - lenProp.lazyLink((v) => { - lenDriving = true; - const resized = resizeSegment(element.p1, element.p2, v); - element.p1 = resized.p1; - element.p2 = resized.p2; - triggerRebuild(); - lenDriving = false; - }); - this._refreshCallback = () => { - if (lenDriving) { - return; - } - lenProp.value = safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0); - }; + this._refreshCallback = refresh; controls.push( makeControl( ctrl.focalLengthStringProperty, @@ -821,92 +738,23 @@ export class EditContainerNode extends Node { }, triggerRebuild, ), - new NumberControl(ctrl.lengthStringProperty, lenProp, L_RANGE, { - delta: 0.05, - includeArrowButtons: false, - soundGenerator: null, - layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), - titleNodeOptions: { fill: OpticsLabColors.overlayLabelFillProperty, font: LABEL_FONT }, - numberDisplayOptions: { - decimalPlaces: 2, - textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, - backgroundFill: "rgba(0,0,0,0.35)", - backgroundStroke: "rgba(100,100,120,0.6)", - }, - sliderOptions: { - trackSize: SLIDER_TRACK_SIZE, - thumbSize: SLIDER_THUMB_SIZE, - tandem: Tandem.OPT_OUT, - }, - tandem: Tandem.OPT_OUT, - }), + lenControl, ); } else if (element instanceof SegmentMirror || element instanceof LineBlocker) { - const L_RANGE = new Range(SEGMENT_LENGTH_MIN, SEGMENT_LENGTH_MAX); - const lenProp = new NumberProperty( - safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0), - { - range: L_RANGE, - tandem: Tandem.OPT_OUT, - }, - ); - let lenDriving = false; - lenProp.lazyLink((v) => { - lenDriving = true; - const resized = resizeSegment(element.p1, element.p2, v); - element.p1 = resized.p1; - element.p2 = resized.p2; - triggerRebuild(); - lenDriving = false; - }); - this._refreshCallback = () => { - if (lenDriving) { - return; - } - lenProp.value = safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0); - }; - controls.push( - new NumberControl(ctrl.lengthStringProperty, lenProp, L_RANGE, { - delta: 0.05, - includeArrowButtons: false, - soundGenerator: null, - layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), - titleNodeOptions: { fill: OpticsLabColors.overlayLabelFillProperty, font: LABEL_FONT }, - numberDisplayOptions: { - decimalPlaces: 2, - textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, - backgroundFill: "rgba(0,0,0,0.35)", - backgroundStroke: "rgba(100,100,120,0.6)", - }, - sliderOptions: { - trackSize: SLIDER_TRACK_SIZE, - thumbSize: SLIDER_THUMB_SIZE, - tandem: Tandem.OPT_OUT, - }, - tandem: Tandem.OPT_OUT, - }), + const { control: lenControl, refresh } = buildSegmentLengthControl( + element, + ctrl.lengthStringProperty, + triggerRebuild, ); + this._refreshCallback = refresh; + controls.push(lenControl); } else if (element instanceof TransmissionGrating || element instanceof ReflectionGrating) { - const L_RANGE = new Range(SEGMENT_LENGTH_MIN, SEGMENT_LENGTH_MAX); - const lenProp = new NumberProperty( - safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0), - { range: L_RANGE, tandem: Tandem.OPT_OUT }, + const { control: lenControl, refresh } = buildSegmentLengthControl( + element, + ctrl.lengthStringProperty, + triggerRebuild, ); - let lenDriving = false; - lenProp.lazyLink((v) => { - lenDriving = true; - const resized = resizeSegment(element.p1, element.p2, v); - element.p1 = resized.p1; - element.p2 = resized.p2; - triggerRebuild(); - lenDriving = false; - }); - this._refreshCallback = () => { - if (lenDriving) { - return; - } - lenProp.value = safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0); - }; + this._refreshCallback = refresh; controls.push( makeControl( ctrl.linesDensityStringProperty, @@ -928,25 +776,7 @@ export class EditContainerNode extends Node { }, triggerRebuild, ), - new NumberControl(ctrl.lengthStringProperty, lenProp, L_RANGE, { - delta: 0.05, - includeArrowButtons: false, - soundGenerator: null, - layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), - titleNodeOptions: { fill: OpticsLabColors.overlayLabelFillProperty, font: LABEL_FONT }, - numberDisplayOptions: { - decimalPlaces: 2, - textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, - backgroundFill: "rgba(0,0,0,0.35)", - backgroundStroke: "rgba(100,100,120,0.6)", - }, - sliderOptions: { - trackSize: SLIDER_TRACK_SIZE, - thumbSize: SLIDER_THUMB_SIZE, - tandem: Tandem.OPT_OUT, - }, - tandem: Tandem.OPT_OUT, - }), + lenControl, ); } else if (element instanceof BeamSplitterElement) { controls.push( diff --git a/src/common/view/blockers/ApertureView.ts b/src/common/view/blockers/ApertureView.ts index 1816cd7..53d7084 100644 --- a/src/common/view/blockers/ApertureView.ts +++ b/src/common/view/blockers/ApertureView.ts @@ -7,16 +7,17 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import OpticsLabColors from "../../../OpticsLabColors.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { ApertureElement } from "../../model/blockers/ApertureElement.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; const BACK_WIDTH = 5; const FRONT_WIDTH = 2.5; -export class ApertureView extends Node { +export class ApertureView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; private readonly backPath: Path; private readonly frontPath: Path; @@ -134,7 +135,7 @@ export class ApertureView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2, p3, p4 } = this.aperture; const vx1 = this.modelViewTransform.modelToViewX(p1.x); const vy1 = this.modelViewTransform.modelToViewY(p1.y); @@ -156,6 +157,7 @@ export class ApertureView extends Node { this.handle3.y = vy3; this.handle4.x = vx4; this.handle4.y = vy4; + this.onRebuild?.(); } } diff --git a/src/common/view/blockers/CircleBlockerView.ts b/src/common/view/blockers/CircleBlockerView.ts index a17391f..23395c7 100644 --- a/src/common/view/blockers/CircleBlockerView.ts +++ b/src/common/view/blockers/CircleBlockerView.ts @@ -7,17 +7,18 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import { GLASS_STROKE_WIDTH } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { CircleBlocker } from "../../model/blockers/CircleBlocker.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; const BLOCKER_FILL = "rgba(30, 30, 30, 0.5)"; const BLOCKER_STROKE = "#555"; const BLOCKER_STROKE_WIDTH = GLASS_STROKE_WIDTH; -export class CircleBlockerView extends Node { +export class CircleBlockerView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; private readonly circlePath: Path; private readonly handleCenter: Circle; @@ -92,7 +93,7 @@ export class CircleBlockerView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2 } = this.blocker; const modelRadius = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); const vcx = this.modelViewTransform.modelToViewX(p1.x); @@ -103,6 +104,7 @@ export class CircleBlockerView extends Node { this.handleCenter.y = vcy; this.handleBoundary.x = this.modelViewTransform.modelToViewX(p2.x); this.handleBoundary.y = this.modelViewTransform.modelToViewY(p2.y); + this.onRebuild?.(); } } diff --git a/src/common/view/blockers/LineBlockerView.ts b/src/common/view/blockers/LineBlockerView.ts index 5ac71e1..5a06938 100644 --- a/src/common/view/blockers/LineBlockerView.ts +++ b/src/common/view/blockers/LineBlockerView.ts @@ -7,11 +7,12 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { MIRROR_BACK_WIDTH, MIRROR_FRONT_WIDTH } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { LineBlocker } from "../../model/blockers/LineBlocker.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, @@ -20,9 +21,8 @@ import { createLineBodyHitPath, } from "../ViewHelpers.js"; -export class LineBlockerView extends Node { +export class LineBlockerView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; - public onRebuild: (() => void) | null = null; private readonly backPath: Path; private readonly frontPath: Path; private readonly bodyHitPath: Path; @@ -104,7 +104,7 @@ export class LineBlockerView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2 } = this.blocker; const vx1 = this.modelViewTransform.modelToViewX(p1.x); const vy1 = this.modelViewTransform.modelToViewY(p1.y); diff --git a/src/common/view/glass/CircleGlassView.ts b/src/common/view/glass/CircleGlassView.ts index d4348c5..998d42e 100644 --- a/src/common/view/glass/CircleGlassView.ts +++ b/src/common/view/glass/CircleGlassView.ts @@ -8,17 +8,18 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import { GLASS_STROKE_WIDTH } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { CircleGlass } from "../../model/glass/CircleGlass.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; // ── Styling constants ───────────────────────────────────────────────────────── const GLASS_FILL = "rgba(100, 180, 255, 0.22)"; const GLASS_STROKE = "rgba(60, 130, 210, 0.8)"; -export class CircleGlassView extends Node { +export class CircleGlassView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; private readonly circlePath: Path; private readonly handleCenter: Circle; @@ -96,7 +97,7 @@ export class CircleGlassView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2 } = this.glass; const modelRadius = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); const vcx = this.modelViewTransform.modelToViewX(p1.x); @@ -107,6 +108,7 @@ export class CircleGlassView extends Node { this.handleCenter.y = vcy; this.handleBoundary.x = this.modelViewTransform.modelToViewX(p2.x); this.handleBoundary.y = this.modelViewTransform.modelToViewY(p2.y); + this.onRebuild?.(); } } diff --git a/src/common/view/glass/GlassView.ts b/src/common/view/glass/GlassView.ts index f9ef9dc..49617dd 100644 --- a/src/common/view/glass/GlassView.ts +++ b/src/common/view/glass/GlassView.ts @@ -14,6 +14,7 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; import { Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { GLASS_STROKE_WIDTH, @@ -33,7 +34,7 @@ import { } from "../../model/optics/Geometry.js"; import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; -export class GlassView extends Node { +export class GlassView extends BaseOpticalElementView { private _bodyDragListener!: RichDragListener; private readonly glassPath: Path; private readonly handlesContainer: Node; @@ -217,7 +218,7 @@ export class GlassView extends Node { }); } - protected rebuild(): void { + protected override rebuild(): void { const pathPoints = this.glass.path; const n = pathPoints.length; diff --git a/src/common/view/glass/HalfPlaneGlassView.ts b/src/common/view/glass/HalfPlaneGlassView.ts index c53ac9d..3b30f5b 100644 --- a/src/common/view/glass/HalfPlaneGlassView.ts +++ b/src/common/view/glass/HalfPlaneGlassView.ts @@ -11,10 +11,11 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import { HALF_PLANE_BORDER_WIDTH } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { HalfPlaneGlass } from "../../model/glass/HalfPlaneGlass.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, @@ -42,7 +43,7 @@ const LINE_EXTEND_PX = 5000; // How far (px) the glass-side fill extends from the boundary const GLASS_DEPTH_PX = 5000; -export class HalfPlaneGlassView extends Node { +export class HalfPlaneGlassView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; private readonly glassPath: Path; private readonly borderPath: Path; @@ -124,7 +125,7 @@ export class HalfPlaneGlassView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { // Update fill opacity to reflect current refractive index this.glassPath.fill = glassFill(this.glass.refIndex); @@ -187,6 +188,7 @@ export class HalfPlaneGlassView extends Node { .lineTo(ex2 + nlvx * GLASS_DEPTH_PX, ey2 + nlvy * GLASS_DEPTH_PX) .lineTo(ex1 + nlvx * GLASS_DEPTH_PX, ey1 + nlvy * GLASS_DEPTH_PX) .close(); + this.onRebuild?.(); } } diff --git a/src/common/view/glass/IdealLensView.ts b/src/common/view/glass/IdealLensView.ts index 3178971..ef24af7 100644 --- a/src/common/view/glass/IdealLensView.ts +++ b/src/common/view/glass/IdealLensView.ts @@ -9,7 +9,7 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { IDEAL_LENS_ARROW_ARM_FACTOR, @@ -20,6 +20,7 @@ import { } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { IdealLens } from "../../model/glass/IdealLens.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, @@ -28,9 +29,8 @@ import { createLineBodyHitPath, } from "../ViewHelpers.js"; -export class IdealLensView extends Node { +export class IdealLensView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; - public onRebuild: (() => void) | null = null; private readonly linePath: Path; private readonly arrowPath: Path; private readonly bodyHitPath: Path; @@ -118,7 +118,7 @@ export class IdealLensView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2, focalLength } = this.lens; const dx = p2.x - p1.x; const dy = p2.y - p1.y; diff --git a/src/common/view/glass/SphericalLensView.ts b/src/common/view/glass/SphericalLensView.ts index d553a89..049ac50 100644 --- a/src/common/view/glass/SphericalLensView.ts +++ b/src/common/view/glass/SphericalLensView.ts @@ -49,8 +49,6 @@ const CORNER_BOTTOM_LEFT = 3; // path[4] const ROTATION_CORNER = CORNER_TOP_RIGHT; export class SphericalLensView extends GlassView { - /** Called after every geometry rebuild (drag or programmatic). Allows external observers to sync UI. */ - public onRebuild: (() => void) | null = null; private readonly focalFront: Path; private readonly focalBack: Path; diff --git a/src/common/view/gratings/ReflectionGratingView.ts b/src/common/view/gratings/ReflectionGratingView.ts index d0b3c92..1588072 100644 --- a/src/common/view/gratings/ReflectionGratingView.ts +++ b/src/common/view/gratings/ReflectionGratingView.ts @@ -7,11 +7,12 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { MIRROR_BACK_WIDTH, MIRROR_FRONT_WIDTH } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { ReflectionGrating } from "../../model/gratings/ReflectionGrating.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, @@ -25,9 +26,8 @@ const GROOVE_COUNT = 14; /** Length of each groove hatch mark in pixels. */ const GROOVE_LENGTH_PX = 6; -export class ReflectionGratingView extends Node { +export class ReflectionGratingView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; - public onRebuild: (() => void) | null = null; private readonly backPath: Path; private readonly frontPath: Path; @@ -117,7 +117,7 @@ export class ReflectionGratingView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2 } = this.grating; const vx1 = this.modelViewTransform.modelToViewX(p1.x); const vy1 = this.modelViewTransform.modelToViewY(p1.y); diff --git a/src/common/view/gratings/TransmissionGratingView.ts b/src/common/view/gratings/TransmissionGratingView.ts index 15cbc67..64695c8 100644 --- a/src/common/view/gratings/TransmissionGratingView.ts +++ b/src/common/view/gratings/TransmissionGratingView.ts @@ -7,11 +7,12 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { GLASS_STROKE_WIDTH } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { TransmissionGrating } from "../../model/gratings/TransmissionGrating.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, @@ -25,9 +26,8 @@ const TICK_COUNT = 12; /** Half-length of each tick mark in pixels. */ const TICK_HALF_PX = 4; -export class TransmissionGratingView extends Node { +export class TransmissionGratingView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; - public onRebuild: (() => void) | null = null; private readonly bodyPath: Path; private readonly tickPath: Path; @@ -109,7 +109,7 @@ export class TransmissionGratingView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2 } = this.grating; const vx1 = this.modelViewTransform.modelToViewX(p1.x); const vy1 = this.modelViewTransform.modelToViewY(p1.y); diff --git a/src/common/view/light-sources/ArcLightSourceView.ts b/src/common/view/light-sources/ArcLightSourceView.ts index 5214611..af79d71 100644 --- a/src/common/view/light-sources/ArcLightSourceView.ts +++ b/src/common/view/light-sources/ArcLightSourceView.ts @@ -9,7 +9,7 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, RichDragListener, type RichDragListenerOptions } from "scenerystack/scenery"; +import { type Circle, Path, RichDragListener, type RichDragListenerOptions } from "scenerystack/scenery"; import { Tandem } from "scenerystack/tandem"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { @@ -29,6 +29,7 @@ import { } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { ArcLightSource } from "../../model/light-sources/ArcLightSource.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachTranslationDrag, createHandle } from "../ViewHelpers.js"; // ── Helper: build an arc (polyline) by sampling in model space ─────────────── @@ -79,7 +80,7 @@ function attachCircleDrag( // ── View class ──────────────────────────────────────────────────────────────── -export class ArcLightSourceView extends Node { +export class ArcLightSourceView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; private readonly glowPath: Path; @@ -195,7 +196,7 @@ export class ArcLightSourceView extends Node { }; } - public rebuild(): void { + protected override rebuild(): void { const modelViewTransform = this.modelViewTransform; const { position: { x, y }, @@ -276,6 +277,7 @@ export class ArcLightSourceView extends Node { const sp = this.spreadHandlePos(); this.spreadHandle.x = modelViewTransform.modelToViewX(sp.x); this.spreadHandle.y = modelViewTransform.modelToViewY(sp.y); + this.onRebuild?.(); } } diff --git a/src/common/view/light-sources/BeamSourceView.ts b/src/common/view/light-sources/BeamSourceView.ts index fb66e7a..fb554f2 100644 --- a/src/common/view/light-sources/BeamSourceView.ts +++ b/src/common/view/light-sources/BeamSourceView.ts @@ -5,11 +5,12 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import { VisibleColor } from "scenerystack/scenery-phet"; import { BEAM_SOURCE_BEAM_WIDTH, BEAM_SOURCE_SHIELD_WIDTH } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { BeamSource } from "../../model/light-sources/BeamSource.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, @@ -18,9 +19,8 @@ import { createLineBodyHitPath, } from "../ViewHelpers.js"; -export class BeamSourceView extends Node { +export class BeamSourceView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; - public onRebuild: (() => void) | null = null; private readonly shieldPath: Path; private readonly beamPath: Path; private readonly bodyHitPath: Path; @@ -98,7 +98,7 @@ export class BeamSourceView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const modelViewTransform = this.modelViewTransform; const { p1, p2 } = this.source; diff --git a/src/common/view/light-sources/ContinuousSpectrumSourceView.ts b/src/common/view/light-sources/ContinuousSpectrumSourceView.ts index 9fb6b3a..4487e7a 100644 --- a/src/common/view/light-sources/ContinuousSpectrumSourceView.ts +++ b/src/common/view/light-sources/ContinuousSpectrumSourceView.ts @@ -10,7 +10,7 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import { VisibleColor } from "scenerystack/scenery-phet"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { @@ -24,9 +24,10 @@ import { } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { ContinuousSpectrumSource } from "../../model/light-sources/ContinuousSpectrumSource.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; -export class ContinuousSpectrumSourceView extends Node { +export class ContinuousSpectrumSourceView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; private readonly rainbowArcs: Path[]; @@ -144,7 +145,7 @@ export class ContinuousSpectrumSourceView extends Node { return { x: -d.y, y: d.x }; } - public rebuild(): void { + protected override rebuild(): void { const mvt = this.modelViewTransform; const { p1, p2 } = this.source; @@ -182,6 +183,7 @@ export class ContinuousSpectrumSourceView extends Node { this.handleDirection.x = vx2; this.handleDirection.y = vy2; + this.onRebuild?.(); } } diff --git a/src/common/view/light-sources/PointSourceView.ts b/src/common/view/light-sources/PointSourceView.ts index 19dcafe..3911bdf 100644 --- a/src/common/view/light-sources/PointSourceView.ts +++ b/src/common/view/light-sources/PointSourceView.ts @@ -5,7 +5,7 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { Path, type RichDragListener } from "scenerystack/scenery"; import { VisibleColor } from "scenerystack/scenery-phet"; import { POINT_SOURCE_GLOW_RADIUS_PX, @@ -16,6 +16,7 @@ import { } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { PointSourceElement } from "../../model/light-sources/PointSourceElement.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachTranslationDrag } from "../ViewHelpers.js"; function wavelengthToRgb(nm: number): { r: number; g: number; b: number } { @@ -23,7 +24,7 @@ function wavelengthToRgb(nm: number): { r: number; g: number; b: number } { return { r: c.r, g: c.g, b: c.b }; } -export class PointSourceView extends Node { +export class PointSourceView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; private readonly glowPath: Path; private readonly spokePath: Path; @@ -62,7 +63,7 @@ export class PointSourceView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const modelViewTransform = this.modelViewTransform; const { x, y } = this.source.position; const { brightness, wavelength } = this.source; @@ -96,6 +97,7 @@ export class PointSourceView extends Node { spokeShape.lineTo(modelViewTransform.modelToViewX(outerMx), modelViewTransform.modelToViewY(outerMy)); } this.spokePath.shape = spokeShape; + this.onRebuild?.(); } } diff --git a/src/common/view/light-sources/SingleRaySourceView.ts b/src/common/view/light-sources/SingleRaySourceView.ts index 5ff832f..62fba0c 100644 --- a/src/common/view/light-sources/SingleRaySourceView.ts +++ b/src/common/view/light-sources/SingleRaySourceView.ts @@ -5,7 +5,7 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import { VisibleColor } from "scenerystack/scenery-phet"; import { SINGLE_RAY_ARROW_ARM_FACTOR, @@ -17,9 +17,10 @@ import { } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { SingleRaySource } from "../../model/light-sources/SingleRaySource.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; -export class SingleRaySourceView extends Node { +export class SingleRaySourceView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; private readonly originPath: Path; private readonly dirPath: Path; @@ -96,7 +97,7 @@ export class SingleRaySourceView extends Node { return { x: -d.y, y: d.x }; } - private rebuild(): void { + protected override rebuild(): void { const modelViewTransform = this.modelViewTransform; const { p1, p2 } = this.source; @@ -140,6 +141,7 @@ export class SingleRaySourceView extends Node { this.handleDirection.x = vx2; this.handleDirection.y = vy2; + this.onRebuild?.(); } } diff --git a/src/common/view/mirrors/ArcMirrorView.ts b/src/common/view/mirrors/ArcMirrorView.ts index d05c25b..5d81c35 100644 --- a/src/common/view/mirrors/ArcMirrorView.ts +++ b/src/common/view/mirrors/ArcMirrorView.ts @@ -8,7 +8,7 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { ARC_MIRROR_SAMPLE_COUNT, @@ -18,7 +18,8 @@ import { } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { ArcMirror } from "../../model/mirrors/ArcMirror.js"; -import type { Point } from "../../model/optics/Geometry.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; +import { circumcenter, type Point, sampleArcPoints } from "../../model/optics/Geometry.js"; import { attachCurvatureHandleDrag, attachEndpointDrag, @@ -27,77 +28,6 @@ import { projectPointOntoPerpendicularBisector, } from "../ViewHelpers.js"; -/** - * Compute the circumcenter of triangle (p1, p2, p3). - * Returns null if the three points are collinear. - * All coordinates are in model space. - */ -function circumcenter(p1: Point, p2: Point, p3: Point): { center: Point; radius: number } | null { - const ax = p1.x; - const ay = p1.y; - const bx = p2.x; - const by = p2.y; - const cx = p3.x; - const cy = p3.y; - - const D = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); - if (Math.abs(D) < 1e-10) { - return null; // collinear - } - - const a2 = ax * ax + ay * ay; - const b2 = bx * bx + by * by; - const c2 = cx * cx + cy * cy; - - const ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / D; - const uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / D; - - const center = { x: ux, y: uy }; - const radius = Math.sqrt((ax - ux) ** 2 + (ay - uy) ** 2); - return { center, radius }; -} - -/** - * Sample points along the circular arc from p1 to p2 passing through p3. - * Returns model-space points. - */ -function sampleArcPoints(p1: Point, p2: Point, p3: Point, n: number): Point[] { - const geo = circumcenter(p1, p2, p3); - if (!geo) { - // Collinear: draw a straight line - const pts: Point[] = []; - for (let i = 0; i <= n; i++) { - const t = i / n; - pts.push({ x: p1.x + (p2.x - p1.x) * t, y: p1.y + (p2.y - p1.y) * t }); - } - return pts; - } - - const { center, radius } = geo; - const norm = (a: number): number => ((a % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); - - const a1 = norm(Math.atan2(p1.y - center.y, p1.x - center.x)); - const a2 = norm(Math.atan2(p2.y - center.y, p2.x - center.x)); - const a3 = norm(Math.atan2(p3.y - center.y, p3.x - center.x)); - - // CCW sweep from a1 to a2; check if a3 is within this sweep - const ccwSweep12 = norm(a2 - a1); - const ccwDist13 = norm(a3 - a1); - - // If a3 is within the CCW sweep from a1 to a2, go CCW; otherwise go CW - const sweepAngle = ccwDist13 < ccwSweep12 ? ccwSweep12 : -(2 * Math.PI - ccwSweep12); - - const pts: Point[] = []; - for (let i = 0; i <= n; i++) { - const angle = a1 + sweepAngle * (i / n); - pts.push({ - x: center.x + radius * Math.cos(angle), - y: center.y + radius * Math.sin(angle), - }); - } - return pts; -} - /** * Build a view-space polyline Shape from model-space points, converting via modelViewTransform. */ @@ -117,10 +47,8 @@ function buildViewShape(pts: Point[], modelViewTransform: ModelViewTransform2): return shape; } -export class ArcMirrorView extends Node { +export class ArcMirrorView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; - /** Called after every geometry rebuild (drag or programmatic). Allows external observers to sync UI. */ - public onRebuild: (() => void) | null = null; private readonly backPath: Path; private readonly frontPath: Path; private readonly focalMarker: Path; @@ -226,7 +154,7 @@ export class ArcMirrorView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2 } = this.mirror; // Keep curvature handle at the vertex (on perpendicular bisector of chord) this.mirror.p3 = projectPointOntoPerpendicularBisector(this.mirror.p3, p1, p2); diff --git a/src/common/view/mirrors/BeamSplitterView.ts b/src/common/view/mirrors/BeamSplitterView.ts index 03f0609..905f572 100644 --- a/src/common/view/mirrors/BeamSplitterView.ts +++ b/src/common/view/mirrors/BeamSplitterView.ts @@ -8,11 +8,12 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { MIRROR_BACK_WIDTH, MIRROR_FRONT_WIDTH } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { BeamSplitterElement } from "../../model/mirrors/BeamSplitterElement.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, @@ -21,7 +22,7 @@ import { createLineBodyHitPath, } from "../ViewHelpers.js"; -export class BeamSplitterView extends Node { +export class BeamSplitterView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; private readonly backPath: Path; private readonly frontPath: Path; @@ -104,7 +105,7 @@ export class BeamSplitterView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2 } = this.splitter; const vx1 = this.modelViewTransform.modelToViewX(p1.x); const vy1 = this.modelViewTransform.modelToViewY(p1.y); @@ -118,6 +119,7 @@ export class BeamSplitterView extends Node { this.handle1.y = vy1; this.handle2.x = vx2; this.handle2.y = vy2; + this.onRebuild?.(); } } diff --git a/src/common/view/mirrors/IdealCurvedMirrorView.ts b/src/common/view/mirrors/IdealCurvedMirrorView.ts index 93fbec1..70c4399 100644 --- a/src/common/view/mirrors/IdealCurvedMirrorView.ts +++ b/src/common/view/mirrors/IdealCurvedMirrorView.ts @@ -9,7 +9,7 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { IDEAL_MIRROR_LINE_WIDTH, @@ -20,6 +20,7 @@ import { } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { IdealCurvedMirror } from "../../model/mirrors/IdealCurvedMirror.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, @@ -28,9 +29,8 @@ import { createLineBodyHitPath, } from "../ViewHelpers.js"; -export class IdealCurvedMirrorView extends Node { +export class IdealCurvedMirrorView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; - public onRebuild: (() => void) | null = null; private readonly linePath: Path; private readonly tickPath: Path; private readonly bodyHitPath: Path; @@ -118,7 +118,7 @@ export class IdealCurvedMirrorView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2 } = this.mirror; const dx = p2.x - p1.x; const dy = p2.y - p1.y; diff --git a/src/common/view/mirrors/ParabolicMirrorView.ts b/src/common/view/mirrors/ParabolicMirrorView.ts index d82991f..afa399a 100644 --- a/src/common/view/mirrors/ParabolicMirrorView.ts +++ b/src/common/view/mirrors/ParabolicMirrorView.ts @@ -8,7 +8,7 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { MIRROR_BACK_WIDTH, @@ -18,6 +18,7 @@ import { } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { ParabolicMirror } from "../../model/mirrors/ParabolicMirror.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import type { Point } from "../../model/optics/Geometry.js"; import { attachCurvatureHandleDrag, @@ -81,7 +82,7 @@ function buildViewShape(pts: Point[], modelViewTransform: ModelViewTransform2): return shape; } -export class ParabolicMirrorView extends Node { +export class ParabolicMirrorView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; private readonly backPath: Path; private readonly frontPath: Path; @@ -182,7 +183,7 @@ export class ParabolicMirrorView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2 } = this.mirror; // Keep curvature handle at the vertex (on perpendicular bisector of chord) this.mirror.p3 = projectPointOntoPerpendicularBisector(this.mirror.p3, p1, p2); @@ -235,6 +236,7 @@ export class ParabolicMirrorView extends Node { } else { this.focalMarker.shape = null; } + this.onRebuild?.(); } } diff --git a/src/common/view/mirrors/SegmentMirrorView.ts b/src/common/view/mirrors/SegmentMirrorView.ts index 1ad6ec8..6c35ad7 100644 --- a/src/common/view/mirrors/SegmentMirrorView.ts +++ b/src/common/view/mirrors/SegmentMirrorView.ts @@ -1,10 +1,11 @@ import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { type Circle, Node, Path, type RichDragListener } from "scenerystack/scenery"; +import { type Circle, Path, type RichDragListener } from "scenerystack/scenery"; import OpticsLabColors from "../../../OpticsLabColors.js"; import { MIRROR_BACK_WIDTH, MIRROR_FRONT_WIDTH } from "../../../OpticsLabConstants.js"; import opticsLab from "../../../OpticsLabNamespace.js"; import type { SegmentMirror } from "../../model/mirrors/SegmentMirror.js"; +import { BaseOpticalElementView } from "../BaseOpticalElementView.js"; import { attachEndpointDrag, attachTranslationDrag, @@ -13,9 +14,8 @@ import { createLineBodyHitPath, } from "../ViewHelpers.js"; -export class SegmentMirrorView extends Node { +export class SegmentMirrorView extends BaseOpticalElementView { public readonly bodyDragListener: RichDragListener; - public onRebuild: (() => void) | null = null; private readonly backPath: Path; private readonly frontPath: Path; private readonly bodyHitPath: Path; @@ -97,7 +97,7 @@ export class SegmentMirrorView extends Node { ); } - private rebuild(): void { + protected override rebuild(): void { const { p1, p2 } = this.mirror; const vx1 = this.modelViewTransform.modelToViewX(p1.x), vy1 = this.modelViewTransform.modelToViewY(p1.y);