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);