diff --git a/package-lock.json b/package-lock.json index 84fdf39..38e55ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3208,7 +3207,6 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3246,7 +3244,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3471,7 +3468,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5247,7 +5243,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6143,7 +6138,6 @@ "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -6902,7 +6896,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -7284,7 +7277,6 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/src/rayTracing/model/SimModel.ts b/src/rayTracing/model/SimModel.ts index e58253d..07d4e05 100644 --- a/src/rayTracing/model/SimModel.ts +++ b/src/rayTracing/model/SimModel.ts @@ -1,8 +1,104 @@ import type { Tandem } from "scenerystack/tandem"; import opticsLab from "../../OpticsLabNamespace.js"; +import { CircleGlass } from "./glass/CircleGlass.js"; +import { HalfPlaneGlass } from "./glass/HalfPlaneGlass.js"; +import { IdealLens } from "./glass/IdealLens.js"; +import { PolygonGlass } from "./glass/PolygonGlass.js"; +import { SphericalLens } from "./glass/SphericalLens.js"; +import { ArcMirror } from "./mirrors/ArcMirror.js"; +import { BeamSplitterElement } from "./mirrors/BeamSplitterElement.js"; +import { IdealCurvedMirror } from "./mirrors/IdealCurvedMirror.js"; +import { ParabolicMirror } from "./mirrors/ParabolicMirror.js"; +import { SegmentMirror } from "./mirrors/SegmentMirror.js"; +import { OpticsScene } from "./optics/OpticsScene.js"; + +// ── Demo scene layout ───────────────────────────────────────────────────────── +// Screen dimensions: 1024 × 618 (typical SceneryStack ScreenView layout bounds). +// Mirrors on the left half, glass / lenses on the right half. + +function buildDemoScene(): OpticsScene { + const scene = new OpticsScene(); + + // ── Mirrors (left column) ───────────────────────────────────────────────── + + // 1. Flat segment mirror — horizontal, near top-left + scene.addElement(new SegmentMirror({ x: 80, y: 90 }, { x: 320, y: 90 })); + + // 2. Concave arc mirror — circular arc opening upward + scene.addElement( + new ArcMirror( + { x: 80, y: 210 }, + { x: 320, y: 210 }, + { x: 200, y: 160 }, // control point above midpoint → concave side up + ), + ); + + // 3. Parabolic mirror — parabola opening upward + scene.addElement( + new ParabolicMirror( + { x: 80, y: 330 }, + { x: 320, y: 330 }, + { x: 200, y: 275 }, // vertex above midpoint → concave side up + ), + ); + + // 4. Ideal curved mirror (obeys mirror equation exactly) + scene.addElement(new IdealCurvedMirror({ x: 80, y: 450 }, { x: 320, y: 450 }, /* focalLength= */ 80)); + + // 5. Beam splitter — diagonal line in the lower-left + scene.addElement(new BeamSplitterElement({ x: 100, y: 540 }, { x: 300, y: 580 }, /* transRatio= */ 0.5)); + + // ── Glass / Lenses (right half) ─────────────────────────────────────────── + + // 6. Circular glass element + scene.addElement( + new CircleGlass( + { x: 680, y: 120 }, // center + { x: 745, y: 120 }, // point on boundary (radius ≈ 65 px) + /* refIndex= */ 1.5, + ), + ); + + // 7. Ideal thin lens — vertical segment with converging arrows + scene.addElement(new IdealLens({ x: 860, y: 60 }, { x: 860, y: 200 }, /* focalLength= */ 120)); + + // 8. Half-plane glass — horizontal boundary with hatching below it + scene.addElement(new HalfPlaneGlass({ x: 560, y: 290 }, { x: 960, y: 290 }, /* refIndex= */ 1.5)); + + // 9. Spherical biconvex lens — two circular arc surfaces + // axisP1/axisP2 define the optical axis; their distance sets the aperture. + scene.addElement( + new SphericalLens( + { x: 680, y: 380 }, // axisP1 + { x: 680, y: 520 }, // axisP2 (axis runs vertically; aperture ≈ 140 px) + /* r1= */ 120, + /* r2= */ -120, + /* refIndex= */ 1.5, + ), + ); + + // 10. Triangular prism (polygon glass) + scene.addElement( + new PolygonGlass( + [ + { x: 870, y: 350 }, + { x: 970, y: 530 }, + { x: 770, y: 530 }, + ], + /* refIndex= */ 1.5, + ), + ); + + return scene; +} export class SimModel { - public constructor(public readonly tandem: Tandem) {} + /** The central optics scene containing all demo optical elements. */ + public readonly scene: OpticsScene; + + public constructor(public readonly tandem: Tandem) { + this.scene = buildDemoScene(); + } public reset(): void { // Called when the user presses the reset-all button diff --git a/src/rayTracing/view/OpticalElementViewFactory.ts b/src/rayTracing/view/OpticalElementViewFactory.ts new file mode 100644 index 0000000..7517f20 --- /dev/null +++ b/src/rayTracing/view/OpticalElementViewFactory.ts @@ -0,0 +1,73 @@ +/** + * OpticalElementViewFactory.ts + * + * Factory that creates the appropriate Scenery Node for any OpticalElement. + * Maps model types to their corresponding view classes. + */ + +import type { Node } from "scenerystack/scenery"; +import opticsLab from "../../OpticsLabNamespace.js"; +import { CircleGlass } from "../model/glass/CircleGlass.js"; +import { HalfPlaneGlass } from "../model/glass/HalfPlaneGlass.js"; +import { IdealLens } from "../model/glass/IdealLens.js"; +import { PolygonGlass } from "../model/glass/PolygonGlass.js"; +import { ArcMirror } from "../model/mirrors/ArcMirror.js"; +import { BeamSplitterElement } from "../model/mirrors/BeamSplitterElement.js"; +import { IdealCurvedMirror } from "../model/mirrors/IdealCurvedMirror.js"; +import { ParabolicMirror } from "../model/mirrors/ParabolicMirror.js"; +import { SegmentMirror } from "../model/mirrors/SegmentMirror.js"; +import type { OpticalElement } from "../model/optics/OpticsTypes.js"; +import { CircleGlassView } from "./glass/CircleGlassView.js"; +import { HalfPlaneGlassView } from "./glass/HalfPlaneGlassView.js"; +import { IdealLensView } from "./glass/IdealLensView.js"; +import { PolygonGlassView } from "./glass/PolygonGlassView.js"; +import { ArcMirrorView } from "./mirrors/ArcMirrorView.js"; +import { BeamSplitterView } from "./mirrors/BeamSplitterView.js"; +import { IdealCurvedMirrorView } from "./mirrors/IdealCurvedMirrorView.js"; +import { ParabolicMirrorView } from "./mirrors/ParabolicMirrorView.js"; +import { SegmentMirrorView } from "./mirrors/SegmentMirrorView.js"; + +/** + * Create and return a Scenery Node that visually represents the given + * optical element. Returns null if the element type has no view (e.g. + * light sources and blockers are handled separately). + */ +export function createOpticalElementView(element: OpticalElement): Node | null { + // ── Mirrors ─────────────────────────────────────────────────────────────── + if (element instanceof SegmentMirror) { + return new SegmentMirrorView(element); + } + if (element instanceof ArcMirror) { + return new ArcMirrorView(element); + } + if (element instanceof ParabolicMirror) { + return new ParabolicMirrorView(element); + } + if (element instanceof IdealCurvedMirror) { + return new IdealCurvedMirrorView(element); + } + if (element instanceof BeamSplitterElement) { + return new BeamSplitterView(element); + } + + // ── Glass / Lenses ──────────────────────────────────────────────────────── + if (element instanceof IdealLens) { + return new IdealLensView(element); + } + if (element instanceof CircleGlass) { + return new CircleGlassView(element); + } + // PolygonGlass must come after IdealLens / CircleGlass (no overlap), but + // SphericalLens extends PolygonGlass so this branch handles both. + if (element instanceof PolygonGlass) { + return new PolygonGlassView(element); + } + if (element instanceof HalfPlaneGlass) { + return new HalfPlaneGlassView(element); + } + + // Light sources and blockers are not handled here. + return null; +} + +opticsLab.register("createOpticalElementView", createOpticalElementView); diff --git a/src/rayTracing/view/SimScreenView.ts b/src/rayTracing/view/SimScreenView.ts index d90e0d5..8156930 100644 --- a/src/rayTracing/view/SimScreenView.ts +++ b/src/rayTracing/view/SimScreenView.ts @@ -1,38 +1,34 @@ -import { Rectangle, Text } from "scenerystack/scenery"; +import { Node } from "scenerystack/scenery"; import { ResetAllButton } from "scenerystack/scenery-phet"; import { ScreenView, type ScreenViewOptions } from "scenerystack/sim"; import { RESET_BUTTON_MARGIN } from "../../OpticsLabConstants.js"; import opticsLab from "../../OpticsLabNamespace.js"; import type { OpticsLabPreferencesModel } from "../../preferences/OpticsLabPreferencesModel.js"; import type { SimModel } from "../model/SimModel.js"; +import { createOpticalElementView } from "./OpticalElementViewFactory.js"; export class SimScreenView extends ScreenView { - private readonly rotatingRectangle: Rectangle; - private readonly opticsLabPreferences: OpticsLabPreferencesModel; - - public constructor(model: SimModel, opticsLabPreferences: OpticsLabPreferencesModel, options?: ScreenViewOptions) { + public constructor(model: SimModel, _opticsLabPreferences: OpticsLabPreferencesModel, options?: ScreenViewOptions) { super(options); - this.opticsLabPreferences = opticsLabPreferences; const tandem = options?.tandem; - // Sample Content - - this.rotatingRectangle = new Rectangle(-150, -20, 300, 40, { - fill: "#ccc", - translation: this.layoutBounds.center, - ...(tandem && { tandem: tandem.createTandem("rotatingRectangle") }), + // ── Optical Elements Layer ────────────────────────────────────────────── + // Create a Scenery node for every mirror and glass/lens in the scene. + const elementsLayer = new Node({ + ...(tandem && { tandem: tandem.createTandem("elementsLayer") }), }); - this.addChild(this.rotatingRectangle); - this.addChild( - new Text("Content goes here", { - font: "24px sans-serif", - center: this.layoutBounds.center, - ...(tandem && { tandem: tandem.createTandem("contentText") }), - }), - ); + for (const element of model.scene.getAllElements()) { + const elementView = createOpticalElementView(element); + if (elementView) { + elementsLayer.addChild(elementView); + } + } + this.addChild(elementsLayer); + + // ── Reset Button ──────────────────────────────────────────────────────── const resetAllButton = new ResetAllButton({ listener: () => { model.reset(); @@ -49,11 +45,8 @@ export class SimScreenView extends ScreenView { // Called when the user presses the reset-all button } - public step(dt: number): void { - // Called every frame, with the time since the last frame in seconds - if (this.opticsLabPreferences.enableDemoAnimationProperty.value) { - this.rotatingRectangle.rotation += 2 * dt; - } + public override step(_dt: number): void { + // Animation step — hook available for future per-frame updates } } diff --git a/src/rayTracing/view/ViewHelpers.ts b/src/rayTracing/view/ViewHelpers.ts new file mode 100644 index 0000000..3be454b --- /dev/null +++ b/src/rayTracing/view/ViewHelpers.ts @@ -0,0 +1,97 @@ +/** + * ViewHelpers.ts + * + * Shared utilities for building interactive optical-element views: + * - createHandle() – a styled draggable control-point circle + * - attachEndpointDrag() – wires a RichDragListener to a single handle + * - attachTranslationDrag() – wires a RichDragListener for whole-element + * translation to any pickable Node + */ + +import { Circle, type Node, RichDragListener } from "scenerystack/scenery"; +import { Tandem } from "scenerystack/tandem"; +import opticsLab from "../../OpticsLabNamespace.js"; +import type { Point } from "../model/optics/Geometry.js"; + +// ── Handle appearance ───────────────────────────────────────────────────────── +export const HANDLE_RADIUS = 6; +const HANDLE_FILL = "rgba(255, 255, 255, 0.88)"; +const HANDLE_STROKE = "#333"; +const HANDLE_LINE_WIDTH = 1.5; + +// ── Public helpers ──────────────────────────────────────────────────────────── + +/** + * Creates a small control-point circle with drag-ready appearance. + * Position the returned node by setting `.x` / `.y` to model coordinates. + */ +export function createHandle(p: Point): Circle { + return new Circle(HANDLE_RADIUS, { + x: p.x, + y: p.y, + fill: HANDLE_FILL, + stroke: HANDLE_STROKE, + lineWidth: HANDLE_LINE_WIDTH, + cursor: "pointer", + tagName: "div", // exposes to PDOM so keyboard drag works + focusable: true, + }); +} + +/** + * Attaches a RichDragListener to `handle` so that dragging (mouse or keyboard) + * updates a single model Point via the supplied getter/setter, then calls + * `rebuild` to refresh the view. + */ +export function attachEndpointDrag( + handle: Circle, + getPoint: () => Point, + setPoint: (p: Point) => void, + rebuild: () => void, +): void { + handle.addInputListener( + new RichDragListener({ + tandem: Tandem.OPT_OUT, + drag: (_event, listener) => { + const { x: dx, y: dy } = listener.modelDelta; + const p = getPoint(); + setPoint({ x: p.x + dx, y: p.y + dy }); + handle.x = getPoint().x; + handle.y = getPoint().y; + rebuild(); + }, + }), + ); +} + +/** + * Attaches a RichDragListener to `bodyNode` for whole-element translation. + * `points` is an array of getter/setter pairs covering every model Point that + * should move together. + */ +export function attachTranslationDrag( + bodyNode: Node, + points: ReadonlyArray<{ get: () => Point; set: (p: Point) => void }>, + rebuild: () => void, +): void { + bodyNode.cursor = "grab"; + bodyNode.addInputListener( + new RichDragListener({ + tandem: Tandem.OPT_OUT, + drag: (_event, listener) => { + const { x: dx, y: dy } = listener.modelDelta; + for (const { get, set } of points) { + const p = get(); + set({ x: p.x + dx, y: p.y + dy }); + } + rebuild(); + }, + }), + ); +} + +opticsLab.register("ViewHelpers", { + createHandle, + attachEndpointDrag, + attachTranslationDrag, +}); diff --git a/src/rayTracing/view/glass/CircleGlassView.ts b/src/rayTracing/view/glass/CircleGlassView.ts new file mode 100644 index 0000000..b595ad7 --- /dev/null +++ b/src/rayTracing/view/glass/CircleGlassView.ts @@ -0,0 +1,102 @@ +/** + * CircleGlassView.ts + * + * Scenery node for a circular glass element. Renders as a translucent + * blue circle matching the model's center (p1) and boundary point (p2). + * A center handle translates the whole circle; a boundary handle resizes it. + */ + +import { Shape } from "scenerystack/kite"; +import { type Circle, Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { CircleGlass } from "../../model/glass/CircleGlass.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)"; +const GLASS_STROKE_WIDTH = 1.5; + +export class CircleGlassView extends Node { + private readonly circlePath: Path; + private readonly handleCenter: Circle; + private readonly handleBoundary: Circle; + + public constructor(private readonly glass: CircleGlass) { + super(); + + this.circlePath = new Path(null, { + fill: GLASS_FILL, + stroke: GLASS_STROKE, + lineWidth: GLASS_STROKE_WIDTH, + }); + this.handleCenter = createHandle(glass.p1); + this.handleBoundary = createHandle(glass.p2); + + this.addChild(this.circlePath); + this.addChild(this.handleCenter); + this.addChild(this.handleBoundary); + + this.rebuild(); + + // Body drag: translate both center and boundary point together + attachTranslationDrag( + this.circlePath, + [ + { + get: () => glass.p1, + set: (p) => { + glass.p1 = p; + }, + }, + { + get: () => glass.p2, + set: (p) => { + glass.p2 = p; + }, + }, + ], + () => { + this.rebuild(); + }, + ); + + // Center handle: translates the whole circle (p1 and p2 move together) + attachEndpointDrag( + this.handleCenter, + () => glass.p1, + (newP1) => { + const oldP1 = glass.p1; + glass.p2 = { x: glass.p2.x + (newP1.x - oldP1.x), y: glass.p2.y + (newP1.y - oldP1.y) }; + glass.p1 = newP1; + }, + () => { + this.rebuild(); + }, + ); + + // Boundary handle: resizes the circle (only p2 moves) + attachEndpointDrag( + this.handleBoundary, + () => glass.p2, + (p) => { + glass.p2 = p; + }, + () => { + this.rebuild(); + }, + ); + } + + private rebuild(): void { + const { p1, p2 } = this.glass; + const radius = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); + this.circlePath.shape = new Shape().circle(p1.x, p1.y, radius); + this.handleCenter.x = p1.x; + this.handleCenter.y = p1.y; + this.handleBoundary.x = p2.x; + this.handleBoundary.y = p2.y; + } +} + +opticsLab.register("CircleGlassView", CircleGlassView); diff --git a/src/rayTracing/view/glass/HalfPlaneGlassView.ts b/src/rayTracing/view/glass/HalfPlaneGlassView.ts new file mode 100644 index 0000000..2dd795f --- /dev/null +++ b/src/rayTracing/view/glass/HalfPlaneGlassView.ts @@ -0,0 +1,133 @@ +/** + * HalfPlaneGlassView.ts + * + * Scenery node for a half-plane glass element. Renders the boundary line + * (p1 → p2) with short hatching strokes on the glass side (left when + * looking from p1 toward p2), indicating the refractive medium. + * Endpoint handles and a body-drag region let the user reposition the element. + */ + +import { Shape } from "scenerystack/kite"; +import { type Circle, Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { HalfPlaneGlass } from "../../model/glass/HalfPlaneGlass.js"; +import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; + +// ── Styling constants ───────────────────────────────────────────────────────── +const BORDER_STROKE = "rgba(60, 130, 210, 0.9)"; +const BORDER_WIDTH = 2; +const HATCH_STROKE = "rgba(100, 180, 255, 0.45)"; +const HATCH_WIDTH = 1; +const HATCH_SPACING = 20; // pixels between hatching lines +const HATCH_DEPTH = 18; // how far into the glass the hatching goes +const HATCH_COUNT = 8; // maximum number of hatch lines + +export class HalfPlaneGlassView extends Node { + private readonly borderPath: Path; + private readonly hatchPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + + public constructor(private readonly glass: HalfPlaneGlass) { + super(); + + this.borderPath = new Path(null, { + stroke: BORDER_STROKE, + lineWidth: BORDER_WIDTH, + lineCap: "round", + }); + this.hatchPath = new Path(null, { + stroke: HATCH_STROKE, + lineWidth: HATCH_WIDTH, + lineCap: "butt", + }); + this.handle1 = createHandle(glass.p1); + this.handle2 = createHandle(glass.p2); + + this.addChild(this.borderPath); + this.addChild(this.hatchPath); + this.addChild(this.handle1); + this.addChild(this.handle2); + + this.rebuild(); + + attachTranslationDrag( + this.borderPath, + [ + { + get: () => glass.p1, + set: (p) => { + glass.p1 = p; + }, + }, + { + get: () => glass.p2, + set: (p) => { + glass.p2 = p; + }, + }, + ], + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle1, + () => glass.p1, + (p) => { + glass.p1 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle2, + () => glass.p2, + (p) => { + glass.p2 = p; + }, + () => { + this.rebuild(); + }, + ); + } + + private rebuild(): void { + const { p1, p2 } = this.glass; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const len = Math.sqrt(dx * dx + dy * dy); + + this.borderPath.shape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); + + if (len > 1e-10) { + // Unit along-edge vector and left-normal (into the glass) + const ux = dx / len; + const uy = dy / len; + // Left normal (perpendicular, pointing into glass side) + const leftNx = -uy; + const leftNy = ux; + + const hatchShape = new Shape(); + const count = Math.min(HATCH_COUNT, Math.floor(len / HATCH_SPACING) + 1); + for (let i = 0; i <= count; i++) { + const t = count > 0 ? i / count : 0; + const bx = p1.x + dx * t; + const by = p1.y + dy * t; + hatchShape.moveTo(bx, by); + hatchShape.lineTo(bx + leftNx * HATCH_DEPTH, by + leftNy * HATCH_DEPTH); + } + this.hatchPath.shape = hatchShape; + } else { + this.hatchPath.shape = null; + } + + this.handle1.x = p1.x; + this.handle1.y = p1.y; + this.handle2.x = p2.x; + this.handle2.y = p2.y; + } +} + +opticsLab.register("HalfPlaneGlassView", HalfPlaneGlassView); diff --git a/src/rayTracing/view/glass/IdealLensView.ts b/src/rayTracing/view/glass/IdealLensView.ts new file mode 100644 index 0000000..e7706cf --- /dev/null +++ b/src/rayTracing/view/glass/IdealLensView.ts @@ -0,0 +1,142 @@ +/** + * IdealLensView.ts + * + * Scenery node for an ideal thin lens. Rendered as a bold green line + * with arrow heads at each end, indicating the converging or diverging + * power encoded by the focal length. + * Endpoint handles and a body-drag region let the user reposition the lens. + */ + +import { Shape } from "scenerystack/kite"; +import { type Circle, Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { IdealLens } from "../../model/glass/IdealLens.js"; +import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; + +// ── Styling constants ───────────────────────────────────────────────────────── +const LENS_STROKE = "#44cc88"; +const LENS_WIDTH = 3; +const ARROW_SIZE = 10; // half-length of each arrow head arm + +export class IdealLensView extends Node { + private readonly linePath: Path; + private readonly arrowPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + + public constructor(private readonly lens: IdealLens) { + super(); + + this.linePath = new Path(null, { + stroke: LENS_STROKE, + lineWidth: LENS_WIDTH, + lineCap: "round", + }); + this.arrowPath = new Path(null, { + stroke: LENS_STROKE, + lineWidth: LENS_WIDTH * 0.75, + lineCap: "round", + }); + this.handle1 = createHandle(lens.p1); + this.handle2 = createHandle(lens.p2); + + this.addChild(this.linePath); + this.addChild(this.arrowPath); + this.addChild(this.handle1); + this.addChild(this.handle2); + + this.rebuild(); + + attachTranslationDrag( + this.linePath, + [ + { + get: () => lens.p1, + set: (p) => { + lens.p1 = p; + }, + }, + { + get: () => lens.p2, + set: (p) => { + lens.p2 = p; + }, + }, + ], + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle1, + () => lens.p1, + (p) => { + lens.p1 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle2, + () => lens.p2, + (p) => { + lens.p2 = p; + }, + () => { + this.rebuild(); + }, + ); + } + + private rebuild(): void { + const { p1, p2, focalLength } = this.lens; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const len = Math.sqrt(dx * dx + dy * dy); + + this.linePath.shape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); + + if (len > 1e-10) { + // Unit vectors along and perpendicular to the lens + const ux = dx / len; + const uy = dy / len; + // Normal to lens (perpendicular) + const nx = -uy; + const ny = ux; + + // Arrow direction: outward (converging) for focalLength > 0, + // inward (diverging) for focalLength < 0 + const arrowSign = focalLength >= 0 ? 1 : -1; + + const arrowShape = new Shape(); + + // Arrow at p1: tip in +normal direction + const tip1X = p1.x + nx * ARROW_SIZE * arrowSign; + const tip1Y = p1.y + ny * ARROW_SIZE * arrowSign; + arrowShape.moveTo(tip1X, tip1Y); + arrowShape.lineTo(p1.x + ux * ARROW_SIZE * 0.5, p1.y + uy * ARROW_SIZE * 0.5); + arrowShape.moveTo(tip1X, tip1Y); + arrowShape.lineTo(p1.x - ux * ARROW_SIZE * 0.5, p1.y - uy * ARROW_SIZE * 0.5); + + // Arrow at p2: tip in -normal direction + const tip2X = p2.x - nx * ARROW_SIZE * arrowSign; + const tip2Y = p2.y - ny * ARROW_SIZE * arrowSign; + arrowShape.moveTo(tip2X, tip2Y); + arrowShape.lineTo(p2.x + ux * ARROW_SIZE * 0.5, p2.y + uy * ARROW_SIZE * 0.5); + arrowShape.moveTo(tip2X, tip2Y); + arrowShape.lineTo(p2.x - ux * ARROW_SIZE * 0.5, p2.y - uy * ARROW_SIZE * 0.5); + + this.arrowPath.shape = arrowShape; + } else { + this.arrowPath.shape = null; + } + + this.handle1.x = p1.x; + this.handle1.y = p1.y; + this.handle2.x = p2.x; + this.handle2.y = p2.y; + } +} + +opticsLab.register("IdealLensView", IdealLensView); diff --git a/src/rayTracing/view/glass/PolygonGlassView.ts b/src/rayTracing/view/glass/PolygonGlassView.ts new file mode 100644 index 0000000..649a141 --- /dev/null +++ b/src/rayTracing/view/glass/PolygonGlassView.ts @@ -0,0 +1,101 @@ +/** + * PolygonGlassView.ts + * + * Scenery node for a polygonal glass element (including SphericalLens, + * which extends PolygonGlass). Renders the closed polygon path as a + * translucent blue filled shape with a blue outline. + * One handle per non-arc vertex lets the user reshape the polygon; + * body drag repositions the whole element. + */ + +import { Shape } from "scenerystack/kite"; +import { type Circle, Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { PolygonGlass } from "../../model/glass/PolygonGlass.js"; +import type { Point } from "../../model/optics/Geometry.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)"; +const GLASS_STROKE_WIDTH = 1.5; + +export class PolygonGlassView extends Node { + private readonly glassPath: Path; + private readonly handles: Circle[]; + + public constructor(private readonly glass: PolygonGlass) { + super(); + + this.glassPath = new Path(null, { + fill: GLASS_FILL, + stroke: GLASS_STROKE, + lineWidth: GLASS_STROKE_WIDTH, + }); + this.addChild(this.glassPath); + + // Create one handle per non-arc vertex; each closure captures the vertex + // object by reference so mutations are reflected in the model directly. + const verts = glass.path.filter((v) => !v.arc); + this.handles = verts.map((vert) => { + const handle = createHandle({ x: vert.x, y: vert.y }); + attachEndpointDrag( + handle, + (): Point => ({ x: vert.x, y: vert.y }), + (p) => { + vert.x = p.x; + vert.y = p.y; + }, + () => { + this.rebuild(); + }, + ); + this.addChild(handle); + return handle; + }); + + this.rebuild(); + + // Body drag: translate all vertices (including arc control points) + const allVertPoints = glass.path.map((v) => ({ + get: (): Point => ({ x: v.x, y: v.y }), + set: (p: Point): void => { + v.x = p.x; + v.y = p.y; + }, + })); + attachTranslationDrag(this.glassPath, allVertPoints, () => { + this.rebuild(); + }); + } + + private rebuild(): void { + const verts = this.glass.path.filter((v) => !v.arc); + + const shape = new Shape(); + const first = verts[0]; + if (first) { + shape.moveTo(first.x, first.y); + for (let i = 1; i < verts.length; i++) { + const v = verts[i]; + if (v) { + shape.lineTo(v.x, v.y); + } + } + shape.close(); + } + this.glassPath.shape = shape; + + // Reposition handles to match updated vertex positions + for (let i = 0; i < this.handles.length; i++) { + const v = verts[i]; + const h = this.handles[i]; + if (v && h) { + h.x = v.x; + h.y = v.y; + } + } + } +} + +opticsLab.register("PolygonGlassView", PolygonGlassView); diff --git a/src/rayTracing/view/mirrors/ArcMirrorView.ts b/src/rayTracing/view/mirrors/ArcMirrorView.ts new file mode 100644 index 0000000..d7f2f31 --- /dev/null +++ b/src/rayTracing/view/mirrors/ArcMirrorView.ts @@ -0,0 +1,215 @@ +/** + * ArcMirrorView.ts + * + * Scenery node for a circular-arc mirror. Samples points along the arc + * from p1 to p2 through p3 and renders the polyline with mirror styling. + * Handles at p1, p2, and p3 allow reshaping; body drag repositions the mirror. + */ + +import { Shape } from "scenerystack/kite"; +import { type Circle, Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { ArcMirror } from "../../model/mirrors/ArcMirror.js"; +import type { Point } from "../../model/optics/Geometry.js"; +import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; + +// ── Styling constants ───────────────────────────────────────────────────────── +const BACK_STROKE = "#666"; +const BACK_WIDTH = 5; +const FRONT_STROKE = "#d8d8d8"; +const FRONT_WIDTH = 2.5; +const ARC_SAMPLE_COUNT = 60; + +/** + * Compute the circumcenter of triangle (p1, p2, p3). + * Returns null if the three points are collinear. + */ +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. + */ +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; +} + +function buildPolylineShape(pts: Point[]): Shape { + const shape = new Shape(); + const first = pts[0]; + if (!first) { + return shape; + } + shape.moveTo(first.x, first.y); + for (let i = 1; i < pts.length; i++) { + const p = pts[i]; + if (p) { + shape.lineTo(p.x, p.y); + } + } + return shape; +} + +export class ArcMirrorView extends Node { + private readonly backPath: Path; + private readonly frontPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + private readonly handle3: Circle; + + public constructor(private readonly mirror: ArcMirror) { + super(); + + this.backPath = new Path(null, { + stroke: BACK_STROKE, + lineWidth: BACK_WIDTH, + lineCap: "round", + lineJoin: "round", + }); + this.frontPath = new Path(null, { + stroke: FRONT_STROKE, + lineWidth: FRONT_WIDTH, + lineCap: "round", + lineJoin: "round", + }); + this.handle1 = createHandle(mirror.p1); + this.handle2 = createHandle(mirror.p2); + this.handle3 = createHandle(mirror.p3); + + this.addChild(this.backPath); + this.addChild(this.frontPath); + this.addChild(this.handle1); + this.addChild(this.handle2); + this.addChild(this.handle3); + + this.rebuild(); + + attachTranslationDrag( + this.backPath, + [ + { + get: () => mirror.p1, + set: (p) => { + mirror.p1 = p; + }, + }, + { + get: () => mirror.p2, + set: (p) => { + mirror.p2 = p; + }, + }, + { + get: () => mirror.p3, + set: (p) => { + mirror.p3 = p; + }, + }, + ], + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle1, + () => mirror.p1, + (p) => { + mirror.p1 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle2, + () => mirror.p2, + (p) => { + mirror.p2 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle3, + () => mirror.p3, + (p) => { + mirror.p3 = p; + }, + () => { + this.rebuild(); + }, + ); + } + + private rebuild(): void { + const { p1, p2, p3 } = this.mirror; + const pts = sampleArcPoints(p1, p2, p3, ARC_SAMPLE_COUNT); + const arcShape = buildPolylineShape(pts); + this.backPath.shape = arcShape; + this.frontPath.shape = arcShape; + this.handle1.x = p1.x; + this.handle1.y = p1.y; + this.handle2.x = p2.x; + this.handle2.y = p2.y; + this.handle3.x = p3.x; + this.handle3.y = p3.y; + } +} + +opticsLab.register("ArcMirrorView", ArcMirrorView); diff --git a/src/rayTracing/view/mirrors/BeamSplitterView.ts b/src/rayTracing/view/mirrors/BeamSplitterView.ts new file mode 100644 index 0000000..c27a241 --- /dev/null +++ b/src/rayTracing/view/mirrors/BeamSplitterView.ts @@ -0,0 +1,104 @@ +/** + * BeamSplitterView.ts + * + * Scenery node for a beam-splitter element. Rendered as a semi-transparent + * amber line, visually indicating partial transmission and partial reflection. + * Endpoint handles and a body-drag region let the user reposition the element. + */ + +import { Shape } from "scenerystack/kite"; +import { type Circle, Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { BeamSplitterElement } from "../../model/mirrors/BeamSplitterElement.js"; +import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; + +// ── Styling constants ───────────────────────────────────────────────────────── +const BACK_STROKE = "rgba(100, 90, 0, 0.5)"; +const BACK_WIDTH = 5; +const FRONT_STROKE = "rgba(220, 200, 60, 0.85)"; +const FRONT_WIDTH = 2.5; + +export class BeamSplitterView extends Node { + private readonly backPath: Path; + private readonly frontPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + + public constructor(private readonly splitter: BeamSplitterElement) { + super(); + + this.backPath = new Path(null, { + stroke: BACK_STROKE, + lineWidth: BACK_WIDTH, + lineCap: "round", + }); + this.frontPath = new Path(null, { + stroke: FRONT_STROKE, + lineWidth: FRONT_WIDTH, + lineCap: "round", + }); + this.handle1 = createHandle(splitter.p1); + this.handle2 = createHandle(splitter.p2); + + this.addChild(this.backPath); + this.addChild(this.frontPath); + this.addChild(this.handle1); + this.addChild(this.handle2); + + this.rebuild(); + + attachTranslationDrag( + this.backPath, + [ + { + get: () => splitter.p1, + set: (p) => { + splitter.p1 = p; + }, + }, + { + get: () => splitter.p2, + set: (p) => { + splitter.p2 = p; + }, + }, + ], + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle1, + () => splitter.p1, + (p) => { + splitter.p1 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle2, + () => splitter.p2, + (p) => { + splitter.p2 = p; + }, + () => { + this.rebuild(); + }, + ); + } + + private rebuild(): void { + const { p1, p2 } = this.splitter; + const shape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); + this.backPath.shape = shape; + this.frontPath.shape = shape; + this.handle1.x = p1.x; + this.handle1.y = p1.y; + this.handle2.x = p2.x; + this.handle2.y = p2.y; + } +} + +opticsLab.register("BeamSplitterView", BeamSplitterView); diff --git a/src/rayTracing/view/mirrors/IdealCurvedMirrorView.ts b/src/rayTracing/view/mirrors/IdealCurvedMirrorView.ts new file mode 100644 index 0000000..873ee86 --- /dev/null +++ b/src/rayTracing/view/mirrors/IdealCurvedMirrorView.ts @@ -0,0 +1,130 @@ +/** + * IdealCurvedMirrorView.ts + * + * Scenery node for an ideal curved mirror (obeys the mirror equation exactly). + * Rendered as a golden line segment with small focal-length tick marks, visually + * distinguishing it from a physical curved mirror. + * Endpoint handles and a body-drag region let the user reposition the mirror. + */ + +import { Shape } from "scenerystack/kite"; +import { type Circle, Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { IdealCurvedMirror } from "../../model/mirrors/IdealCurvedMirror.js"; +import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; + +// ── Styling constants ───────────────────────────────────────────────────────── +const MIRROR_STROKE = "#e8c000"; +const MIRROR_WIDTH = 3; +const TICK_STROKE = "#b89000"; +const TICK_WIDTH = 1.5; +const TICK_LENGTH = 6; +const TICK_COUNT = 5; + +export class IdealCurvedMirrorView extends Node { + private readonly linePath: Path; + private readonly tickPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + + public constructor(private readonly mirror: IdealCurvedMirror) { + super(); + + this.linePath = new Path(null, { + stroke: MIRROR_STROKE, + lineWidth: MIRROR_WIDTH, + lineCap: "round", + }); + this.tickPath = new Path(null, { + stroke: TICK_STROKE, + lineWidth: TICK_WIDTH, + lineCap: "butt", + }); + this.handle1 = createHandle(mirror.p1); + this.handle2 = createHandle(mirror.p2); + + this.addChild(this.linePath); + this.addChild(this.tickPath); + this.addChild(this.handle1); + this.addChild(this.handle2); + + this.rebuild(); + + attachTranslationDrag( + this.linePath, + [ + { + get: () => mirror.p1, + set: (p) => { + mirror.p1 = p; + }, + }, + { + get: () => mirror.p2, + set: (p) => { + mirror.p2 = p; + }, + }, + ], + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle1, + () => mirror.p1, + (p) => { + mirror.p1 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle2, + () => mirror.p2, + (p) => { + mirror.p2 = p; + }, + () => { + this.rebuild(); + }, + ); + } + + private rebuild(): void { + const { p1, p2 } = this.mirror; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const len = Math.sqrt(dx * dx + dy * dy); + + this.linePath.shape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); + + if (len > 1e-10) { + const ux = dx / len; + const uy = dy / len; + // Normal direction (perpendicular to mirror, pointing "back") + const nx = -uy; + const ny = ux; + + const tickShape = new Shape(); + for (let i = 0; i <= TICK_COUNT; i++) { + const t = i / TICK_COUNT; + const mx = p1.x + dx * t; + const my = p1.y + dy * t; + tickShape.moveTo(mx, my); + tickShape.lineTo(mx + nx * TICK_LENGTH, my + ny * TICK_LENGTH); + } + this.tickPath.shape = tickShape; + } else { + this.tickPath.shape = null; + } + + this.handle1.x = p1.x; + this.handle1.y = p1.y; + this.handle2.x = p2.x; + this.handle2.y = p2.y; + } +} + +opticsLab.register("IdealCurvedMirrorView", IdealCurvedMirrorView); diff --git a/src/rayTracing/view/mirrors/ParabolicMirrorView.ts b/src/rayTracing/view/mirrors/ParabolicMirrorView.ts new file mode 100644 index 0000000..fca8f05 --- /dev/null +++ b/src/rayTracing/view/mirrors/ParabolicMirrorView.ts @@ -0,0 +1,181 @@ +/** + * ParabolicMirrorView.ts + * + * Scenery node for a parabolic mirror. Replicates the model's polyline + * approximation (80 segments) and renders it with mirror styling. + * Handles at p1, p2, and p3 allow reshaping; body drag repositions the mirror. + */ + +import { Shape } from "scenerystack/kite"; +import { type Circle, Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { ParabolicMirror } from "../../model/mirrors/ParabolicMirror.js"; +import type { Point } from "../../model/optics/Geometry.js"; +import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; + +// ── Styling constants ───────────────────────────────────────────────────────── +const BACK_STROKE = "#666"; +const BACK_WIDTH = 5; +const FRONT_STROKE = "#d8d8d8"; +const FRONT_WIDTH = 2.5; +const NUM_SEGMENTS = 80; + +/** + * Compute the parabola's polyline approximation in scene coordinates. + * Matches the logic in ParabolicMirror.computePoints(). + */ +function computeParabolaPoints(p1: Point, p2: Point, p3: Point): Point[] { + const chordMidX = (p1.x + p2.x) / 2; + const chordMidY = (p1.y + p2.y) / 2; + const chordLen = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); + + if (chordLen < 1e-10) { + return [p1, p2]; + } + + const tangentX = (p2.x - p1.x) / chordLen; + const tangentY = (p2.y - p1.y) / chordLen; + + // Sagitta: signed distance of p3 from the chord midpoint in the normal direction + const sagitta = (p3.x - chordMidX) * -tangentY + (p3.y - chordMidY) * tangentX; + + const halfAperture = chordLen / 2; + const a = halfAperture ** 2 === 0 ? 0 : sagitta / halfAperture ** 2; + + const points: Point[] = []; + for (let i = 0; i <= NUM_SEGMENTS; i++) { + const t = -1 + (2 * i) / NUM_SEGMENTS; + const u = t * halfAperture; + const v = a * u * u; + points.push({ + x: chordMidX + tangentX * u - tangentY * v, + y: chordMidY + tangentY * u + tangentX * v, + }); + } + return points; +} + +function buildPolylineShape(pts: Point[]): Shape { + const shape = new Shape(); + const first = pts[0]; + if (!first) { + return shape; + } + shape.moveTo(first.x, first.y); + for (let i = 1; i < pts.length; i++) { + const p = pts[i]; + if (p) { + shape.lineTo(p.x, p.y); + } + } + return shape; +} + +export class ParabolicMirrorView extends Node { + private readonly backPath: Path; + private readonly frontPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + private readonly handle3: Circle; + + public constructor(private readonly mirror: ParabolicMirror) { + super(); + + this.backPath = new Path(null, { + stroke: BACK_STROKE, + lineWidth: BACK_WIDTH, + lineCap: "round", + lineJoin: "round", + }); + this.frontPath = new Path(null, { + stroke: FRONT_STROKE, + lineWidth: FRONT_WIDTH, + lineCap: "round", + lineJoin: "round", + }); + this.handle1 = createHandle(mirror.p1); + this.handle2 = createHandle(mirror.p2); + this.handle3 = createHandle(mirror.p3); + + this.addChild(this.backPath); + this.addChild(this.frontPath); + this.addChild(this.handle1); + this.addChild(this.handle2); + this.addChild(this.handle3); + + this.rebuild(); + + attachTranslationDrag( + this.backPath, + [ + { + get: () => mirror.p1, + set: (p) => { + mirror.p1 = p; + }, + }, + { + get: () => mirror.p2, + set: (p) => { + mirror.p2 = p; + }, + }, + { + get: () => mirror.p3, + set: (p) => { + mirror.p3 = p; + }, + }, + ], + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle1, + () => mirror.p1, + (p) => { + mirror.p1 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle2, + () => mirror.p2, + (p) => { + mirror.p2 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle3, + () => mirror.p3, + (p) => { + mirror.p3 = p; + }, + () => { + this.rebuild(); + }, + ); + } + + private rebuild(): void { + const { p1, p2, p3 } = this.mirror; + const pts = computeParabolaPoints(p1, p2, p3); + const parabolaShape = buildPolylineShape(pts); + this.backPath.shape = parabolaShape; + this.frontPath.shape = parabolaShape; + this.handle1.x = p1.x; + this.handle1.y = p1.y; + this.handle2.x = p2.x; + this.handle2.y = p2.y; + this.handle3.x = p3.x; + this.handle3.y = p3.y; + } +} + +opticsLab.register("ParabolicMirrorView", ParabolicMirrorView); diff --git a/src/rayTracing/view/mirrors/SegmentMirrorView.ts b/src/rayTracing/view/mirrors/SegmentMirrorView.ts new file mode 100644 index 0000000..4acdc43 --- /dev/null +++ b/src/rayTracing/view/mirrors/SegmentMirrorView.ts @@ -0,0 +1,105 @@ +/** + * SegmentMirrorView.ts + * + * Scenery node for a flat (segment) mirror. Renders as a silver reflective + * line with a dark backing, matching the classic optics-diagram style. + * Endpoint handles and a body-drag region let the user reshape and reposition + * the mirror interactively. + */ + +import { Shape } from "scenerystack/kite"; +import { type Circle, Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { SegmentMirror } from "../../model/mirrors/SegmentMirror.js"; +import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; + +// ── Styling constants ───────────────────────────────────────────────────────── +const BACK_STROKE = "#666"; +const BACK_WIDTH = 5; +const FRONT_STROKE = "#d8d8d8"; +const FRONT_WIDTH = 2.5; + +export class SegmentMirrorView extends Node { + private readonly backPath: Path; + private readonly frontPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + + public constructor(private readonly mirror: SegmentMirror) { + super(); + + this.backPath = new Path(null, { + stroke: BACK_STROKE, + lineWidth: BACK_WIDTH, + lineCap: "round", + }); + this.frontPath = new Path(null, { + stroke: FRONT_STROKE, + lineWidth: FRONT_WIDTH, + lineCap: "round", + }); + this.handle1 = createHandle(mirror.p1); + this.handle2 = createHandle(mirror.p2); + + this.addChild(this.backPath); + this.addChild(this.frontPath); + this.addChild(this.handle1); + this.addChild(this.handle2); + + this.rebuild(); + + attachTranslationDrag( + this.backPath, + [ + { + get: () => mirror.p1, + set: (p) => { + mirror.p1 = p; + }, + }, + { + get: () => mirror.p2, + set: (p) => { + mirror.p2 = p; + }, + }, + ], + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle1, + () => mirror.p1, + (p) => { + mirror.p1 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle2, + () => mirror.p2, + (p) => { + mirror.p2 = p; + }, + () => { + this.rebuild(); + }, + ); + } + + private rebuild(): void { + const { p1, p2 } = this.mirror; + const shape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); + this.backPath.shape = shape; + this.frontPath.shape = shape; + this.handle1.x = p1.x; + this.handle1.y = p1.y; + this.handle2.x = p2.x; + this.handle2.y = p2.y; + } +} + +opticsLab.register("SegmentMirrorView", SegmentMirrorView);