From de163c1947ea1133e7e702743ea4dbd8bf581e5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 16:10:05 +0000 Subject: [PATCH 1/3] Add views for mirrors and glass/lens optical elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates a dedicated Scenery node for every mirror and glass/lens model type: Mirrors (src/rayTracing/view/mirrors/): - SegmentMirrorView – flat mirror with silver front + dark backing - ArcMirrorView – circular-arc mirror (circumcenter + arc sampling) - ParabolicMirrorView– parabolic mirror (80-segment polyline, mirrors model logic) - IdealCurvedMirrorView – golden line + tick marks (ideal element indicator) - BeamSplitterView – semi-transparent amber line Glass / Lenses (src/rayTracing/view/glass/): - CircleGlassView – translucent blue filled circle - PolygonGlassView – translucent blue filled polygon (covers SphericalLens too) - IdealLensView – bold green line with arrowheads (converging/diverging) - HalfPlaneGlassView – boundary line with hatching on the glass side Factory & integration: - OpticalElementViewFactory creates the correct view for any OpticalElement - SimModel now builds a demo OpticsScene with all 10 element types - SimScreenView replaced the placeholder with an elements layer wired to the model via the factory https://claude.ai/code/session_01CRFkPktcpxMr9ufGuKpy7T --- src/rayTracing/model/SimModel.ts | 98 ++++++++++++- .../view/OpticalElementViewFactory.ts | 73 ++++++++++ src/rayTracing/view/SimScreenView.ts | 43 +++--- src/rayTracing/view/glass/CircleGlassView.ts | 37 +++++ .../view/glass/HalfPlaneGlassView.ts | 72 ++++++++++ src/rayTracing/view/glass/IdealLensView.ts | 79 +++++++++++ src/rayTracing/view/glass/PolygonGlassView.ts | 55 ++++++++ src/rayTracing/view/mirrors/ArcMirrorView.ts | 132 ++++++++++++++++++ .../view/mirrors/BeamSplitterView.ts | 46 ++++++ .../view/mirrors/IdealCurvedMirrorView.ts | 67 +++++++++ .../view/mirrors/ParabolicMirrorView.ts | 98 +++++++++++++ .../view/mirrors/SegmentMirrorView.ts | 44 ++++++ 12 files changed, 818 insertions(+), 26 deletions(-) create mode 100644 src/rayTracing/view/OpticalElementViewFactory.ts create mode 100644 src/rayTracing/view/glass/CircleGlassView.ts create mode 100644 src/rayTracing/view/glass/HalfPlaneGlassView.ts create mode 100644 src/rayTracing/view/glass/IdealLensView.ts create mode 100644 src/rayTracing/view/glass/PolygonGlassView.ts create mode 100644 src/rayTracing/view/mirrors/ArcMirrorView.ts create mode 100644 src/rayTracing/view/mirrors/BeamSplitterView.ts create mode 100644 src/rayTracing/view/mirrors/IdealCurvedMirrorView.ts create mode 100644 src/rayTracing/view/mirrors/ParabolicMirrorView.ts create mode 100644 src/rayTracing/view/mirrors/SegmentMirrorView.ts 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/glass/CircleGlassView.ts b/src/rayTracing/view/glass/CircleGlassView.ts new file mode 100644 index 0000000..65d9c42 --- /dev/null +++ b/src/rayTracing/view/glass/CircleGlassView.ts @@ -0,0 +1,37 @@ +/** + * 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). + */ + +import { Shape } from "scenerystack/kite"; +import { Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { CircleGlass } from "../../model/glass/CircleGlass.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 { + public constructor(glass: CircleGlass) { + super(); + + const cx = glass.p1.x; + const cy = glass.p1.y; + const radius = Math.sqrt((glass.p2.x - cx) ** 2 + (glass.p2.y - cy) ** 2); + + const shape = new Shape().circle(cx, cy, radius); + const path = new Path(shape, { + fill: GLASS_FILL, + stroke: GLASS_STROKE, + lineWidth: GLASS_STROKE_WIDTH, + }); + + this.addChild(path); + } +} + +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..14f092b --- /dev/null +++ b/src/rayTracing/view/glass/HalfPlaneGlassView.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ + +import { Shape } from "scenerystack/kite"; +import { Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { HalfPlaneGlass } from "../../model/glass/HalfPlaneGlass.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 { + public constructor(glass: HalfPlaneGlass) { + super(); + + const { p1, p2 } = glass; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const len = Math.sqrt(dx * dx + dy * dy); + + // Boundary line + const borderShape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); + const borderPath = new Path(borderShape, { + stroke: BORDER_STROKE, + lineWidth: BORDER_WIDTH, + lineCap: "round", + }); + this.addChild(borderPath); + + if (len < 1e-10) { + return; + } + + // 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; + + // Draw hatching lines evenly spaced along the boundary + 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); + } + + const hatchPath = new Path(hatchShape, { + stroke: HATCH_STROKE, + lineWidth: HATCH_WIDTH, + lineCap: "butt", + }); + this.addChild(hatchPath); + } +} + +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..6ffc11d --- /dev/null +++ b/src/rayTracing/view/glass/IdealLensView.ts @@ -0,0 +1,79 @@ +/** + * 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. + */ + +import { Shape } from "scenerystack/kite"; +import { Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { IdealLens } from "../../model/glass/IdealLens.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 { + public constructor(lens: IdealLens) { + super(); + + const { p1, p2, focalLength } = lens; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const len = Math.sqrt(dx * dx + dy * dy); + + // Main lens line + const lineShape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); + const linePath = new Path(lineShape, { + stroke: LENS_STROKE, + lineWidth: LENS_WIDTH, + lineCap: "round", + }); + this.addChild(linePath); + + if (len < 1e-10) { + return; + } + + // 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; + + // Draw arrow heads at p1 and p2 + const arrowShape = new Shape(); + + for (const [px, py, tipDir] of [ + [p1.x, p1.y, 1] as const, // tip pointing in +normal direction at p1 + [p2.x, p2.y, -1] as const, // tip pointing in -normal direction at p2 + ]) { + // Arrow tip + const tipX = px + nx * ARROW_SIZE * arrowSign * tipDir; + const tipY = py + ny * ARROW_SIZE * arrowSign * tipDir; + + // Arrow head arms (angled back 45° from tip toward the line) + arrowShape.moveTo(tipX, tipY); + arrowShape.lineTo(px + ux * ARROW_SIZE * 0.5, py + uy * ARROW_SIZE * 0.5); + arrowShape.moveTo(tipX, tipY); + arrowShape.lineTo(px - ux * ARROW_SIZE * 0.5, py - uy * ARROW_SIZE * 0.5); + } + + const arrowPath = new Path(arrowShape, { + stroke: LENS_STROKE, + lineWidth: LENS_WIDTH * 0.75, + lineCap: "round", + }); + this.addChild(arrowPath); + } +} + +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..1b8bfad --- /dev/null +++ b/src/rayTracing/view/glass/PolygonGlassView.ts @@ -0,0 +1,55 @@ +/** + * 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. + */ + +import { Shape } from "scenerystack/kite"; +import { Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { PolygonGlass } from "../../model/glass/PolygonGlass.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 { + public constructor(glass: PolygonGlass) { + super(); + + // Filter out arc-control vertices; use only "real" polygon vertices + const verts = glass.path.filter((v) => !v.arc); + + if (verts.length < 2) { + return; + } + + const shape = new Shape(); + const first = verts[0]; + if (!first) { + return; + } + + 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(); + + const path = new Path(shape, { + fill: GLASS_FILL, + stroke: GLASS_STROKE, + lineWidth: GLASS_STROKE_WIDTH, + }); + + this.addChild(path); + } +} + +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..fcec78a --- /dev/null +++ b/src/rayTracing/view/mirrors/ArcMirrorView.ts @@ -0,0 +1,132 @@ +/** + * 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. + */ + +import { Shape } from "scenerystack/kite"; +import { 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"; + +// ── 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 { + public constructor(mirror: ArcMirror) { + super(); + + const pts = sampleArcPoints(mirror.p1, mirror.p2, mirror.p3, ARC_SAMPLE_COUNT); + const arcShape = buildPolylineShape(pts); + + const backPath = new Path(arcShape, { + stroke: BACK_STROKE, + lineWidth: BACK_WIDTH, + lineCap: "round", + lineJoin: "round", + }); + + const frontPath = new Path(arcShape, { + stroke: FRONT_STROKE, + lineWidth: FRONT_WIDTH, + lineCap: "round", + lineJoin: "round", + }); + + this.addChild(backPath); + this.addChild(frontPath); + } +} + +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..4af821f --- /dev/null +++ b/src/rayTracing/view/mirrors/BeamSplitterView.ts @@ -0,0 +1,46 @@ +/** + * BeamSplitterView.ts + * + * Scenery node for a beam-splitter element. Rendered as a semi-transparent + * amber line, visually indicating partial transmission and partial reflection. + */ + +import { Shape } from "scenerystack/kite"; +import { Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { BeamSplitterElement } from "../../model/mirrors/BeamSplitterElement.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 { + public constructor(splitter: BeamSplitterElement) { + super(); + + const { p1, p2 } = splitter; + + const shape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); + + // Subtle backing + const backPath = new Path(shape, { + stroke: BACK_STROKE, + lineWidth: BACK_WIDTH, + lineCap: "round", + }); + + // Semi-transparent amber front — conveys partial reflectance + const frontPath = new Path(shape, { + stroke: FRONT_STROKE, + lineWidth: FRONT_WIDTH, + lineCap: "round", + }); + + this.addChild(backPath); + this.addChild(frontPath); + } +} + +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..ada283a --- /dev/null +++ b/src/rayTracing/view/mirrors/IdealCurvedMirrorView.ts @@ -0,0 +1,67 @@ +/** + * 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. + */ + +import { Shape } from "scenerystack/kite"; +import { Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { IdealCurvedMirror } from "../../model/mirrors/IdealCurvedMirror.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 { + public constructor(mirror: IdealCurvedMirror) { + super(); + + const { p1, p2 } = mirror; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const len = Math.sqrt(dx * dx + dy * dy); + + // Main mirror line + const lineShape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); + const linePath = new Path(lineShape, { + stroke: MIRROR_STROKE, + lineWidth: MIRROR_WIDTH, + lineCap: "round", + }); + this.addChild(linePath); + + // Tick marks along the mirror body (indicating an ideal element) + 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); + } + + const tickPath = new Path(tickShape, { + stroke: TICK_STROKE, + lineWidth: TICK_WIDTH, + lineCap: "butt", + }); + this.addChild(tickPath); + } + } +} + +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..8f9606a --- /dev/null +++ b/src/rayTracing/view/mirrors/ParabolicMirrorView.ts @@ -0,0 +1,98 @@ +/** + * ParabolicMirrorView.ts + * + * Scenery node for a parabolic mirror. Replicates the model's polyline + * approximation (80 segments) and renders it with mirror styling. + */ + +import { Shape } from "scenerystack/kite"; +import { 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"; + +// ── 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 { + public constructor(mirror: ParabolicMirror) { + super(); + + const pts = computeParabolaPoints(mirror.p1, mirror.p2, mirror.p3); + const parabolaShape = buildPolylineShape(pts); + + const backPath = new Path(parabolaShape, { + stroke: BACK_STROKE, + lineWidth: BACK_WIDTH, + lineCap: "round", + lineJoin: "round", + }); + + const frontPath = new Path(parabolaShape, { + stroke: FRONT_STROKE, + lineWidth: FRONT_WIDTH, + lineCap: "round", + lineJoin: "round", + }); + + this.addChild(backPath); + this.addChild(frontPath); + } +} + +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..fcf2be4 --- /dev/null +++ b/src/rayTracing/view/mirrors/SegmentMirrorView.ts @@ -0,0 +1,44 @@ +/** + * 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. + */ + +import { Shape } from "scenerystack/kite"; +import { Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { SegmentMirror } from "../../model/mirrors/SegmentMirror.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 { + public constructor(mirror: SegmentMirror) { + super(); + + // Dark backing (shadow side of the mirror) + const backShape = new Shape().moveTo(mirror.p1.x, mirror.p1.y).lineTo(mirror.p2.x, mirror.p2.y); + const backPath = new Path(backShape, { + stroke: BACK_STROKE, + lineWidth: BACK_WIDTH, + lineCap: "round", + }); + + // Bright reflective front surface + const frontShape = new Shape().moveTo(mirror.p1.x, mirror.p1.y).lineTo(mirror.p2.x, mirror.p2.y); + const frontPath = new Path(frontShape, { + stroke: FRONT_STROKE, + lineWidth: FRONT_WIDTH, + lineCap: "round", + }); + + this.addChild(backPath); + this.addChild(frontPath); + } +} + +opticsLab.register("SegmentMirrorView", SegmentMirrorView); From 568823ebe1d0d23632f069ee476a2ce16f9fd0ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 16:11:04 +0000 Subject: [PATCH 2/3] Update package-lock.json after npm install Minor metadata cleanup by npm (removed redundant "peer" flags). https://claude.ai/code/session_01CRFkPktcpxMr9ufGuKpy7T --- package-lock.json | 8 -------- 1 file changed, 8 deletions(-) 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" }, From fd1569dd4c5139ee23dbaf164337fdb31789c53c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 16:31:40 +0000 Subject: [PATCH 3/3] Add RichDragListener interactivity to all optical element views Each mirror and glass/lens view now supports: - Body drag (grab the element to translate it) - Endpoint handles (white circles) at key control points to reshape the element - For arc/parabolic mirrors: a third handle at p3 to adjust curvature - For CircleGlass: center handle translates the whole circle; boundary handle resizes radius - For PolygonGlass: one handle per non-arc vertex for independent reshaping All views share the createHandle / attachEndpointDrag / attachTranslationDrag helpers from ViewHelpers.ts and implement a private rebuild() method that updates path shapes and handle positions from the current model state. https://claude.ai/code/session_01CRFkPktcpxMr9ufGuKpy7T --- src/rayTracing/view/ViewHelpers.ts | 97 +++++++++++ src/rayTracing/view/glass/CircleGlassView.ts | 83 ++++++++- .../view/glass/HalfPlaneGlassView.ts | 131 +++++++++++---- src/rayTracing/view/glass/IdealLensView.ts | 157 ++++++++++++------ src/rayTracing/view/glass/PolygonGlassView.ts | 88 +++++++--- src/rayTracing/view/mirrors/ArcMirrorView.ts | 103 ++++++++++-- .../view/mirrors/BeamSplitterView.ts | 84 ++++++++-- .../view/mirrors/IdealCurvedMirrorView.ts | 101 ++++++++--- .../view/mirrors/ParabolicMirrorView.ts | 103 ++++++++++-- .../view/mirrors/SegmentMirrorView.ts | 83 +++++++-- 10 files changed, 855 insertions(+), 175 deletions(-) create mode 100644 src/rayTracing/view/ViewHelpers.ts 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 index 65d9c42..b595ad7 100644 --- a/src/rayTracing/view/glass/CircleGlassView.ts +++ b/src/rayTracing/view/glass/CircleGlassView.ts @@ -3,12 +3,14 @@ * * 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 { Node, Path } from "scenerystack/scenery"; +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)"; @@ -16,21 +18,84 @@ const GLASS_STROKE = "rgba(60, 130, 210, 0.8)"; const GLASS_STROKE_WIDTH = 1.5; export class CircleGlassView extends Node { - public constructor(glass: CircleGlass) { - super(); + private readonly circlePath: Path; + private readonly handleCenter: Circle; + private readonly handleBoundary: Circle; - const cx = glass.p1.x; - const cy = glass.p1.y; - const radius = Math.sqrt((glass.p2.x - cx) ** 2 + (glass.p2.y - cy) ** 2); + public constructor(private readonly glass: CircleGlass) { + super(); - const shape = new Shape().circle(cx, cy, radius); - const path = new Path(shape, { + 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(); + }, + ); + } - this.addChild(path); + 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; } } diff --git a/src/rayTracing/view/glass/HalfPlaneGlassView.ts b/src/rayTracing/view/glass/HalfPlaneGlassView.ts index 14f092b..2dd795f 100644 --- a/src/rayTracing/view/glass/HalfPlaneGlassView.ts +++ b/src/rayTracing/view/glass/HalfPlaneGlassView.ts @@ -4,12 +4,14 @@ * 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 { Node, Path } from "scenerystack/scenery"; +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)"; @@ -21,51 +23,110 @@ 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 { - public constructor(glass: HalfPlaneGlass) { - super(); + private readonly borderPath: Path; + private readonly hatchPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; - const { p1, p2 } = glass; - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const len = Math.sqrt(dx * dx + dy * dy); + public constructor(private readonly glass: HalfPlaneGlass) { + super(); - // Boundary line - const borderShape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); - const borderPath = new Path(borderShape, { + this.borderPath = new Path(null, { stroke: BORDER_STROKE, lineWidth: BORDER_WIDTH, lineCap: "round", }); - this.addChild(borderPath); + this.hatchPath = new Path(null, { + stroke: HATCH_STROKE, + lineWidth: HATCH_WIDTH, + lineCap: "butt", + }); + this.handle1 = createHandle(glass.p1); + this.handle2 = createHandle(glass.p2); - if (len < 1e-10) { - return; - } + this.addChild(this.borderPath); + this.addChild(this.hatchPath); + this.addChild(this.handle1); + this.addChild(this.handle2); + + this.rebuild(); - // 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; + 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); - // Draw hatching lines evenly spaced along the boundary - 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); + 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; } - const hatchPath = new Path(hatchShape, { - stroke: HATCH_STROKE, - lineWidth: HATCH_WIDTH, - lineCap: "butt", - }); - this.addChild(hatchPath); + this.handle1.x = p1.x; + this.handle1.y = p1.y; + this.handle2.x = p2.x; + this.handle2.y = p2.y; } } diff --git a/src/rayTracing/view/glass/IdealLensView.ts b/src/rayTracing/view/glass/IdealLensView.ts index 6ffc11d..e7706cf 100644 --- a/src/rayTracing/view/glass/IdealLensView.ts +++ b/src/rayTracing/view/glass/IdealLensView.ts @@ -4,12 +4,14 @@ * 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 { Node, Path } from "scenerystack/scenery"; +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"; @@ -17,62 +19,123 @@ const LENS_WIDTH = 3; const ARROW_SIZE = 10; // half-length of each arrow head arm export class IdealLensView extends Node { - public constructor(lens: IdealLens) { - super(); + private readonly linePath: Path; + private readonly arrowPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; - const { p1, p2, focalLength } = lens; - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const len = Math.sqrt(dx * dx + dy * dy); + public constructor(private readonly lens: IdealLens) { + super(); - // Main lens line - const lineShape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); - const linePath = new Path(lineShape, { + this.linePath = new Path(null, { stroke: LENS_STROKE, lineWidth: LENS_WIDTH, lineCap: "round", }); - this.addChild(linePath); - - if (len < 1e-10) { - return; - } - - // 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; - - // Draw arrow heads at p1 and p2 - const arrowShape = new Shape(); - - for (const [px, py, tipDir] of [ - [p1.x, p1.y, 1] as const, // tip pointing in +normal direction at p1 - [p2.x, p2.y, -1] as const, // tip pointing in -normal direction at p2 - ]) { - // Arrow tip - const tipX = px + nx * ARROW_SIZE * arrowSign * tipDir; - const tipY = py + ny * ARROW_SIZE * arrowSign * tipDir; - - // Arrow head arms (angled back 45° from tip toward the line) - arrowShape.moveTo(tipX, tipY); - arrowShape.lineTo(px + ux * ARROW_SIZE * 0.5, py + uy * ARROW_SIZE * 0.5); - arrowShape.moveTo(tipX, tipY); - arrowShape.lineTo(px - ux * ARROW_SIZE * 0.5, py - uy * ARROW_SIZE * 0.5); - } - - const arrowPath = new Path(arrowShape, { + this.arrowPath = new Path(null, { stroke: LENS_STROKE, lineWidth: LENS_WIDTH * 0.75, lineCap: "round", }); - this.addChild(arrowPath); + 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; } } diff --git a/src/rayTracing/view/glass/PolygonGlassView.ts b/src/rayTracing/view/glass/PolygonGlassView.ts index 1b8bfad..649a141 100644 --- a/src/rayTracing/view/glass/PolygonGlassView.ts +++ b/src/rayTracing/view/glass/PolygonGlassView.ts @@ -4,12 +4,16 @@ * 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 { Node, Path } from "scenerystack/scenery"; +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)"; @@ -17,38 +21,80 @@ const GLASS_STROKE = "rgba(60, 130, 210, 0.8)"; const GLASS_STROKE_WIDTH = 1.5; export class PolygonGlassView extends Node { - public constructor(glass: PolygonGlass) { + private readonly glassPath: Path; + private readonly handles: Circle[]; + + public constructor(private readonly glass: PolygonGlass) { super(); - // Filter out arc-control vertices; use only "real" polygon vertices + 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; + }); - if (verts.length < 2) { - return; - } + 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) { - return; + 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; - shape.moveTo(first.x, first.y); - for (let i = 1; i < verts.length; i++) { + // Reposition handles to match updated vertex positions + for (let i = 0; i < this.handles.length; i++) { const v = verts[i]; - if (v) { - shape.lineTo(v.x, v.y); + const h = this.handles[i]; + if (v && h) { + h.x = v.x; + h.y = v.y; } } - shape.close(); - - const path = new Path(shape, { - fill: GLASS_FILL, - stroke: GLASS_STROKE, - lineWidth: GLASS_STROKE_WIDTH, - }); - - this.addChild(path); } } diff --git a/src/rayTracing/view/mirrors/ArcMirrorView.ts b/src/rayTracing/view/mirrors/ArcMirrorView.ts index fcec78a..d7f2f31 100644 --- a/src/rayTracing/view/mirrors/ArcMirrorView.ts +++ b/src/rayTracing/view/mirrors/ArcMirrorView.ts @@ -3,13 +3,15 @@ * * 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 { Node, Path } from "scenerystack/scenery"; +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"; @@ -104,28 +106,109 @@ function buildPolylineShape(pts: Point[]): Shape { } export class ArcMirrorView extends Node { - public constructor(mirror: ArcMirror) { - super(); + private readonly backPath: Path; + private readonly frontPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + private readonly handle3: Circle; - const pts = sampleArcPoints(mirror.p1, mirror.p2, mirror.p3, ARC_SAMPLE_COUNT); - const arcShape = buildPolylineShape(pts); + public constructor(private readonly mirror: ArcMirror) { + super(); - const backPath = new Path(arcShape, { + this.backPath = new Path(null, { stroke: BACK_STROKE, lineWidth: BACK_WIDTH, lineCap: "round", lineJoin: "round", }); - - const frontPath = new Path(arcShape, { + 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(); + }, + ); + } - this.addChild(backPath); - this.addChild(frontPath); + 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; } } diff --git a/src/rayTracing/view/mirrors/BeamSplitterView.ts b/src/rayTracing/view/mirrors/BeamSplitterView.ts index 4af821f..c27a241 100644 --- a/src/rayTracing/view/mirrors/BeamSplitterView.ts +++ b/src/rayTracing/view/mirrors/BeamSplitterView.ts @@ -3,12 +3,14 @@ * * 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 { Node, Path } from "scenerystack/scenery"; +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)"; @@ -17,29 +19,85 @@ const FRONT_STROKE = "rgba(220, 200, 60, 0.85)"; const FRONT_WIDTH = 2.5; export class BeamSplitterView extends Node { - public constructor(splitter: BeamSplitterElement) { - super(); - - const { p1, p2 } = splitter; + private readonly backPath: Path; + private readonly frontPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; - const shape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); + public constructor(private readonly splitter: BeamSplitterElement) { + super(); - // Subtle backing - const backPath = new Path(shape, { + this.backPath = new Path(null, { stroke: BACK_STROKE, lineWidth: BACK_WIDTH, lineCap: "round", }); - - // Semi-transparent amber front — conveys partial reflectance - const frontPath = new Path(shape, { + 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(backPath); - this.addChild(frontPath); + 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; } } diff --git a/src/rayTracing/view/mirrors/IdealCurvedMirrorView.ts b/src/rayTracing/view/mirrors/IdealCurvedMirrorView.ts index ada283a..873ee86 100644 --- a/src/rayTracing/view/mirrors/IdealCurvedMirrorView.ts +++ b/src/rayTracing/view/mirrors/IdealCurvedMirrorView.ts @@ -4,12 +4,14 @@ * 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 { Node, Path } from "scenerystack/scenery"; +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"; @@ -20,24 +22,84 @@ const TICK_LENGTH = 6; const TICK_COUNT = 5; export class IdealCurvedMirrorView extends Node { - public constructor(mirror: IdealCurvedMirror) { - super(); + private readonly linePath: Path; + private readonly tickPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; - const { p1, p2 } = mirror; - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const len = Math.sqrt(dx * dx + dy * dy); + public constructor(private readonly mirror: IdealCurvedMirror) { + super(); - // Main mirror line - const lineShape = new Shape().moveTo(p1.x, p1.y).lineTo(p2.x, p2.y); - const linePath = new Path(lineShape, { + this.linePath = new Path(null, { stroke: MIRROR_STROKE, lineWidth: MIRROR_WIDTH, lineCap: "round", }); - this.addChild(linePath); + 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); - // Tick marks along the mirror body (indicating an ideal element) if (len > 1e-10) { const ux = dx / len; const uy = dy / len; @@ -53,14 +115,15 @@ export class IdealCurvedMirrorView extends Node { tickShape.moveTo(mx, my); tickShape.lineTo(mx + nx * TICK_LENGTH, my + ny * TICK_LENGTH); } - - const tickPath = new Path(tickShape, { - stroke: TICK_STROKE, - lineWidth: TICK_WIDTH, - lineCap: "butt", - }); - this.addChild(tickPath); + 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; } } diff --git a/src/rayTracing/view/mirrors/ParabolicMirrorView.ts b/src/rayTracing/view/mirrors/ParabolicMirrorView.ts index 8f9606a..fca8f05 100644 --- a/src/rayTracing/view/mirrors/ParabolicMirrorView.ts +++ b/src/rayTracing/view/mirrors/ParabolicMirrorView.ts @@ -3,13 +3,15 @@ * * 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 { Node, Path } from "scenerystack/scenery"; +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"; @@ -70,28 +72,109 @@ function buildPolylineShape(pts: Point[]): Shape { } export class ParabolicMirrorView extends Node { - public constructor(mirror: ParabolicMirror) { - super(); + private readonly backPath: Path; + private readonly frontPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + private readonly handle3: Circle; - const pts = computeParabolaPoints(mirror.p1, mirror.p2, mirror.p3); - const parabolaShape = buildPolylineShape(pts); + public constructor(private readonly mirror: ParabolicMirror) { + super(); - const backPath = new Path(parabolaShape, { + this.backPath = new Path(null, { stroke: BACK_STROKE, lineWidth: BACK_WIDTH, lineCap: "round", lineJoin: "round", }); - - const frontPath = new Path(parabolaShape, { + 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.addChild(backPath); - this.addChild(frontPath); + 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; } } diff --git a/src/rayTracing/view/mirrors/SegmentMirrorView.ts b/src/rayTracing/view/mirrors/SegmentMirrorView.ts index fcf2be4..4acdc43 100644 --- a/src/rayTracing/view/mirrors/SegmentMirrorView.ts +++ b/src/rayTracing/view/mirrors/SegmentMirrorView.ts @@ -3,12 +3,15 @@ * * 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 { Node, Path } from "scenerystack/scenery"; +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"; @@ -17,27 +20,85 @@ const FRONT_STROKE = "#d8d8d8"; const FRONT_WIDTH = 2.5; export class SegmentMirrorView extends Node { - public constructor(mirror: SegmentMirror) { + private readonly backPath: Path; + private readonly frontPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + + public constructor(private readonly mirror: SegmentMirror) { super(); - // Dark backing (shadow side of the mirror) - const backShape = new Shape().moveTo(mirror.p1.x, mirror.p1.y).lineTo(mirror.p2.x, mirror.p2.y); - const backPath = new Path(backShape, { + this.backPath = new Path(null, { stroke: BACK_STROKE, lineWidth: BACK_WIDTH, lineCap: "round", }); - - // Bright reflective front surface - const frontShape = new Shape().moveTo(mirror.p1.x, mirror.p1.y).lineTo(mirror.p2.x, mirror.p2.y); - const frontPath = new Path(frontShape, { + 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(); + }, + ); + } - this.addChild(backPath); - this.addChild(frontPath); + 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; } }