From af53aa84c459d7266392bf535f0fdf32d8041e50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 20:35:32 +0000 Subject: [PATCH] Add component carousel toolbox for creating optical elements Replace hardcoded demo scene with an interactive carousel sidebar that lets users create any optical component by clicking its icon. The carousel (from SceneryStack's sun library) displays icons for all 16 component types organized by category: light sources, mirrors, lenses/glass, and blockers. - Add ComponentCarousel with miniature icons and factory functions for all element types (point source, beam, single ray, flat/arc/ parabolic/ideal mirrors, beam splitter, ideal lens, circle/spherical lens, prism, half-plane glass, line/circle blockers, aperture) - Add blocker view classes (LineBlockerView, CircleBlockerView, ApertureView) so blockers are now rendered and interactive - Update OpticalElementViewFactory to include blocker views - Update SimScreenView to host carousel and support dynamic element creation via callback - Simplify SimModel to start with empty scene; reset clears all https://claude.ai/code/session_01QvNN5qX7o5FmGAtweEQyaZ --- src/rayTracing/model/SimModel.ts | 126 +---- src/rayTracing/view/ComponentCarousel.ts | 482 ++++++++++++++++++ .../view/OpticalElementViewFactory.ts | 18 +- src/rayTracing/view/SimScreenView.ts | 31 +- src/rayTracing/view/blockers/ApertureView.ts | 145 ++++++ .../view/blockers/CircleBlockerView.ts | 97 ++++ .../view/blockers/LineBlockerView.ts | 102 ++++ 7 files changed, 872 insertions(+), 129 deletions(-) create mode 100644 src/rayTracing/view/ComponentCarousel.ts create mode 100644 src/rayTracing/view/blockers/ApertureView.ts create mode 100644 src/rayTracing/view/blockers/CircleBlockerView.ts create mode 100644 src/rayTracing/view/blockers/LineBlockerView.ts diff --git a/src/rayTracing/model/SimModel.ts b/src/rayTracing/model/SimModel.ts index 3c0ece7..3a5e333 100644 --- a/src/rayTracing/model/SimModel.ts +++ b/src/rayTracing/model/SimModel.ts @@ -1,137 +1,17 @@ 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 { BeamSource } from "./light-sources/BeamSource.js"; -import { PointSourceElement } from "./light-sources/PointSourceElement.js"; -import { SingleRaySource } from "./light-sources/SingleRaySource.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). -// Light sources along the top row, mirrors on the left half, glass / lenses -// on the right half. - -function buildDemoScene(): OpticsScene { - const scene = new OpticsScene(); - - // ── Light Sources (top row) ─────────────────────────────────────────────── - - // A. 360° point source – positioned in the top-center area - scene.addElement(new PointSourceElement({ x: 200, y: 40 }, /* brightness= */ 0.6)); - - // B. Parallel beam – vertical aperture on the left edge, aimed rightward - // p1/p2 define the aperture cross-section; rays emit perpendicular (→) - scene.addElement( - new BeamSource( - { x: 420, y: 20 }, - { x: 420, y: 75 }, - /* brightness= */ 0.5, - /* wavelength= */ 532, - /* emisAngle= */ 0, - ), - ); - - // C. Single directional ray – aimed at the arc mirror below - scene.addElement( - new SingleRaySource( - { x: 600, y: 30 }, // origin - { x: 650, y: 80 }, // direction point - /* brightness= */ 1.0, - ), - ); - - // ── 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 { - /** The central optics scene containing all demo optical elements. */ + /** The central optics scene containing all optical elements. */ public readonly scene: OpticsScene; public constructor(public readonly tandem: Tandem) { - this.scene = buildDemoScene(); + this.scene = new OpticsScene(); } public reset(): void { - // Called when the user presses the reset-all button + this.scene.clearElements(); } public step(_dt: number): void { diff --git a/src/rayTracing/view/ComponentCarousel.ts b/src/rayTracing/view/ComponentCarousel.ts new file mode 100644 index 0000000..3722b45 --- /dev/null +++ b/src/rayTracing/view/ComponentCarousel.ts @@ -0,0 +1,482 @@ +/** + * ComponentCarousel.ts + * + * A Carousel-based toolbox from which the user can drag optical components + * onto the scene. Each carousel item is a small icon representing a component + * type. Pressing an icon creates a new instance of that component at the + * center of the play area, adds it to the model scene, and creates the + * corresponding interactive view in the elements layer. + */ + +import { Shape } from "scenerystack/kite"; +import { Circle, Node, Path, RichDragListener, Text } from "scenerystack/scenery"; +import { Carousel, type CarouselItem } from "scenerystack/sun"; +import { Tandem } from "scenerystack/tandem"; +import opticsLab from "../../OpticsLabNamespace.js"; +import { ApertureElement } from "../model/blockers/ApertureElement.js"; +import { CircleBlocker } from "../model/blockers/CircleBlocker.js"; +import { LineBlocker } from "../model/blockers/LineBlocker.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 { SphericalLens } from "../model/glass/SphericalLens.js"; +import { BeamSource } from "../model/light-sources/BeamSource.js"; +import { PointSourceElement } from "../model/light-sources/PointSourceElement.js"; +import { SingleRaySource } from "../model/light-sources/SingleRaySource.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"; + +// ── Icon dimensions ────────────────────────────────────────────────────────── +const ICON_SIZE = 40; +const ICON_HALF = ICON_SIZE / 2; + +// ── Element factory type ───────────────────────────────────────────────────── + +interface ComponentDescriptor { + label: string; + createIcon: () => Node; + createElement: (cx: number, cy: number) => OpticalElement; +} + +// ── Icon builders ──────────────────────────────────────────────────────────── +// Each function returns a small Node that fits in an ICON_SIZE × ICON_SIZE box, +// centered at (0, 0). + +function pointSourceIcon(): Node { + const node = new Node(); + const glow = new Circle(6, { + fill: "rgba(255, 220, 80, 0.35)", + stroke: "rgba(255, 220, 80, 0.9)", + lineWidth: 1.5, + }); + node.addChild(glow); + const spokeShape = new Shape(); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * Math.PI * 2; + spokeShape.moveTo(Math.cos(a) * 7, Math.sin(a) * 7); + spokeShape.lineTo(Math.cos(a) * 14, Math.sin(a) * 14); + } + node.addChild( + new Path(spokeShape, { + stroke: "rgba(255, 210, 60, 0.7)", + lineWidth: 1, + }), + ); + return node; +} + +function beamSourceIcon(): Node { + const node = new Node(); + // Three parallel arrows pointing right + for (let dy = -8; dy <= 8; dy += 8) { + const shape = new Shape().moveTo(-12, dy).lineTo(8, dy); + node.addChild( + new Path(shape, { + stroke: "#44ee66", + lineWidth: 1.5, + }), + ); + // Arrowhead + const arrow = new Shape() + .moveTo(5, dy - 4) + .lineTo(12, dy) + .lineTo(5, dy + 4); + node.addChild( + new Path(arrow, { + stroke: "#44ee66", + lineWidth: 1.5, + lineCap: "round", + lineJoin: "round", + }), + ); + } + return node; +} + +function singleRayIcon(): Node { + const node = new Node(); + const shape = new Shape().moveTo(-14, 0).lineTo(10, 0); + node.addChild( + new Path(shape, { + stroke: "#44ee66", + lineWidth: 2, + }), + ); + const arrow = new Shape().moveTo(6, -5).lineTo(14, 0).lineTo(6, 5); + node.addChild( + new Path(arrow, { + stroke: "#44ee66", + lineWidth: 2, + lineCap: "round", + lineJoin: "round", + }), + ); + return node; +} + +function segmentMirrorIcon(): Node { + const node = new Node(); + const shape = new Shape().moveTo(-14, 0).lineTo(14, 0); + node.addChild(new Path(shape, { stroke: "#666", lineWidth: 4, lineCap: "round" })); + node.addChild(new Path(shape, { stroke: "#d8d8d8", lineWidth: 2, lineCap: "round" })); + return node; +} + +function arcMirrorIcon(): Node { + const node = new Node(); + const shape = new Shape().arc(0, 20, 24, -Math.PI * 0.75, -Math.PI * 0.25); + node.addChild(new Path(shape, { stroke: "#666", lineWidth: 4, lineCap: "round" })); + node.addChild(new Path(shape, { stroke: "#d8d8d8", lineWidth: 2, lineCap: "round" })); + return node; +} + +function parabolicMirrorIcon(): Node { + const node = new Node(); + const shape = new Shape(); + const N = 20; + for (let i = 0; i <= N; i++) { + const t = (i / N) * 2 - 1; // -1 to 1 + const x = t * 14; + const y = -t * t * 10 + 4; + if (i === 0) { + shape.moveTo(x, y); + } else { + shape.lineTo(x, y); + } + } + node.addChild(new Path(shape, { stroke: "#666", lineWidth: 4, lineCap: "round", lineJoin: "round" })); + node.addChild(new Path(shape, { stroke: "#d8d8d8", lineWidth: 2, lineCap: "round", lineJoin: "round" })); + return node; +} + +function idealCurvedMirrorIcon(): Node { + const node = new Node(); + // Line segment with a focal dot + const lineShape = new Shape().moveTo(-14, 0).lineTo(14, 0); + node.addChild(new Path(lineShape, { stroke: "#666", lineWidth: 4, lineCap: "round" })); + node.addChild(new Path(lineShape, { stroke: "#d8d8d8", lineWidth: 2, lineCap: "round" })); + // Small 'f' dot to distinguish from flat mirror + node.addChild(new Circle(2.5, { x: 0, y: -8, fill: "#ff8844" })); + return node; +} + +function beamSplitterIcon(): Node { + const node = new Node(); + // Diagonal line + const lineShape = new Shape().moveTo(-10, 10).lineTo(10, -10); + node.addChild(new Path(lineShape, { stroke: "#999", lineWidth: 2, lineDash: [4, 3] })); + // Arrows: reflected and transmitted + const refShape = new Shape().moveTo(0, 0).lineTo(-10, -10); + node.addChild(new Path(refShape, { stroke: "#d8d8d8", lineWidth: 1.5 })); + const transShape = new Shape().moveTo(0, 0).lineTo(10, 10); + node.addChild(new Path(transShape, { stroke: "#d8d8d8", lineWidth: 1.5 })); + return node; +} + +function idealLensIcon(): Node { + const node = new Node(); + const lineShape = new Shape().moveTo(0, -14).lineTo(0, 14); + node.addChild(new Path(lineShape, { stroke: "#44cc88", lineWidth: 2.5, lineCap: "round" })); + // Arrow heads at each end + const topArrow = new Shape().moveTo(-5, -10).lineTo(0, -14).lineTo(5, -10); + const botArrow = new Shape().moveTo(-5, 10).lineTo(0, 14).lineTo(5, 10); + node.addChild(new Path(topArrow, { stroke: "#44cc88", lineWidth: 2, lineCap: "round", lineJoin: "round" })); + node.addChild(new Path(botArrow, { stroke: "#44cc88", lineWidth: 2, lineCap: "round", lineJoin: "round" })); + return node; +} + +function circleGlassIcon(): Node { + const node = new Node(); + node.addChild( + new Circle(12, { + fill: "rgba(100, 180, 255, 0.25)", + stroke: "rgba(60, 130, 210, 0.8)", + lineWidth: 1.5, + }), + ); + return node; +} + +function sphericalLensIcon(): Node { + const node = new Node(); + // Biconvex lens shape + const shape = new Shape() + .arc(-12, 0, 18, -Math.PI / 4, Math.PI / 4) + .arc(12, 0, 18, Math.PI - Math.PI / 4, Math.PI + Math.PI / 4) + .close(); + node.addChild( + new Path(shape, { + fill: "rgba(100, 180, 255, 0.25)", + stroke: "rgba(60, 130, 210, 0.8)", + lineWidth: 1.5, + }), + ); + return node; +} + +function polygonGlassIcon(): Node { + const node = new Node(); + // Triangular prism + const shape = new Shape().moveTo(0, -12).lineTo(12, 10).lineTo(-12, 10).close(); + node.addChild( + new Path(shape, { + fill: "rgba(100, 180, 255, 0.25)", + stroke: "rgba(60, 130, 210, 0.8)", + lineWidth: 1.5, + }), + ); + return node; +} + +function halfPlaneGlassIcon(): Node { + const node = new Node(); + // Horizontal boundary line + const lineShape = new Shape().moveTo(-14, 0).lineTo(14, 0); + node.addChild( + new Path(lineShape, { + stroke: "rgba(60, 130, 210, 0.8)", + lineWidth: 2, + }), + ); + // Hatching below the line + const hatchShape = new Shape(); + for (let x = -12; x <= 12; x += 5) { + hatchShape.moveTo(x, 2).lineTo(x - 4, 10); + } + node.addChild( + new Path(hatchShape, { + stroke: "rgba(60, 130, 210, 0.5)", + lineWidth: 1, + }), + ); + return node; +} + +function lineBlockerIcon(): Node { + const node = new Node(); + const shape = new Shape().moveTo(-14, 0).lineTo(14, 0); + node.addChild(new Path(shape, { stroke: "#555", lineWidth: 4, lineCap: "round" })); + node.addChild(new Path(shape, { stroke: "#222", lineWidth: 2, lineCap: "round" })); + return node; +} + +function circleBlockerIcon(): Node { + const node = new Node(); + node.addChild( + new Circle(12, { + fill: "rgba(30, 30, 30, 0.5)", + stroke: "#555", + lineWidth: 1.5, + }), + ); + return node; +} + +function apertureIcon(): Node { + const node = new Node(); + // Two segments with a gap + const left = new Shape().moveTo(-14, 0).lineTo(-4, 0); + const right = new Shape().moveTo(4, 0).lineTo(14, 0); + node.addChild(new Path(left, { stroke: "#555", lineWidth: 4, lineCap: "round" })); + node.addChild(new Path(left, { stroke: "#222", lineWidth: 2, lineCap: "round" })); + node.addChild(new Path(right, { stroke: "#555", lineWidth: 4, lineCap: "round" })); + node.addChild(new Path(right, { stroke: "#222", lineWidth: 2, lineCap: "round" })); + return node; +} + +// ── Component descriptors ──────────────────────────────────────────────────── +// Each descriptor provides label, icon factory, and model element factory. +// The cx, cy arguments to createElement are the center of the play area where +// the new element should be placed. + +function getComponentDescriptors(): ComponentDescriptor[] { + const S = 60; // default half-size for new elements + return [ + // ── Light Sources ────────────────────────────────────────────────────── + { + label: "Point Source", + createIcon: pointSourceIcon, + createElement: (cx, cy) => new PointSourceElement({ x: cx, y: cy }, 0.6), + }, + { + label: "Beam", + createIcon: beamSourceIcon, + createElement: (cx, cy) => new BeamSource({ x: cx, y: cy - S / 2 }, { x: cx, y: cy + S / 2 }, 0.5, 532, 0), + }, + { + label: "Single Ray", + createIcon: singleRayIcon, + createElement: (cx, cy) => new SingleRaySource({ x: cx - S / 2, y: cy }, { x: cx + S / 2, y: cy }, 1.0), + }, + + // ── Mirrors ──────────────────────────────────────────────────────────── + { + label: "Flat Mirror", + createIcon: segmentMirrorIcon, + createElement: (cx, cy) => new SegmentMirror({ x: cx - S, y: cy }, { x: cx + S, y: cy }), + }, + { + label: "Arc Mirror", + createIcon: arcMirrorIcon, + createElement: (cx, cy) => new ArcMirror({ x: cx - S, y: cy }, { x: cx + S, y: cy }, { x: cx, y: cy - S * 0.5 }), + }, + { + label: "Parabolic Mirror", + createIcon: parabolicMirrorIcon, + createElement: (cx, cy) => + new ParabolicMirror({ x: cx - S, y: cy }, { x: cx + S, y: cy }, { x: cx, y: cy - S * 0.5 }), + }, + { + label: "Ideal Mirror", + createIcon: idealCurvedMirrorIcon, + createElement: (cx, cy) => new IdealCurvedMirror({ x: cx - S, y: cy }, { x: cx + S, y: cy }, 80), + }, + { + label: "Beam Splitter", + createIcon: beamSplitterIcon, + createElement: (cx, cy) => + new BeamSplitterElement({ x: cx - S * 0.7, y: cy + S * 0.7 }, { x: cx + S * 0.7, y: cy - S * 0.7 }, 0.5), + }, + + // ── Lenses / Glass ───────────────────────────────────────────────────── + { + label: "Ideal Lens", + createIcon: idealLensIcon, + createElement: (cx, cy) => new IdealLens({ x: cx, y: cy - S }, { x: cx, y: cy + S }, 120), + }, + { + label: "Circle Glass", + createIcon: circleGlassIcon, + createElement: (cx, cy) => new CircleGlass({ x: cx, y: cy }, { x: cx + S * 0.7, y: cy }, 1.5), + }, + { + label: "Spherical Lens", + createIcon: sphericalLensIcon, + createElement: (cx, cy) => new SphericalLens({ x: cx, y: cy - S }, { x: cx, y: cy + S }, 120, -120, 1.5), + }, + { + label: "Prism", + createIcon: polygonGlassIcon, + createElement: (cx, cy) => + new PolygonGlass( + [ + { x: cx, y: cy - S * 0.8 }, + { x: cx + S * 0.7, y: cy + S * 0.6 }, + { x: cx - S * 0.7, y: cy + S * 0.6 }, + ], + 1.5, + ), + }, + { + label: "Half-Plane Glass", + createIcon: halfPlaneGlassIcon, + createElement: (cx, cy) => new HalfPlaneGlass({ x: cx - S * 1.5, y: cy }, { x: cx + S * 1.5, y: cy }, 1.5), + }, + + // ── Blockers ─────────────────────────────────────────────────────────── + { + label: "Line Blocker", + createIcon: lineBlockerIcon, + createElement: (cx, cy) => new LineBlocker({ x: cx - S, y: cy }, { x: cx + S, y: cy }), + }, + { + label: "Circle Blocker", + createIcon: circleBlockerIcon, + createElement: (cx, cy) => new CircleBlocker({ x: cx, y: cy }, { x: cx + S * 0.5, y: cy }), + }, + { + label: "Aperture", + createIcon: apertureIcon, + createElement: (cx, cy) => + new ApertureElement( + { x: cx - S, y: cy }, + { x: cx + S, y: cy }, + { x: cx - S * 0.2, y: cy }, + { x: cx + S * 0.2, y: cy }, + ), + }, + ]; +} + +// ── Callback type ──────────────────────────────────────────────────────────── + +export type AddElementCallback = (element: OpticalElement) => void; + +// ── Carousel builder ───────────────────────────────────────────────────────── + +/** + * Creates a Carousel containing icons for every available optical component. + * When the user clicks an icon a new element is created and handed to + * `onAddElement`, which should add it to the model and create its view. + * + * @param onAddElement - called with the newly created OpticalElement + * @param getCenterX - returns x coordinate of the play area center + * @param getCenterY - returns y coordinate of the play area center + */ +export function createComponentCarousel( + onAddElement: AddElementCallback, + getCenterX: () => number, + getCenterY: () => number, +): Carousel { + const descriptors = getComponentDescriptors(); + + const carouselItems: CarouselItem[] = descriptors.map((descriptor) => ({ + createNode: () => { + // Wrap the icon and label in a container node + const icon = descriptor.createIcon(); + const label = new Text(descriptor.label, { + font: "11px sans-serif", + fill: "#ccc", + maxWidth: ICON_SIZE + 20, + }); + + const container = new Node({ + children: [icon, label], + cursor: "pointer", + }); + + // Position label below the icon + label.centerX = icon.centerX; + label.top = ICON_HALF + 2; + + // On press, create a new element at the play area center + container.addInputListener( + new RichDragListener({ + tandem: Tandem.OPT_OUT, + start: () => { + const element = descriptor.createElement(getCenterX(), getCenterY()); + onAddElement(element); + }, + }), + ); + + return container; + }, + })); + + const carousel = new Carousel(carouselItems, { + orientation: "vertical", + itemsPerPage: 6, + spacing: 10, + margin: 8, + fill: "rgba(25, 25, 45, 0.90)", + stroke: "rgba(120, 120, 140, 1)", + cornerRadius: 8, + buttonOptions: { + baseColor: "rgba(80, 80, 100, 0.6)", + arrowPathOptions: { + stroke: "#ccc", + lineWidth: 2, + }, + }, + }); + + return carousel; +} + +opticsLab.register("ComponentCarousel", { createComponentCarousel }); diff --git a/src/rayTracing/view/OpticalElementViewFactory.ts b/src/rayTracing/view/OpticalElementViewFactory.ts index eb71979..2804e0f 100644 --- a/src/rayTracing/view/OpticalElementViewFactory.ts +++ b/src/rayTracing/view/OpticalElementViewFactory.ts @@ -7,6 +7,9 @@ import type { Node } from "scenerystack/scenery"; import opticsLab from "../../OpticsLabNamespace.js"; +import { ApertureElement } from "../model/blockers/ApertureElement.js"; +import { CircleBlocker } from "../model/blockers/CircleBlocker.js"; +import { LineBlocker } from "../model/blockers/LineBlocker.js"; import { CircleGlass } from "../model/glass/CircleGlass.js"; import { HalfPlaneGlass } from "../model/glass/HalfPlaneGlass.js"; import { IdealLens } from "../model/glass/IdealLens.js"; @@ -20,6 +23,9 @@ 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 { ApertureView } from "./blockers/ApertureView.js"; +import { CircleBlockerView } from "./blockers/CircleBlockerView.js"; +import { LineBlockerView } from "./blockers/LineBlockerView.js"; import { CircleGlassView } from "./glass/CircleGlassView.js"; import { HalfPlaneGlassView } from "./glass/HalfPlaneGlassView.js"; import { IdealLensView } from "./glass/IdealLensView.js"; @@ -83,7 +89,17 @@ export function createOpticalElementView(element: OpticalElement): Node | null { return new HalfPlaneGlassView(element); } - // Blockers are not handled here. + // ── Blockers ────────────────────────────────────────────────────────────── + if (element instanceof ApertureElement) { + return new ApertureView(element); + } + if (element instanceof CircleBlocker) { + return new CircleBlockerView(element); + } + if (element instanceof LineBlocker) { + return new LineBlockerView(element); + } + return null; } diff --git a/src/rayTracing/view/SimScreenView.ts b/src/rayTracing/view/SimScreenView.ts index 065ee0c..660f2da 100644 --- a/src/rayTracing/view/SimScreenView.ts +++ b/src/rayTracing/view/SimScreenView.ts @@ -5,12 +5,14 @@ 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 { createComponentCarousel } from "./ComponentCarousel.js"; import { createOpticalElementView } from "./OpticalElementViewFactory.js"; import { RayPropagationView } from "./RayPropagationView.js"; export class SimScreenView extends ScreenView { private readonly model: SimModel; private readonly rayPropagationView: RayPropagationView; + private readonly elementsLayer: Node; public constructor(model: SimModel, _opticsLabPreferences: OpticsLabPreferencesModel, options?: ScreenViewOptions) { super(options); @@ -23,19 +25,37 @@ export class SimScreenView extends ScreenView { this.addChild(this.rayPropagationView); // ── Optical Elements Layer ────────────────────────────────────────────── - // Create a Scenery node for every optical element in the scene. - const elementsLayer = new Node({ + this.elementsLayer = new Node({ ...(tandem && { tandem: tandem.createTandem("elementsLayer") }), }); for (const element of model.scene.getAllElements()) { const elementView = createOpticalElementView(element); if (elementView) { - elementsLayer.addChild(elementView); + this.elementsLayer.addChild(elementView); } } - this.addChild(elementsLayer); + this.addChild(this.elementsLayer); + + // ── Component Carousel (toolbox) ───────────────────────────────────────── + const carousel = createComponentCarousel( + (element) => { + // Add to model + model.scene.addElement(element); + + // Create and add corresponding view + const view = createOpticalElementView(element); + if (view) { + this.elementsLayer.addChild(view); + } + }, + () => this.layoutBounds.centerX, + () => this.layoutBounds.centerY, + ); + carousel.left = this.layoutBounds.minX + 8; + carousel.centerY = this.layoutBounds.centerY; + this.addChild(carousel); // ── Reset Button ──────────────────────────────────────────────────────── const resetAllButton = new ResetAllButton({ @@ -54,7 +74,8 @@ export class SimScreenView extends ScreenView { } public reset(): void { - // Called when the user presses the reset-all button + // Remove all element views and clear the scene + this.elementsLayer.removeAllChildren(); } public override step(_dt: number): void { diff --git a/src/rayTracing/view/blockers/ApertureView.ts b/src/rayTracing/view/blockers/ApertureView.ts new file mode 100644 index 0000000..b25afe6 --- /dev/null +++ b/src/rayTracing/view/blockers/ApertureView.ts @@ -0,0 +1,145 @@ +/** + * ApertureView.ts + * + * Scenery node for an aperture element. Rendered as two dark line segments + * with a gap between them. Handles at p1, p2, p3, p4 allow reshaping. + */ + +import { Shape } from "scenerystack/kite"; +import { type Circle, Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { ApertureElement } from "../../model/blockers/ApertureElement.js"; +import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; + +const BACK_STROKE = "#555"; +const BACK_WIDTH = 5; +const FRONT_STROKE = "#222"; +const FRONT_WIDTH = 2.5; + +export class ApertureView extends Node { + private readonly backPath: Path; + private readonly frontPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + private readonly handle3: Circle; + private readonly handle4: Circle; + + public constructor(private readonly aperture: ApertureElement) { + 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(aperture.p1); + this.handle2 = createHandle(aperture.p2); + this.handle3 = createHandle(aperture.p3); + this.handle4 = createHandle(aperture.p4); + + this.addChild(this.backPath); + this.addChild(this.frontPath); + this.addChild(this.handle1); + this.addChild(this.handle2); + this.addChild(this.handle3); + this.addChild(this.handle4); + + this.rebuild(); + + attachTranslationDrag( + this.backPath, + [ + { + get: () => aperture.p1, + set: (p) => { + aperture.p1 = p; + }, + }, + { + get: () => aperture.p2, + set: (p) => { + aperture.p2 = p; + }, + }, + { + get: () => aperture.p3, + set: (p) => { + aperture.p3 = p; + }, + }, + { + get: () => aperture.p4, + set: (p) => { + aperture.p4 = p; + }, + }, + ], + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle1, + () => aperture.p1, + (p) => { + aperture.p1 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle2, + () => aperture.p2, + (p) => { + aperture.p2 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle3, + () => aperture.p3, + (p) => { + aperture.p3 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle4, + () => aperture.p4, + (p) => { + aperture.p4 = p; + }, + () => { + this.rebuild(); + }, + ); + } + + private rebuild(): void { + const { p1, p2, p3, p4 } = this.aperture; + // Two segments: p1→p3 and p4→p2 (gap between p3 and p4) + const shape = new Shape().moveTo(p1.x, p1.y).lineTo(p3.x, p3.y).moveTo(p4.x, p4.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; + this.handle3.x = p3.x; + this.handle3.y = p3.y; + this.handle4.x = p4.x; + this.handle4.y = p4.y; + } +} + +opticsLab.register("ApertureView", ApertureView); diff --git a/src/rayTracing/view/blockers/CircleBlockerView.ts b/src/rayTracing/view/blockers/CircleBlockerView.ts new file mode 100644 index 0000000..45978f8 --- /dev/null +++ b/src/rayTracing/view/blockers/CircleBlockerView.ts @@ -0,0 +1,97 @@ +/** + * CircleBlockerView.ts + * + * Scenery node for a circular blocker. Rendered as a dark filled circle. + * A center handle translates the whole blocker; 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 { CircleBlocker } from "../../model/blockers/CircleBlocker.js"; +import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; + +const BLOCKER_FILL = "rgba(30, 30, 30, 0.5)"; +const BLOCKER_STROKE = "#555"; +const BLOCKER_STROKE_WIDTH = 1.5; + +export class CircleBlockerView extends Node { + private readonly circlePath: Path; + private readonly handleCenter: Circle; + private readonly handleBoundary: Circle; + + public constructor(private readonly blocker: CircleBlocker) { + super(); + + this.circlePath = new Path(null, { + fill: BLOCKER_FILL, + stroke: BLOCKER_STROKE, + lineWidth: BLOCKER_STROKE_WIDTH, + }); + this.handleCenter = createHandle(blocker.p1); + this.handleBoundary = createHandle(blocker.p2); + + this.addChild(this.circlePath); + this.addChild(this.handleCenter); + this.addChild(this.handleBoundary); + + this.rebuild(); + + attachTranslationDrag( + this.circlePath, + [ + { + get: () => blocker.p1, + set: (p) => { + blocker.p1 = p; + }, + }, + { + get: () => blocker.p2, + set: (p) => { + blocker.p2 = p; + }, + }, + ], + () => { + this.rebuild(); + }, + ); + + attachEndpointDrag( + this.handleCenter, + () => blocker.p1, + (newP1) => { + const oldP1 = blocker.p1; + blocker.p2 = { x: blocker.p2.x + (newP1.x - oldP1.x), y: blocker.p2.y + (newP1.y - oldP1.y) }; + blocker.p1 = newP1; + }, + () => { + this.rebuild(); + }, + ); + + attachEndpointDrag( + this.handleBoundary, + () => blocker.p2, + (p) => { + blocker.p2 = p; + }, + () => { + this.rebuild(); + }, + ); + } + + private rebuild(): void { + const { p1, p2 } = this.blocker; + 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("CircleBlockerView", CircleBlockerView); diff --git a/src/rayTracing/view/blockers/LineBlockerView.ts b/src/rayTracing/view/blockers/LineBlockerView.ts new file mode 100644 index 0000000..e3f9d89 --- /dev/null +++ b/src/rayTracing/view/blockers/LineBlockerView.ts @@ -0,0 +1,102 @@ +/** + * LineBlockerView.ts + * + * Scenery node for a line-segment blocker. Rendered as a dark opaque line. + * Endpoint handles and a body-drag region let the user reshape and reposition. + */ + +import { Shape } from "scenerystack/kite"; +import { type Circle, Node, Path } from "scenerystack/scenery"; +import opticsLab from "../../../OpticsLabNamespace.js"; +import type { LineBlocker } from "../../model/blockers/LineBlocker.js"; +import { attachEndpointDrag, attachTranslationDrag, createHandle } from "../ViewHelpers.js"; + +const BACK_STROKE = "#555"; +const BACK_WIDTH = 5; +const FRONT_STROKE = "#222"; +const FRONT_WIDTH = 2.5; + +export class LineBlockerView extends Node { + private readonly backPath: Path; + private readonly frontPath: Path; + private readonly handle1: Circle; + private readonly handle2: Circle; + + public constructor(private readonly blocker: LineBlocker) { + 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(blocker.p1); + this.handle2 = createHandle(blocker.p2); + + this.addChild(this.backPath); + this.addChild(this.frontPath); + this.addChild(this.handle1); + this.addChild(this.handle2); + + this.rebuild(); + + attachTranslationDrag( + this.backPath, + [ + { + get: () => blocker.p1, + set: (p) => { + blocker.p1 = p; + }, + }, + { + get: () => blocker.p2, + set: (p) => { + blocker.p2 = p; + }, + }, + ], + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle1, + () => blocker.p1, + (p) => { + blocker.p1 = p; + }, + () => { + this.rebuild(); + }, + ); + attachEndpointDrag( + this.handle2, + () => blocker.p2, + (p) => { + blocker.p2 = p; + }, + () => { + this.rebuild(); + }, + ); + } + + private rebuild(): void { + const { p1, p2 } = this.blocker; + 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("LineBlockerView", LineBlockerView);