diff --git a/package-lock.json b/package-lock.json index b4fa6e7..2e3c4db 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", @@ -3191,7 +3190,6 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3229,7 +3227,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3454,7 +3451,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6347,7 +6343,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", @@ -6413,7 +6408,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6674,7 +6668,6 @@ "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", @@ -7075,7 +7068,6 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/src/common/view/ComponentCarousel.ts b/src/common/view/ComponentCarousel.ts index de9fc47..2710090 100644 --- a/src/common/view/ComponentCarousel.ts +++ b/src/common/view/ComponentCarousel.ts @@ -6,14 +6,15 @@ * type. Pressing an icon creates a new instance of that component at the * pointer position in MODEL coordinates, adds it to the model scene, and * creates the corresponding interactive view in the elements layer. + * + * Icon SVG builders live in ./carousel/CarouselIcons.ts to keep this file + * focused on carousel orchestration. */ import type { ReadOnlyProperty } from "scenerystack/axon"; import type { Vector2 } from "scenerystack/dot"; -import { Shape } from "scenerystack/kite"; import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import { Circle, Node, Path, type PressListenerEvent, RichDragListener, Text } from "scenerystack/scenery"; -import { VisibleColor } from "scenerystack/scenery-phet"; +import { Node, type PressListenerEvent, RichDragListener, Text } from "scenerystack/scenery"; import { Carousel, type CarouselItem } from "scenerystack/sun"; import { StringManager } from "../../i18n/StringManager.js"; import OpticsLabColors from "../../OpticsLabColors.js"; @@ -24,7 +25,6 @@ import { CAROUSEL_ITEM_MARGIN, CAROUSEL_ITEM_SPACING, CAROUSEL_ITEMS_PER_PAGE, - CONT_SPECTRUM_SAMPLE_WL, } from "../../OpticsLabConstants.js"; import opticsLab from "../../OpticsLabNamespace.js"; import { ApertureElement } from "../model/blockers/ApertureElement.js"; @@ -59,7 +59,40 @@ 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 type { OpticalElementView } from "./OpticalElementViewFactory.js"; +import { + apertureIcon, + arcMirrorIcon, + arcSourceIcon, + beamSourceIcon, + beamSplitterIcon, + biconcaveLensIcon, + biconvexLensIcon, + circleGlassIcon, + contSpectrumIcon, + detectorIcon, + dovePrismIcon, + equilateralPrismIcon, + halfPlaneGlassIcon, + idealCurvedMirrorIcon, + idealLensIcon, + lineBlockerIcon, + parabolicMirrorIcon, + parallelogramPrismIcon, + planoConcaveLensIcon, + planoConvexLensIcon, + pointSourceIcon, + polygonGlassIcon, + porroPrismIcon, + reflectionGratingIcon, + rightAnglePrismIcon, + segmentMirrorIcon, + singleRayIcon, + slabGlassIcon, + sphericalLensIcon, + trackIcon, + transmissionGratingIcon, +} from "./carousel/CarouselIcons.js"; +import type { OpticalElementView } from "./ElementRegistry.js"; // ── Icon dimensions ────────────────────────────────────────────────────────── const ICON_SIZE = CAROUSEL_ICON_SIZE_PX; @@ -109,834 +142,6 @@ interface ComponentDescriptor { createElement: (cx: number, cy: number) => OpticalElement; } -// ── Icon builders ──────────────────────────────────────────────────────────── - -function pointSourceIcon(): Node { - const node = new Node(); - const coreGlowRadius = 6; - const glowOutlineWidth = 1.5; - const spokeCount = 8; - const spokeInnerRadius = 7; - const spokeOuterRadius = 14; - const spokeLineWidth = 1; - const fullTurnRadians = Math.PI * 2; - - const glow = new Circle(coreGlowRadius, { - fill: OpticsLabColors.arcSourceGlowFillProperty, - stroke: OpticsLabColors.arcSourceGlowStrokeProperty, - lineWidth: glowOutlineWidth, - }); - node.addChild(glow); - const spokeShape = new Shape(); - for (let i = 0; i < spokeCount; i++) { - const a = (i / spokeCount) * fullTurnRadians; - spokeShape.moveTo(Math.cos(a) * spokeInnerRadius, Math.sin(a) * spokeInnerRadius); - spokeShape.lineTo(Math.cos(a) * spokeOuterRadius, Math.sin(a) * spokeOuterRadius); - } - node.addChild( - new Path(spokeShape, { - stroke: OpticsLabColors.arcSourceSpokeStrokeProperty, - lineWidth: spokeLineWidth, - }), - ); - return node; -} - -function arcSourceIcon(): Node { - const node = new Node(); - const arcRadius = 14; - const rimHalfAngleTurns = 0.6; - const sectorHalfAngleTurns = 0.3; - const rimLineWidth = 1; - const sectorLineWidth = 1.2; - const spokeIndexMin = -2; - const spokeIndexMax = 2; - const spokeIndexScale = 2; - const spokeFanHalfAngleTurns = 0.55; - const spokeInnerRadius = 4; - const spokeOuterRadius = 12; - const spokeLineWidth = 1; - const coreRadius = 4; - const coreOutlineWidth = 1.5; - - const rimShape = new Shape().arc(0, 0, arcRadius, -Math.PI * rimHalfAngleTurns, Math.PI * rimHalfAngleTurns, false); - node.addChild(new Path(rimShape, { stroke: OpticsLabColors.arcSourceRimStrokeProperty, lineWidth: rimLineWidth })); - const sectorShape = new Shape() - .moveTo(0, 0) - .arc(0, 0, arcRadius, -Math.PI * sectorHalfAngleTurns, Math.PI * sectorHalfAngleTurns, false) - .close(); - node.addChild( - new Path(sectorShape, { - fill: OpticsLabColors.arcSourceSectorFillProperty, - stroke: OpticsLabColors.arcSourceSectorStrokeProperty, - lineWidth: sectorLineWidth, - }), - ); - const spokeShape = new Shape(); - for (let i = spokeIndexMin; i <= spokeIndexMax; i++) { - const a = (i / spokeIndexScale) * Math.PI * spokeFanHalfAngleTurns; - spokeShape.moveTo(Math.cos(a) * spokeInnerRadius, Math.sin(a) * spokeInnerRadius); - spokeShape.lineTo(Math.cos(a) * spokeOuterRadius, Math.sin(a) * spokeOuterRadius); - } - node.addChild( - new Path(spokeShape, { stroke: OpticsLabColors.arcSourceSpokeStrokeProperty, lineWidth: spokeLineWidth }), - ); - node.addChild( - new Circle(coreRadius, { - fill: OpticsLabColors.arcSourceGlowFillProperty, - stroke: OpticsLabColors.arcSourceGlowStrokeProperty, - lineWidth: coreOutlineWidth, - }), - ); - return node; -} - -function beamSourceIcon(): Node { - const node = new Node(); - const rayVerticalSpacing = 8; - const rayVerticalMin = -rayVerticalSpacing; - const rayVerticalMax = rayVerticalSpacing; - const rayTailX = -12; - const rayHeadX = 8; - const rayLineWidth = 1.5; - const arrowBaseX = 5; - const arrowTipX = 12; - const arrowHalfHeight = 4; - - for (let dy = rayVerticalMin; dy <= rayVerticalMax; dy += rayVerticalSpacing) { - const shape = new Shape().moveTo(rayTailX, dy).lineTo(rayHeadX, dy); - node.addChild(new Path(shape, { stroke: OpticsLabColors.iconRayStrokeProperty, lineWidth: rayLineWidth })); - const arrow = new Shape() - .moveTo(arrowBaseX, dy - arrowHalfHeight) - .lineTo(arrowTipX, dy) - .lineTo(arrowBaseX, dy + arrowHalfHeight); - node.addChild( - new Path(arrow, { - stroke: OpticsLabColors.iconRayStrokeProperty, - lineWidth: rayLineWidth, - lineCap: "round", - lineJoin: "round", - }), - ); - } - return node; -} - -function singleRayIcon(): Node { - const node = new Node(); - const tailX = -14; - const shaftEndX = 10; - const rayLineWidth = 2; - const arrowBaseX = 6; - const arrowTipX = 14; - const arrowHalfHeight = 5; - - const shape = new Shape().moveTo(tailX, 0).lineTo(shaftEndX, 0); - node.addChild(new Path(shape, { stroke: OpticsLabColors.iconRayStrokeProperty, lineWidth: rayLineWidth })); - const arrow = new Shape() - .moveTo(arrowBaseX, -arrowHalfHeight) - .lineTo(arrowTipX, 0) - .lineTo(arrowBaseX, arrowHalfHeight); - node.addChild( - new Path(arrow, { - stroke: OpticsLabColors.iconRayStrokeProperty, - lineWidth: rayLineWidth, - lineCap: "round", - lineJoin: "round", - }), - ); - return node; -} - -function contSpectrumIcon(): Node { - const node = new Node(); - const spectrumArcRadius = 9; - const spectrumStrokeAlpha = 0.9; - const spectrumArcLineWidth = 2.5; - const directionShaftLength = 14; - const directionLineWidth = 1.5; - const directionArrowBaseX = 10; - const directionArrowTipX = 14; - const directionArrowHalfHeight = 4; - const fullTurnRadians = Math.PI * 2; - - // Rainbow disc: one arc per sampled wavelength. - const arcSpan = fullTurnRadians / CONT_SPECTRUM_SAMPLE_WL.length; - for (let i = 0; i < CONT_SPECTRUM_SAMPLE_WL.length; i++) { - const wl = CONT_SPECTRUM_SAMPLE_WL[i] ?? CONT_SPECTRUM_SAMPLE_WL[0]; - const c = VisibleColor.wavelengthToColor(wl); - const shape = new Shape().arc(0, 0, spectrumArcRadius, i * arcSpan, (i + 1) * arcSpan); - node.addChild( - new Path(shape, { - stroke: `rgba(${c.r},${c.g},${c.b},${spectrumStrokeAlpha})`, - lineWidth: spectrumArcLineWidth, - }), - ); - } - const line = new Shape().moveTo(0, 0).lineTo(directionShaftLength, 0); - node.addChild(new Path(line, { stroke: OpticsLabColors.sourceDirLineStrokeProperty, lineWidth: directionLineWidth })); - const arrow = new Shape() - .moveTo(directionArrowBaseX, -directionArrowHalfHeight) - .lineTo(directionArrowTipX, 0) - .lineTo(directionArrowBaseX, directionArrowHalfHeight); - node.addChild( - new Path(arrow, { - stroke: OpticsLabColors.sourceDirArrowStrokeProperty, - lineWidth: directionLineWidth, - lineCap: "round", - lineJoin: "round", - }), - ); - return node; -} - -function segmentMirrorIcon(): Node { - const node = new Node(); - const halfLength = 14; - const backLineWidth = 4; - const frontLineWidth = 2; - - const shape = new Shape().moveTo(-halfLength, 0).lineTo(halfLength, 0); - node.addChild( - new Path(shape, { stroke: OpticsLabColors.mirrorBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), - ); - node.addChild( - new Path(shape, { stroke: OpticsLabColors.mirrorFrontStrokeProperty, lineWidth: frontLineWidth, lineCap: "round" }), - ); - return node; -} - -function arcMirrorIcon(): Node { - const node = new Node(); - const arcCenterYOffset = 20; - const arcRadius = 24; - const arcStartAngleTurns = -0.75; - const arcEndAngleTurns = -0.25; - const backLineWidth = 4; - const frontLineWidth = 2; - - const shape = new Shape().arc( - 0, - arcCenterYOffset, - arcRadius, - Math.PI * arcStartAngleTurns, - Math.PI * arcEndAngleTurns, - ); - node.addChild( - new Path(shape, { stroke: OpticsLabColors.mirrorBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), - ); - node.addChild( - new Path(shape, { stroke: OpticsLabColors.mirrorFrontStrokeProperty, lineWidth: frontLineWidth, lineCap: "round" }), - ); - return node; -} - -function parabolicMirrorIcon(): Node { - const node = new Node(); - const shape = new Shape(); - const segmentCount = 20; - const horizontalHalfExtent = 14; - const parabolaDepth = 10; - const parabolaVerticalOffset = 4; - const backLineWidth = 4; - const frontLineWidth = 2; - - for (let i = 0; i <= segmentCount; i++) { - const t = (i / segmentCount) * 2 - 1; - const x = t * horizontalHalfExtent; - const y = -t * t * parabolaDepth + parabolaVerticalOffset; - if (i === 0) { - shape.moveTo(x, y); - } else { - shape.lineTo(x, y); - } - } - node.addChild( - new Path(shape, { - stroke: OpticsLabColors.mirrorBackStrokeProperty, - lineWidth: backLineWidth, - lineCap: "round", - lineJoin: "round", - }), - ); - node.addChild( - new Path(shape, { - stroke: OpticsLabColors.mirrorFrontStrokeProperty, - lineWidth: frontLineWidth, - lineCap: "round", - lineJoin: "round", - }), - ); - return node; -} - -function idealCurvedMirrorIcon(): Node { - const node = new Node(); - const halfLength = 14; - const backLineWidth = 4; - const frontLineWidth = 2; - const focalPointMarkerRadius = 2.5; - const focalPointMarkerYOffset = -8; - - const lineShape = new Shape().moveTo(-halfLength, 0).lineTo(halfLength, 0); - node.addChild( - new Path(lineShape, { - stroke: OpticsLabColors.mirrorBackStrokeProperty, - lineWidth: backLineWidth, - lineCap: "round", - }), - ); - node.addChild( - new Path(lineShape, { - stroke: OpticsLabColors.mirrorFrontStrokeProperty, - lineWidth: frontLineWidth, - lineCap: "round", - }), - ); - node.addChild( - new Circle(focalPointMarkerRadius, { - x: 0, - y: focalPointMarkerYOffset, - fill: OpticsLabColors.pointSourceFillProperty, - }), - ); - return node; -} - -function beamSplitterIcon(): Node { - const node = new Node(); - const plateHalfExtent = 10; - const bodyLineWidth = 2; - const dashOnLength = 4; - const dashGapLength = 3; - const reflectedRayLineWidth = 1.5; - - const lineShape = new Shape().moveTo(-plateHalfExtent, plateHalfExtent).lineTo(plateHalfExtent, -plateHalfExtent); - node.addChild( - new Path(lineShape, { - stroke: OpticsLabColors.beamSplitterIconBodyStrokeProperty, - lineWidth: bodyLineWidth, - lineDash: [dashOnLength, dashGapLength], - }), - ); - node.addChild( - new Path(new Shape().moveTo(0, 0).lineTo(-plateHalfExtent, -plateHalfExtent), { - stroke: OpticsLabColors.mirrorFrontStrokeProperty, - lineWidth: reflectedRayLineWidth, - }), - ); - node.addChild( - new Path(new Shape().moveTo(0, 0).lineTo(plateHalfExtent, plateHalfExtent), { - stroke: OpticsLabColors.mirrorFrontStrokeProperty, - lineWidth: reflectedRayLineWidth, - }), - ); - return node; -} - -function idealLensIcon(): Node { - const node = new Node(); - const halfHeight = 14; - const verticalLineWidth = 2.5; - const arrowLineWidth = 2; - const arrowHalfWidth = 5; - const arrowTipInsetFromEnd = 4; - - const lineShape = new Shape().moveTo(0, -halfHeight).lineTo(0, halfHeight); - node.addChild( - new Path(lineShape, { - stroke: OpticsLabColors.idealLensStrokeProperty, - lineWidth: verticalLineWidth, - lineCap: "round", - }), - ); - const topArrowTipY = -halfHeight; - const topArrowBaseY = topArrowTipY + arrowTipInsetFromEnd; - const botArrowTipY = halfHeight; - const botArrowBaseY = botArrowTipY - arrowTipInsetFromEnd; - const topArrow = new Shape() - .moveTo(-arrowHalfWidth, topArrowBaseY) - .lineTo(0, topArrowTipY) - .lineTo(arrowHalfWidth, topArrowBaseY); - const botArrow = new Shape() - .moveTo(-arrowHalfWidth, botArrowBaseY) - .lineTo(0, botArrowTipY) - .lineTo(arrowHalfWidth, botArrowBaseY); - node.addChild( - new Path(topArrow, { - stroke: OpticsLabColors.idealLensStrokeProperty, - lineWidth: arrowLineWidth, - lineCap: "round", - lineJoin: "round", - }), - ); - node.addChild( - new Path(botArrow, { - stroke: OpticsLabColors.idealLensStrokeProperty, - lineWidth: arrowLineWidth, - lineCap: "round", - lineJoin: "round", - }), - ); - return node; -} - -function circleGlassIcon(): Node { - const node = new Node(); - const discRadius = 12; - const outlineWidth = 1.5; - - node.addChild( - new Circle(discRadius, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function sphericalLensIcon(): Node { - const node = new Node(); - const lensCenterOffsetX = 12; - const surfaceArcRadius = 18; - const arcHalfAngle = Math.PI / 4; - const outlineWidth = 1.5; - - const shape = new Shape() - .arc(-lensCenterOffsetX, 0, surfaceArcRadius, -arcHalfAngle, arcHalfAngle) - .arc(lensCenterOffsetX, 0, surfaceArcRadius, Math.PI - arcHalfAngle, Math.PI + arcHalfAngle) - .close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function biconvexLensIcon(): Node { - const node = new Node(); - const lensCenterOffsetX = 8; - const surfaceArcRadius = 14; - const arcHalfAngle = Math.PI / 3.5; - const outlineWidth = 1.5; - - const shape = new Shape() - .arc(-lensCenterOffsetX, 0, surfaceArcRadius, -arcHalfAngle, arcHalfAngle) - .arc(lensCenterOffsetX, 0, surfaceArcRadius, Math.PI - arcHalfAngle, Math.PI + arcHalfAngle) - .close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function biconcaveLensIcon(): Node { - const node = new Node(); - const lensCenterOffsetX = 18; - const surfaceArcRadius = 12; - const arcHalfAngle = Math.PI / 2.5; - const outlineWidth = 1.5; - - const shape = new Shape() - .arc(lensCenterOffsetX, 0, surfaceArcRadius, Math.PI - arcHalfAngle, Math.PI + arcHalfAngle) - .arc(-lensCenterOffsetX, 0, surfaceArcRadius, -arcHalfAngle, arcHalfAngle) - .close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function planoConvexLensIcon(): Node { - const node = new Node(); - const halfHeight = 12; - const flatEdgeX = 10; - const convexArcCenterX = 16; - const convexDepth = 20; - const outlineWidth = 1.5; - - const shape = new Shape() - .moveTo(flatEdgeX, -halfHeight) - .arc( - convexArcCenterX, - 0, - Math.hypot(convexDepth, halfHeight), - -Math.atan2(halfHeight, convexDepth), - +Math.atan2(halfHeight, convexDepth), - false, - ) - .lineTo(flatEdgeX, halfHeight) - .close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function planoConcaveLensIcon(): Node { - const node = new Node(); - const halfHeight = 12; - const flatEdgeX = -4; - const concaveArcCenterX = 16; - const concaveDepth = 10; - const outlineWidth = 1.5; - - const shape = new Shape() - .moveTo(flatEdgeX, halfHeight) - .arc( - concaveArcCenterX, - 0, - Math.hypot(concaveDepth, halfHeight), - Math.PI - Math.atan2(halfHeight, concaveDepth), - Math.PI + Math.atan2(halfHeight, concaveDepth), - ) - .lineTo(flatEdgeX, -halfHeight) - .close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function polygonGlassIcon(): Node { - const node = new Node(); - const apexY = -12; - const baseHalfWidth = 12; - const baseY = 10; - const outlineWidth = 1.5; - - const shape = new Shape().moveTo(0, apexY).lineTo(baseHalfWidth, baseY).lineTo(-baseHalfWidth, baseY).close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function equilateralPrismIcon(): Node { - const node = new Node(); - const apexY = -12; - const baseCornerOffsetX = 11; - const baseY = 9; - const outlineWidth = 1.5; - - const shape = new Shape().moveTo(0, apexY).lineTo(baseCornerOffsetX, baseY).lineTo(-baseCornerOffsetX, baseY).close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function rightAnglePrismIcon(): Node { - const node = new Node(); - const legHalfLength = 9; - const outlineWidth = 1.5; - - const shape = new Shape() - .moveTo(-legHalfLength, legHalfLength) - .lineTo(legHalfLength, legHalfLength) - .lineTo(-legHalfLength, -legHalfLength) - .close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function porroPrismIcon(): Node { - const node = new Node(); - const verticalEdgeX = -4; - const verticalHalfExtent = 11; - const apexX = 8; - const outlineWidth = 1.5; - - const shape = new Shape() - .moveTo(verticalEdgeX, -verticalHalfExtent) - .lineTo(verticalEdgeX, verticalHalfExtent) - .lineTo(apexX, 0) - .close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function slabGlassIcon(): Node { - const node = new Node(); - const halfWidth = 12; - const halfHeight = 6; - const outlineWidth = 1.5; - - const shape = new Shape() - .moveTo(-halfWidth, -halfHeight) - .lineTo(halfWidth, -halfHeight) - .lineTo(halfWidth, halfHeight) - .lineTo(-halfWidth, halfHeight) - .close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function parallelogramPrismIcon(): Node { - const node = new Node(); - const topLeftX = -10; - const topRightX = 5; - const topY = 8; - const bottomRightX = 10; - const bottomLeftX = -5; - const bottomY = -8; - const outlineWidth = 1.5; - - const shape = new Shape() - .moveTo(topLeftX, topY) - .lineTo(topRightX, topY) - .lineTo(bottomRightX, bottomY) - .lineTo(bottomLeftX, bottomY) - .close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function dovePrismIcon(): Node { - const node = new Node(); - const topHalfWidth = 12; - const topY = 8; - const bottomRightX = 6; - const bottomLeftX = -6; - const bottomY = -8; - const outlineWidth = 1.5; - - const shape = new Shape() - .moveTo(-topHalfWidth, topY) - .lineTo(topHalfWidth, topY) - .lineTo(bottomRightX, bottomY) - .lineTo(bottomLeftX, bottomY) - .close(); - node.addChild( - new Path(shape, { - fill: OpticsLabColors.glassFillProperty, - stroke: OpticsLabColors.glassStrokeProperty, - lineWidth: outlineWidth, - }), - ); - return node; -} - -function halfPlaneGlassIcon(): Node { - const node = new Node(); - const boundaryHalfLength = 14; - const boundaryLineWidth = 2; - const hatchStartX = -12; - const hatchEndX = 12; - const hatchSpacing = 5; - const hatchTopY = 2; - const hatchBottomY = 10; - const hatchSkew = 4; - const hatchLineWidth = 1; - - const lineShape = new Shape().moveTo(-boundaryHalfLength, 0).lineTo(boundaryHalfLength, 0); - node.addChild(new Path(lineShape, { stroke: OpticsLabColors.glassStrokeProperty, lineWidth: boundaryLineWidth })); - const hatchShape = new Shape(); - for (let x = hatchStartX; x <= hatchEndX; x += hatchSpacing) { - hatchShape.moveTo(x, hatchTopY).lineTo(x - hatchSkew, hatchBottomY); - } - node.addChild(new Path(hatchShape, { stroke: OpticsLabColors.glassHatchStrokeProperty, lineWidth: hatchLineWidth })); - return node; -} - -function lineBlockerIcon(): Node { - const node = new Node(); - const halfLength = 14; - const backLineWidth = 4; - const frontLineWidth = 2; - - const shape = new Shape().moveTo(-halfLength, 0).lineTo(halfLength, 0); - node.addChild( - new Path(shape, { stroke: OpticsLabColors.blockerBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), - ); - node.addChild( - new Path(shape, { - stroke: OpticsLabColors.blockerFrontStrokeProperty, - lineWidth: frontLineWidth, - lineCap: "round", - }), - ); - return node; -} - -function detectorIcon(): Node { - const node = new Node(); - const bodyHalfLength = 14; - const backLineWidth = 4; - const frontLineWidth = 2; - const tickStartX = -10; - const tickEndX = 10; - const tickSpacing = 5; - const tickHalfHeight = 3; - const tickLineWidth = 1; - - const shape = new Shape().moveTo(-bodyHalfLength, 0).lineTo(bodyHalfLength, 0); - node.addChild( - new Path(shape, { stroke: OpticsLabColors.detectorBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), - ); - node.addChild( - new Path(shape, { - stroke: OpticsLabColors.detectorFrontStrokeProperty, - lineWidth: frontLineWidth, - lineCap: "round", - }), - ); - const ticks = new Shape(); - for (let x = tickStartX; x <= tickEndX; x += tickSpacing) { - ticks.moveTo(x, -tickHalfHeight).lineTo(x, tickHalfHeight); - } - node.addChild(new Path(ticks, { stroke: OpticsLabColors.detectorFrontStrokeProperty, lineWidth: tickLineWidth })); - return node; -} - -function apertureIcon(): Node { - const node = new Node(); - const outerHalfExtent = 14; - const gapHalfWidth = 4; - const backLineWidth = 4; - const frontLineWidth = 2; - - const left = new Shape().moveTo(-outerHalfExtent, 0).lineTo(-gapHalfWidth, 0); - const right = new Shape().moveTo(gapHalfWidth, 0).lineTo(outerHalfExtent, 0); - node.addChild( - new Path(left, { stroke: OpticsLabColors.blockerBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), - ); - node.addChild( - new Path(left, { stroke: OpticsLabColors.blockerFrontStrokeProperty, lineWidth: frontLineWidth, lineCap: "round" }), - ); - node.addChild( - new Path(right, { stroke: OpticsLabColors.blockerBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), - ); - node.addChild( - new Path(right, { - stroke: OpticsLabColors.blockerFrontStrokeProperty, - lineWidth: frontLineWidth, - lineCap: "round", - }), - ); - return node; -} - -function transmissionGratingIcon(): Node { - const node = new Node(); - const halfHeight = 14; - const bodyLineWidth = 2.5; - const grooveStartY = -10; - const grooveEndY = 10; - const grooveSpacing = 5; - const grooveHalfLength = 4; - const grooveLineWidth = 1; - - const line = new Shape().moveTo(0, -halfHeight).lineTo(0, halfHeight); - node.addChild( - new Path(line, { stroke: OpticsLabColors.glassStrokeProperty, lineWidth: bodyLineWidth, lineCap: "round" }), - ); - const ticks = new Shape(); - for (let y = grooveStartY; y <= grooveEndY; y += grooveSpacing) { - ticks.moveTo(-grooveHalfLength, y).lineTo(grooveHalfLength, y); - } - node.addChild(new Path(ticks, { stroke: OpticsLabColors.glassStrokeProperty, lineWidth: grooveLineWidth })); - return node; -} - -function trackIcon(): Node { - const node = new Node(); - const halfLength = 14; - const lineWidth = 2; - const dashOnLength = 6; - const dashGapLength = 3; - - const shape = new Shape().moveTo(-halfLength, 0).lineTo(halfLength, 0); - node.addChild( - new Path(shape, { - stroke: OpticsLabColors.trackStrokeProperty, - lineWidth, - lineDash: [dashOnLength, dashGapLength], - lineCap: "round", - }), - ); - return node; -} - -function reflectionGratingIcon(): Node { - const node = new Node(); - const halfHeight = 14; - const backLineWidth = 4; - const frontLineWidth = 2; - const hatchStartY = -10; - const hatchEndY = 10; - const hatchVerticalStep = 4; - const hatchHorizontalExtent = -5; - const hatchVerticalDrop = 2; - const hatchLineWidth = 1; - - const line = new Shape().moveTo(0, -halfHeight).lineTo(0, halfHeight); - node.addChild( - new Path(line, { stroke: OpticsLabColors.mirrorBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), - ); - node.addChild( - new Path(line, { stroke: OpticsLabColors.mirrorFrontStrokeProperty, lineWidth: frontLineWidth, lineCap: "round" }), - ); - const hatches = new Shape(); - for (let y = hatchStartY; y <= hatchEndY; y += hatchVerticalStep) { - hatches.moveTo(0, y).lineTo(hatchHorizontalExtent, y + hatchVerticalDrop); - } - node.addChild(new Path(hatches, { stroke: OpticsLabColors.mirrorFrontStrokeProperty, lineWidth: hatchLineWidth })); - return node; -} - // ── Component descriptors ──────────────────────────────────────────────────── // cx, cy are MODEL coordinates (metres, y-up, origin at screen centre). // S = default half-size in metres (0.6 m = 60 px at 100 px/m). diff --git a/src/common/view/EditControlFactory.ts b/src/common/view/EditControlFactory.ts index 6173217..4dae7cc 100644 --- a/src/common/view/EditControlFactory.ts +++ b/src/common/view/EditControlFactory.ts @@ -1,922 +1,26 @@ /** * EditControlFactory.ts * - * Factory that builds the type-specific UI controls shown in the edit panel. - * This is the single location that maps model element types to their edit - * controls — it mirrors the structure of OpticalElementViewFactory and is the - * intentional coupling point between model classes and the edit UI. + * Public dispatcher that routes any OpticalElement to its edit-panel controls. + * All per-element builder functions now live in: + * - ./edit-controls/LightSourceEditControls.ts + * - ./edit-controls/GlassEditControls.ts + * - ./edit-controls/MirrorEditControls.ts * - * Keeping this logic here means EditContainerNode itself has zero direct - * model-class imports. - * - * Structure: - * - Private widget builders: makeControl, makeWavelengthControl, numberControlOptions - * - Shared segment helpers: segmentLength, resizeSegment, buildSegmentLengthControl - * - Per-element builders: buildXxxControls() — one per element type - * - Public dispatcher: buildEditControls() — routes to the right per-element builder + * The actual dispatch is handled by ElementRegistry.buildEditControls, which + * iterates the shared ELEMENT_REGISTRY so that view creation and edit controls + * are registered in one place. */ -import { NumberProperty, type ReadOnlyProperty } from "scenerystack/axon"; -import { Dimension2, Range } from "scenerystack/dot"; -import type { Node } from "scenerystack/scenery"; -import { NumberControl, SpectrumSliderTrack, VisibleColor } from "scenerystack/scenery-phet"; -import { Tandem } from "scenerystack/tandem"; -import { StringManager } from "../../i18n/StringManager.js"; -import OpticsLabColors from "../../OpticsLabColors.js"; -import { - ARC_MIRROR_RADIUS_MAX, - ARC_MIRROR_RADIUS_MIN, - BRIGHTNESS_MAX, - BRIGHTNESS_MIN, - DIVERGENCE_MAX_DEG, - DIVERGENCE_MIN_DEG, - EMISSION_ANGLE_MAX_DEG, - EMISSION_ANGLE_MIN_DEG, - FOCAL_LENGTH_MAX_M, - FOCAL_LENGTH_MIN_M, - LINES_DENSITY_CONTROL_DELTA, - REFRACTIVE_INDEX_MAX, - REFRACTIVE_INDEX_MIN, - SEGMENT_LENGTH_MAX, - SEGMENT_LENGTH_MIN, - SLIDER_THUMB_HEIGHT, - SLIDER_THUMB_WIDTH, - SLIDER_TRACK_HEIGHT, - SLIDER_TRACK_WIDTH, - SPHERICAL_R1_FALLBACK, - SPHERICAL_R2_FALLBACK, - SPHERICAL_RADIUS_MAX, - SPHERICAL_RADIUS_MIN, - WAVELENGTH_CONTROL_DELTA, - WAVELENGTH_MAX_NM, - WAVELENGTH_MIN_NM, -} from "../../OpticsLabConstants.js"; import opticsLab from "../../OpticsLabNamespace.js"; import type { SignConvention } from "../../preferences/OpticsLabPreferencesModel.js"; -import { LineBlocker } from "../model/blockers/LineBlocker.js"; -import { DetectorElement } from "../model/detectors/DetectorElement.js"; -import { BaseGlass } from "../model/glass/BaseGlass.js"; -import { DovePrism } from "../model/glass/DovePrism.js"; -import { EquilateralPrism } from "../model/glass/EquilateralPrism.js"; -import { IdealLens } from "../model/glass/IdealLens.js"; -import { ParallelogramPrism } from "../model/glass/ParallelogramPrism.js"; -import { PorroPrism } from "../model/glass/PorroPrism.js"; -import { RightAnglePrism } from "../model/glass/RightAnglePrism.js"; -import { SlabGlass } from "../model/glass/SlabGlass.js"; -import { SphericalLens } from "../model/glass/SphericalLens.js"; -import { ReflectionGrating } from "../model/gratings/ReflectionGrating.js"; -import { TransmissionGrating } from "../model/gratings/TransmissionGrating.js"; -import { TrackElement } from "../model/guides/TrackElement.js"; -import { ArcLightSource } from "../model/light-sources/ArcLightSource.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 { SegmentMirror } from "../model/mirrors/SegmentMirror.js"; import type { OpticalElement } from "../model/optics/OpticsTypes.js"; -import { SymmetricWavelengthThumb } from "./SymmetricWavelengthThumb.js"; - -// ── Module-level constants ──────────────────────────────────────────────────── - -const SLIDER_TRACK_SIZE = new Dimension2(SLIDER_TRACK_WIDTH, SLIDER_TRACK_HEIGHT); -const SLIDER_THUMB_SIZE = new Dimension2(SLIDER_THUMB_WIDTH, SLIDER_THUMB_HEIGHT); - -const LABEL_FONT = "11px sans-serif"; - -// ── Private widget builders ─────────────────────────────────────────────────── - -/** Clamp a number to a range, replacing non-finite values with a fallback. */ -function safeClamp(value: number, min: number, max: number, fallback: number): number { - return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback; -} - -/** Number of decimal places to show for a given step size. */ -function decimalPlacesForDelta(delta: number): number { - const abs = Math.abs(delta); - if (abs >= 1) { - return 0; - } - if (abs >= 0.1) { - return 1; - } - return 2; -} - -/** - * Build a NumberControl: a labeled slider that also displays the current value. - */ -function makeControl( - label: string | ReadOnlyProperty, - initValue: number, - range: Range, - delta: number, - onSet: (v: number) => void, - onAfterSet: () => void, -): Node { - const clampedInit = Math.max(range.min, Math.min(range.max, initValue)); - const prop = new NumberProperty(clampedInit, { range, tandem: Tandem.OPT_OUT }); - prop.lazyLink((v) => { - onSet(v); - onAfterSet(); - }); - return new NumberControl(label, prop, range, { - delta, - includeArrowButtons: true, - soundGenerator: null, - layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), - titleNodeOptions: { - fill: OpticsLabColors.overlayLabelFillProperty, - font: LABEL_FONT, - }, - numberDisplayOptions: { - decimalPlaces: decimalPlacesForDelta(delta), - textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, - backgroundFill: "rgba(0,0,0,0.35)", - backgroundStroke: "rgba(100,100,120,0.6)", - }, - sliderOptions: { - trackSize: SLIDER_TRACK_SIZE, - thumbSize: SLIDER_THUMB_SIZE, - tandem: Tandem.OPT_OUT, - }, - tandem: Tandem.OPT_OUT, - }); -} - -/** - * Build a wavelength control with a spectrum-coloured track and a custom - * symmetric thumb (centred on the track, not hanging below it). - */ -function makeWavelengthControl( - initValue: number, - range: Range, - onSet: (v: number) => void, - onAfterSet: () => void, -): Node { - const clampedInit = Math.max(range.min, Math.min(range.max, initValue)); - const prop = new NumberProperty(clampedInit, { range, tandem: Tandem.OPT_OUT }); - prop.lazyLink((v) => { - onSet(v); - onAfterSet(); - }); - - const trackNode = new SpectrumSliderTrack(prop, range, { - valueToColor: VisibleColor.wavelengthToColor, - size: SLIDER_TRACK_SIZE, - tandem: Tandem.OPT_OUT, - }); - - const thumbNode = new SymmetricWavelengthThumb(prop); - - return new NumberControl("λ", prop, range, { - delta: WAVELENGTH_CONTROL_DELTA, - includeArrowButtons: true, - soundGenerator: null, - layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), - titleNodeOptions: { - fill: OpticsLabColors.overlayLabelFillProperty, - font: LABEL_FONT, - }, - numberDisplayOptions: { - decimalPlaces: 0, - valuePattern: "{0} nm", - textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, - backgroundFill: "rgba(0,0,0,0.35)", - backgroundStroke: "rgba(100,100,120,0.6)", - }, - sliderOptions: { - trackNode, - thumbNode, - tandem: Tandem.OPT_OUT, - }, - tandem: Tandem.OPT_OUT, - }); -} - -/** - * Shared NumberControl options used throughout the panel. - */ -function numberControlOptions(delta: number, decimalPlaces: number) { - return { - delta, - includeArrowButtons: true, - soundGenerator: null, - layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), - titleNodeOptions: { fill: OpticsLabColors.overlayLabelFillProperty, font: LABEL_FONT }, - numberDisplayOptions: { - decimalPlaces, - textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, - backgroundFill: "rgba(0,0,0,0.35)", - backgroundStroke: "rgba(100,100,120,0.6)", - }, - sliderOptions: { - trackSize: SLIDER_TRACK_SIZE, - thumbSize: SLIDER_THUMB_SIZE, - tandem: Tandem.OPT_OUT, - }, - tandem: Tandem.OPT_OUT, - }; -} - -// ── Shared segment helpers ──────────────────────────────────────────────────── - -/** Euclidean length of a two-point segment in model space. */ -function segmentLength(p1: { x: number; y: number }, p2: { x: number; y: number }): number { - return Math.hypot(p2.x - p1.x, p2.y - p1.y); -} +import { buildEditControls as buildEditControlsFromRegistry } from "./ElementRegistry.js"; +import type { EditControlsResult } from "./edit-controls/EditControlsResult.js"; -/** Resize a segment to newLength while keeping its centre and orientation fixed. */ -function resizeSegment( - p1: { x: number; y: number }, - p2: { x: number; y: number }, - newLength: number, -): { p1: { x: number; y: number }; p2: { x: number; y: number } } { - const cx = (p1.x + p2.x) / 2; - const cy = (p1.y + p2.y) / 2; - const dx = p2.x - p1.x; - const dy = p2.y - p1.y; - const len = Math.hypot(dx, dy); - const ux = len > 1e-10 ? dx / len : 1; - const uy = len > 1e-10 ? dy / len : 0; - const half = newLength / 2; - return { - p1: { x: cx - ux * half, y: cy - uy * half }, - p2: { x: cx + ux * half, y: cy + uy * half }, - }; -} - -/** - * Build the standard segment-length NumberControl for any element with p1/p2 - * endpoints. Returns the control node and a refresh function that syncs the - * displayed value after an external drag changes the geometry. - */ -function buildSegmentLengthControl( - element: { p1: { x: number; y: number }; p2: { x: number; y: number } }, - label: string | ReadOnlyProperty, - triggerRebuild: () => void, -): { control: Node; refresh: () => void } { - const L_RANGE = new Range(SEGMENT_LENGTH_MIN, SEGMENT_LENGTH_MAX); - const lenProp = new NumberProperty(safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0), { - range: L_RANGE, - tandem: Tandem.OPT_OUT, - }); - let lenDriving = false; - lenProp.lazyLink((v) => { - lenDriving = true; - const resized = resizeSegment(element.p1, element.p2, v); - element.p1 = resized.p1; - element.p2 = resized.p2; - triggerRebuild(); - lenDriving = false; - }); - const control = new NumberControl(label, lenProp, L_RANGE, numberControlOptions(0.05, 2)); - const refresh = (): void => { - if (lenDriving) { - return; - } - lenProp.value = safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0); - }; - return { control, refresh }; -} - -// ── Per-element control builders ────────────────────────────────────────────── - -function buildArcLightSourceControls(element: ArcLightSource, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - return { - controls: [ - makeControl( - ctrl.brightnessStringProperty, - element.brightness, - new Range(BRIGHTNESS_MIN, BRIGHTNESS_MAX), - 0.05, - (v) => { - element.brightness = v; - }, - triggerRebuild, - ), - makeControl( - ctrl.emissionAngleStringProperty, - element.emissionAngle * (180 / Math.PI), - new Range(EMISSION_ANGLE_MIN_DEG, EMISSION_ANGLE_MAX_DEG), - 1, - (v) => { - element.emissionAngle = v * (Math.PI / 180); - }, - triggerRebuild, - ), - makeWavelengthControl( - element.wavelength, - new Range(WAVELENGTH_MIN_NM, WAVELENGTH_MAX_NM), - (v) => { - element.wavelength = v; - }, - triggerRebuild, - ), - ], - refreshCallback: null, - }; -} - -function buildPointSourceControls(element: PointSourceElement, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - return { - controls: [ - makeControl( - ctrl.brightnessStringProperty, - element.brightness, - new Range(BRIGHTNESS_MIN, BRIGHTNESS_MAX), - 0.05, - (v) => { - element.brightness = v; - }, - triggerRebuild, - ), - makeWavelengthControl( - element.wavelength, - new Range(WAVELENGTH_MIN_NM, WAVELENGTH_MAX_NM), - (v) => { - element.wavelength = v; - }, - triggerRebuild, - ), - ], - refreshCallback: null, - }; -} - -function buildBeamSourceControls(element: BeamSource, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - const { control: heightControl, refresh } = buildSegmentLengthControl( - element, - ctrl.heightStringProperty, - triggerRebuild, - ); - return { - controls: [ - makeControl( - ctrl.brightnessStringProperty, - element.brightness, - new Range(BRIGHTNESS_MIN, BRIGHTNESS_MAX), - 0.05, - (v) => { - element.brightness = v; - }, - triggerRebuild, - ), - makeControl( - ctrl.divergenceStringProperty, - element.emisAngle, - new Range(DIVERGENCE_MIN_DEG, DIVERGENCE_MAX_DEG), - 1, - (v) => { - element.emisAngle = v; - }, - triggerRebuild, - ), - heightControl, - makeWavelengthControl( - element.wavelength, - new Range(WAVELENGTH_MIN_NM, WAVELENGTH_MAX_NM), - (v) => { - element.wavelength = v; - }, - triggerRebuild, - ), - ], - refreshCallback: refresh, - }; -} - -function buildSingleRaySourceControls(element: SingleRaySource, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - return { - controls: [ - makeControl( - ctrl.brightnessStringProperty, - element.brightness, - new Range(BRIGHTNESS_MIN, BRIGHTNESS_MAX), - 0.05, - (v) => { - element.brightness = v; - }, - triggerRebuild, - ), - makeWavelengthControl( - element.wavelength, - new Range(WAVELENGTH_MIN_NM, WAVELENGTH_MAX_NM), - (v) => { - element.wavelength = v; - }, - triggerRebuild, - ), - ], - refreshCallback: null, - }; -} - -function buildSphericalLensControls( - element: SphericalLens, - triggerRebuild: () => void, - signConvention: SignConvention, -): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - const { r1, r2 } = element.getDR1R2(); - const R_RANGE = new Range(SPHERICAL_RADIUS_MIN, SPHERICAL_RADIUS_MAX); - const L_RANGE = new Range(SEGMENT_LENGTH_MIN, SEGMENT_LENGTH_MAX); - - // In Real-is-Positive mode, R₂ is negated for display: a biconvex lens - // has R₁ > 0, R₂ > 0 (instead of R₁ > 0, R₂ < 0 in New Cartesian). - const isRIP = signConvention === "realIsPositive"; - const r2Display = isRIP ? -r2 : r2; - - const r1Prop = new NumberProperty(safeClamp(r1, R_RANGE.min, R_RANGE.max, SPHERICAL_R1_FALLBACK), { - range: R_RANGE, - tandem: Tandem.OPT_OUT, - }); - const r2Prop = new NumberProperty(safeClamp(r2Display, R_RANGE.min, R_RANGE.max, SPHERICAL_R2_FALLBACK), { - range: R_RANGE, - tandem: Tandem.OPT_OUT, - }); - const lenProp = new NumberProperty(safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0), { - range: L_RANGE, - tandem: Tandem.OPT_OUT, - }); - - // sliderDriving: slider is changing the model → suppress refreshCallback. - // viewDriving: view is updating the slider display → suppress lazyLinks. - let sliderDriving = false; - let viewDriving = false; - r1Prop.lazyLink((v) => { - if (viewDriving) { - return; - } - sliderDriving = true; - // Move only the left arc apex — same as the blue curvature drag handle. - element.applyRadiusKeepingCorners("r1", v); - triggerRebuild(); - sliderDriving = false; - }); - r2Prop.lazyLink((v) => { - if (viewDriving) { - return; - } - sliderDriving = true; - // In RIP mode the slider holds −R₂_model; invert before storing. - const modelR2 = isRIP ? -v : v; - // Move only the right arc apex — same as the blue curvature drag handle. - element.applyRadiusKeepingCorners("r2", modelR2); - triggerRebuild(); - sliderDriving = false; - }); - lenProp.lazyLink((v) => { - if (viewDriving) { - return; - } - sliderDriving = true; - const resized = resizeSegment(element.p1, element.p2, v); - element.p1 = resized.p1; - element.p2 = resized.p2; - const { d, r1: cr1, r2: cr2 } = element.getDR1R2(); - element.createLensWithDR1R2(d, cr1, cr2); - triggerRebuild(); - sliderDriving = false; - }); - - const refreshCallback = (): void => { - if (sliderDriving) { - return; - } - viewDriving = true; - const { r1: newR1, r2: newR2 } = element.getDR1R2(); - r1Prop.value = safeClamp(newR1, R_RANGE.min, R_RANGE.max, SPHERICAL_R1_FALLBACK); - r2Prop.value = safeClamp(isRIP ? -newR2 : newR2, R_RANGE.min, R_RANGE.max, SPHERICAL_R2_FALLBACK); - lenProp.value = safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0); - viewDriving = false; - }; - - const r2Label = isRIP ? ctrl.r2RightRIPStringProperty : ctrl.r2RightSurfaceStringProperty; - - return { - controls: [ - new NumberControl(ctrl.r1LeftSurfaceStringProperty, r1Prop, R_RANGE, numberControlOptions(0.1, 1)), - new NumberControl(r2Label, r2Prop, R_RANGE, numberControlOptions(0.1, 1)), - new NumberControl(ctrl.lengthStringProperty, lenProp, L_RANGE, numberControlOptions(0.05, 2)), - makeControl( - ctrl.refractiveIndexStringProperty, - element.refIndex, - new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), - 0.05, - (v) => { - element.refIndex = v; - }, - triggerRebuild, - ), - ], - refreshCallback, - }; -} - -function buildIdealLensControls(element: IdealLens, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - const { control: lenControl, refresh } = buildSegmentLengthControl( - element, - ctrl.lengthStringProperty, - triggerRebuild, - ); - return { - controls: [ - makeControl( - ctrl.focalLengthStringProperty, - element.focalLength, - new Range(FOCAL_LENGTH_MIN_M, FOCAL_LENGTH_MAX_M), - 0.1, - (v) => { - element.focalLength = v; - }, - triggerRebuild, - ), - lenControl, - ], - refreshCallback: refresh, - }; -} - -function buildRefractiveIndexControls(element: BaseGlass, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - return { - controls: [ - makeControl( - ctrl.refractiveIndexStringProperty, - element.refIndex, - new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), - 0.05, - (v) => { - element.refIndex = v; - }, - triggerRebuild, - ), - ], - refreshCallback: null, - }; -} - -function buildArcMirrorControls(element: ArcMirror, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - const R_RANGE = new Range(ARC_MIRROR_RADIUS_MIN, ARC_MIRROR_RADIUS_MAX); - const currentRadius = safeClamp( - element.getRadius() ?? ARC_MIRROR_RADIUS_MAX, - R_RANGE.min, - R_RANGE.max, - ARC_MIRROR_RADIUS_MAX, - ); - const radiusProp = new NumberProperty(currentRadius, { range: R_RANGE, tandem: Tandem.OPT_OUT }); - let sliderDriving = false; - radiusProp.lazyLink((v) => { - sliderDriving = true; - element.setRadius(v); - triggerRebuild(); - sliderDriving = false; - }); - const refreshCallback = (): void => { - if (sliderDriving) { - return; - } - radiusProp.value = safeClamp( - element.getRadius() ?? ARC_MIRROR_RADIUS_MAX, - R_RANGE.min, - R_RANGE.max, - ARC_MIRROR_RADIUS_MAX, - ); - }; - return { - controls: [ - new NumberControl(ctrl.radiusOfCurvatureStringProperty, radiusProp, R_RANGE, numberControlOptions(0.1, 1)), - ], - refreshCallback, - }; -} - -function buildIdealCurvedMirrorControls(element: IdealCurvedMirror, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - const { control: lenControl, refresh } = buildSegmentLengthControl( - element, - ctrl.lengthStringProperty, - triggerRebuild, - ); - return { - controls: [ - makeControl( - ctrl.focalLengthStringProperty, - element.focalLength, - new Range(FOCAL_LENGTH_MIN_M, FOCAL_LENGTH_MAX_M), - 0.1, - (v) => { - element.focalLength = v; - }, - triggerRebuild, - ), - lenControl, - ], - refreshCallback: refresh, - }; -} - -function buildDetectorControls(element: DetectorElement, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - const { control: lenControl, refresh } = buildSegmentLengthControl( - element, - ctrl.lengthStringProperty, - triggerRebuild, - ); - return { controls: [lenControl], refreshCallback: refresh }; -} - -function buildSegmentControls( - element: SegmentMirror | LineBlocker | TrackElement, - triggerRebuild: () => void, -): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - const { control: lenControl, refresh } = buildSegmentLengthControl( - element, - ctrl.lengthStringProperty, - triggerRebuild, - ); - return { controls: [lenControl], refreshCallback: refresh }; -} - -function buildGratingControls( - element: TransmissionGrating | ReflectionGrating, - triggerRebuild: () => void, -): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - const { control: lenControl, refresh } = buildSegmentLengthControl( - element, - ctrl.lengthStringProperty, - triggerRebuild, - ); - return { - controls: [ - makeControl( - ctrl.linesDensityStringProperty, - element.linesDensity, - new Range(1, 2500), - LINES_DENSITY_CONTROL_DELTA, - (v) => { - element.linesDensity = v; - }, - triggerRebuild, - ), - makeControl( - ctrl.dutyCycleStringProperty, - element.dutyCycle, - new Range(0.01, 0.99), - 0.01, - (v) => { - element.dutyCycle = v; - }, - triggerRebuild, - ), - lenControl, - ], - refreshCallback: refresh, - }; -} - -function buildBeamSplitterControls(element: BeamSplitterElement, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - return { - controls: [ - makeControl( - ctrl.transmissionRatioStringProperty, - element.transRatio, - new Range(0, 1), - 0.05, - (v) => { - element.transRatio = v; - }, - triggerRebuild, - ), - ], - refreshCallback: null, - }; -} - -// ── Typed prism builders ────────────────────────────────────────────────────── - -function buildEquilateralPrismControls(element: EquilateralPrism, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - return { - controls: [ - makeControl( - ctrl.sizeStringProperty, - element.size, - new Range(0.1, 2.0), - 0.05, - (v) => { - element.setSize(v); - }, - triggerRebuild, - ), - makeControl( - ctrl.refractiveIndexStringProperty, - element.refIndex, - new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), - 0.05, - (v) => { - element.refIndex = v; - }, - triggerRebuild, - ), - ], - refreshCallback: null, - }; -} - -function buildRightAnglePrismControls(element: RightAnglePrism, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - return { - controls: [ - makeControl( - ctrl.legLengthStringProperty, - element.legLength, - new Range(0.1, 2.0), - 0.05, - (v) => { - element.setLegLength(v); - }, - triggerRebuild, - ), - makeControl( - ctrl.refractiveIndexStringProperty, - element.refIndex, - new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), - 0.05, - (v) => { - element.refIndex = v; - }, - triggerRebuild, - ), - ], - refreshCallback: null, - }; -} - -function buildPorroPrismControls(element: PorroPrism, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - return { - controls: [ - makeControl( - ctrl.legLengthStringProperty, - element.legLength, - new Range(0.1, 2.0), - 0.05, - (v) => { - element.setLegLength(v); - }, - triggerRebuild, - ), - makeControl( - ctrl.refractiveIndexStringProperty, - element.refIndex, - new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), - 0.05, - (v) => { - element.refIndex = v; - }, - triggerRebuild, - ), - ], - refreshCallback: null, - }; -} - -function buildSlabGlassControls(element: SlabGlass, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - return { - controls: [ - makeControl( - ctrl.widthStringProperty, - element.width, - new Range(0.1, 3.0), - 0.05, - (v) => { - element.setWidth(v); - }, - triggerRebuild, - ), - makeControl( - ctrl.heightStringProperty, - element.height, - new Range(0.1, 2.0), - 0.05, - (v) => { - element.setHeight(v); - }, - triggerRebuild, - ), - makeControl( - ctrl.refractiveIndexStringProperty, - element.refIndex, - new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), - 0.05, - (v) => { - element.refIndex = v; - }, - triggerRebuild, - ), - ], - refreshCallback: null, - }; -} - -function buildParallelogramPrismControls(element: ParallelogramPrism, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - return { - controls: [ - makeControl( - ctrl.widthStringProperty, - element.width, - new Range(0.1, 3.0), - 0.05, - (v) => { - element.setWidth(v); - }, - triggerRebuild, - ), - makeControl( - ctrl.heightStringProperty, - element.height, - new Range(0.1, 2.0), - 0.05, - (v) => { - element.setHeight(v); - }, - triggerRebuild, - ), - makeControl( - ctrl.refractiveIndexStringProperty, - element.refIndex, - new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), - 0.05, - (v) => { - element.refIndex = v; - }, - triggerRebuild, - ), - ], - refreshCallback: null, - }; -} - -function buildDovePrismControls(element: DovePrism, triggerRebuild: () => void): EditControlsResult { - const ctrl = StringManager.getInstance().getControlStrings(); - return { - controls: [ - makeControl( - ctrl.widthStringProperty, - element.width, - new Range(0.1, 3.0), - 0.05, - (v) => { - element.setWidth(v); - }, - triggerRebuild, - ), - makeControl( - ctrl.heightStringProperty, - element.height, - new Range(0.1, 2.0), - 0.05, - (v) => { - element.setHeight(v); - }, - triggerRebuild, - ), - makeControl( - ctrl.refractiveIndexStringProperty, - element.refIndex, - new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), - 0.05, - (v) => { - element.refIndex = v; - }, - triggerRebuild, - ), - ], - refreshCallback: null, - }; -} - -// ── Public API ──────────────────────────────────────────────────────────────── - -export type EditControlsResult = { - controls: Node[]; - /** Called by EditContainerNode.refresh() to sync controls after a geometry drag. */ - refreshCallback: (() => void) | null; -}; +// Re-export so callers that previously imported EditControlsResult from this +// file continue to work without any import-path changes. +export type { EditControlsResult } from "./edit-controls/EditControlsResult.js"; /** * Build the property controls appropriate for the given optical element. @@ -928,70 +32,7 @@ export function buildEditControls( triggerRebuild: () => void, signConvention: SignConvention, ): EditControlsResult { - // ── Light Sources ───────────────────────────────────────────────────── - if (element instanceof ArcLightSource) { - return buildArcLightSourceControls(element, triggerRebuild); - } - if (element instanceof PointSourceElement) { - return buildPointSourceControls(element, triggerRebuild); - } - if (element instanceof BeamSource) { - return buildBeamSourceControls(element, triggerRebuild); - } - if (element instanceof SingleRaySource) { - return buildSingleRaySourceControls(element, triggerRebuild); - } - - // ── Glass / Lenses ──────────────────────────────────────────────────── - if (element instanceof SphericalLens) { - return buildSphericalLensControls(element, triggerRebuild, signConvention); - } - if (element instanceof IdealLens) { - return buildIdealLensControls(element, triggerRebuild); - } - if (element instanceof EquilateralPrism) { - return buildEquilateralPrismControls(element, triggerRebuild); - } - if (element instanceof RightAnglePrism) { - return buildRightAnglePrismControls(element, triggerRebuild); - } - if (element instanceof PorroPrism) { - return buildPorroPrismControls(element, triggerRebuild); - } - if (element instanceof SlabGlass) { - return buildSlabGlassControls(element, triggerRebuild); - } - if (element instanceof ParallelogramPrism) { - return buildParallelogramPrismControls(element, triggerRebuild); - } - if (element instanceof DovePrism) { - return buildDovePrismControls(element, triggerRebuild); - } - if (element instanceof BaseGlass) { - return buildRefractiveIndexControls(element, triggerRebuild); - } - - // ── Mirrors ─────────────────────────────────────────────────────────── - if (element instanceof ArcMirror) { - return buildArcMirrorControls(element, triggerRebuild); - } - if (element instanceof IdealCurvedMirror) { - return buildIdealCurvedMirrorControls(element, triggerRebuild); - } - if (element instanceof DetectorElement) { - return buildDetectorControls(element, triggerRebuild); - } - if (element instanceof SegmentMirror || element instanceof LineBlocker || element instanceof TrackElement) { - return buildSegmentControls(element, triggerRebuild); - } - if (element instanceof TransmissionGrating || element instanceof ReflectionGrating) { - return buildGratingControls(element, triggerRebuild); - } - if (element instanceof BeamSplitterElement) { - return buildBeamSplitterControls(element, triggerRebuild); - } - - return { controls: [], refreshCallback: null }; + return buildEditControlsFromRegistry(element, triggerRebuild, signConvention); } opticsLab.register("buildEditControls", buildEditControls); diff --git a/src/common/view/ElementRegistry.ts b/src/common/view/ElementRegistry.ts new file mode 100644 index 0000000..7e0de34 --- /dev/null +++ b/src/common/view/ElementRegistry.ts @@ -0,0 +1,343 @@ +/** + * ElementRegistry.ts + * + * Central registry that maps each optical element type to its view constructor + * and edit-controls builder. Adding a new element type requires only a single + * entry here; OpticalElementViewFactory and EditControlFactory both delegate to + * this registry rather than maintaining their own parallel dispatch tables. + * + * Serialization/deserialization is intentionally excluded: it belongs in the + * model layer (OpticsScene.ts) to keep model-view separation intact. + * + * Entry ordering matters: more-specific subclasses must appear before their + * base classes (e.g. BiconvexLens before SphericalLens). + */ + +import type { ModelViewTransform2 } from "scenerystack/phetcommon"; +import type { Node, RichDragListener } from "scenerystack/scenery"; +import type { SignConvention } from "../../preferences/OpticsLabPreferencesModel.js"; +import { ApertureElement } from "../model/blockers/ApertureElement.js"; +import { LineBlocker } from "../model/blockers/LineBlocker.js"; +import { DetectorElement } from "../model/detectors/DetectorElement.js"; +import type { BaseGlass } from "../model/glass/BaseGlass.js"; +import { BiconcaveLens } from "../model/glass/BiconcaveLens.js"; +import { BiconvexLens } from "../model/glass/BiconvexLens.js"; +import { CircleGlass } from "../model/glass/CircleGlass.js"; +import { DovePrism } from "../model/glass/DovePrism.js"; +import { EquilateralPrism } from "../model/glass/EquilateralPrism.js"; +import { Glass } from "../model/glass/Glass.js"; +import { HalfPlaneGlass } from "../model/glass/HalfPlaneGlass.js"; +import { IdealLens } from "../model/glass/IdealLens.js"; +import { ParallelogramPrism } from "../model/glass/ParallelogramPrism.js"; +import { PlanoConcaveLens } from "../model/glass/PlanoConcaveLens.js"; +import { PlanoConvexLens } from "../model/glass/PlanoConvexLens.js"; +import { PorroPrism } from "../model/glass/PorroPrism.js"; +import { RightAnglePrism } from "../model/glass/RightAnglePrism.js"; +import { SlabGlass } from "../model/glass/SlabGlass.js"; +import { SphericalLens } from "../model/glass/SphericalLens.js"; +import { ReflectionGrating } from "../model/gratings/ReflectionGrating.js"; +import { TransmissionGrating } from "../model/gratings/TransmissionGrating.js"; +import { TrackElement } from "../model/guides/TrackElement.js"; +import { ArcLightSource } from "../model/light-sources/ArcLightSource.js"; +import { BeamSource } from "../model/light-sources/BeamSource.js"; +import { ContinuousSpectrumSource } from "../model/light-sources/ContinuousSpectrumSource.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"; +import { ApertureView } from "./blockers/ApertureView.js"; +import { LineBlockerView } from "./blockers/LineBlockerView.js"; +import { DetectorView } from "./detectors/DetectorView.js"; +import type { EditControlsResult } from "./edit-controls/EditControlsResult.js"; +import { + buildDovePrismControls, + buildEquilateralPrismControls, + buildIdealLensControls, + buildParallelogramPrismControls, + buildPorroPrismControls, + buildRefractiveIndexControls, + buildRightAnglePrismControls, + buildSlabGlassControls, + buildSphericalLensControls, +} from "./edit-controls/GlassEditControls.js"; +import { + buildArcLightSourceControls, + buildBeamSourceControls, + buildPointSourceControls, + buildSingleRaySourceControls, +} from "./edit-controls/LightSourceEditControls.js"; +import { + buildArcMirrorControls, + buildBeamSplitterControls, + buildDetectorControls, + buildGratingControls, + buildIdealCurvedMirrorControls, + buildSegmentControls, +} from "./edit-controls/MirrorEditControls.js"; +import { BiconcaveLensView } from "./glass/BiconcaveLensView.js"; +import { BiconvexLensView } from "./glass/BiconvexLensView.js"; +import { CircleGlassView } from "./glass/CircleGlassView.js"; +import { GlassView } from "./glass/GlassView.js"; +import { HalfPlaneGlassView } from "./glass/HalfPlaneGlassView.js"; +import { IdealLensView } from "./glass/IdealLensView.js"; +import { PlanoConcaveLensView } from "./glass/PlanoConcaveLensView.js"; +import { PlanoConvexLensView } from "./glass/PlanoConvexLensView.js"; +import { SphericalLensView } from "./glass/SphericalLensView.js"; +import { TypedPrismView } from "./glass/TypedPrismView.js"; +import { ReflectionGratingView } from "./gratings/ReflectionGratingView.js"; +import { TransmissionGratingView } from "./gratings/TransmissionGratingView.js"; +import { TrackView } from "./guides/TrackView.js"; +import { ArcLightSourceView } from "./light-sources/ArcLightSourceView.js"; +import { BeamSourceView } from "./light-sources/BeamSourceView.js"; +import { ContinuousSpectrumSourceView } from "./light-sources/ContinuousSpectrumSourceView.js"; +import { PointSourceView } from "./light-sources/PointSourceView.js"; +import { SingleRaySourceView } from "./light-sources/SingleRaySourceView.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"; + +/** The view type: a Node that also exposes its body-drag listener. */ +export type OpticalElementView = Node & { readonly bodyDragListener: RichDragListener }; + +/** Per-element descriptor used by the registry. */ +interface ElementDescriptor { + /** Returns true when this descriptor applies to the given element. */ + readonly guard: (el: OpticalElement) => boolean; + /** Create the Scenery view for this element type, or null if not applicable. */ + readonly createView: (el: OpticalElement, mvt: ModelViewTransform2) => OpticalElementView | null; + /** + * Build the edit-panel controls for this element type. + * Absent for elements with no editable properties (e.g. ApertureElement). + */ + readonly buildEditControls?: ( + el: OpticalElement, + triggerRebuild: () => void, + signConvention: SignConvention, + ) => EditControlsResult; +} + +/** + * Ordered list of element descriptors. Entries are checked top-to-bottom, + * so more-specific subclasses must appear before their base classes. + */ +export const ELEMENT_REGISTRY: ElementDescriptor[] = [ + // ── Light Sources ────────────────────────────────────────────────────────── + { + guard: (el) => el instanceof ArcLightSource, + createView: (el, mvt) => new ArcLightSourceView(el as ArcLightSource, mvt), + buildEditControls: (el, rebuild) => buildArcLightSourceControls(el as ArcLightSource, rebuild), + }, + { + guard: (el) => el instanceof PointSourceElement, + createView: (el, mvt) => new PointSourceView(el as PointSourceElement, mvt), + buildEditControls: (el, rebuild) => buildPointSourceControls(el as PointSourceElement, rebuild), + }, + { + guard: (el) => el instanceof BeamSource, + createView: (el, mvt) => new BeamSourceView(el as BeamSource, mvt), + buildEditControls: (el, rebuild) => buildBeamSourceControls(el as BeamSource, rebuild), + }, + { + guard: (el) => el instanceof SingleRaySource, + createView: (el, mvt) => new SingleRaySourceView(el as SingleRaySource, mvt), + buildEditControls: (el, rebuild) => buildSingleRaySourceControls(el as SingleRaySource, rebuild), + }, + { + guard: (el) => el instanceof ContinuousSpectrumSource, + createView: (el, mvt) => new ContinuousSpectrumSourceView(el as ContinuousSpectrumSource, mvt), + // No editable properties for ContinuousSpectrumSource. + }, + + // ── Mirrors ──────────────────────────────────────────────────────────────── + { + guard: (el) => el instanceof SegmentMirror, + createView: (el, mvt) => new SegmentMirrorView(el as SegmentMirror, mvt), + buildEditControls: (el, rebuild) => buildSegmentControls(el as SegmentMirror, rebuild), + }, + { + guard: (el) => el instanceof ArcMirror, + createView: (el, mvt) => new ArcMirrorView(el as ArcMirror, mvt), + buildEditControls: (el, rebuild) => buildArcMirrorControls(el as ArcMirror, rebuild), + }, + { + guard: (el) => el instanceof ParabolicMirror, + createView: (el, mvt) => new ParabolicMirrorView(el as ParabolicMirror, mvt), + // No editable properties for ParabolicMirror. + }, + { + guard: (el) => el instanceof IdealCurvedMirror, + createView: (el, mvt) => new IdealCurvedMirrorView(el as IdealCurvedMirror, mvt), + buildEditControls: (el, rebuild) => buildIdealCurvedMirrorControls(el as IdealCurvedMirror, rebuild), + }, + { + guard: (el) => el instanceof BeamSplitterElement, + createView: (el, mvt) => new BeamSplitterView(el as BeamSplitterElement, mvt), + buildEditControls: (el, rebuild) => buildBeamSplitterControls(el as BeamSplitterElement, rebuild), + }, + + // ── Glass / Lenses (specific subclasses before base classes) ─────────────── + { + guard: (el) => el instanceof IdealLens, + createView: (el, mvt) => new IdealLensView(el as IdealLens, mvt), + buildEditControls: (el, rebuild) => buildIdealLensControls(el as IdealLens, rebuild), + }, + { + guard: (el) => el instanceof CircleGlass, + createView: (el, mvt) => new CircleGlassView(el as CircleGlass, mvt), + buildEditControls: (el, rebuild) => buildRefractiveIndexControls(el as CircleGlass, rebuild), + }, + { + guard: (el) => el instanceof BiconvexLens, + createView: (el, mvt) => new BiconvexLensView(el as BiconvexLens, mvt), + buildEditControls: (el, rebuild, signConvention) => + buildSphericalLensControls(el as BiconvexLens, rebuild, signConvention), + }, + { + guard: (el) => el instanceof BiconcaveLens, + createView: (el, mvt) => new BiconcaveLensView(el as BiconcaveLens, mvt), + buildEditControls: (el, rebuild, signConvention) => + buildSphericalLensControls(el as BiconcaveLens, rebuild, signConvention), + }, + { + guard: (el) => el instanceof PlanoConvexLens, + createView: (el, mvt) => new PlanoConvexLensView(el as PlanoConvexLens, mvt), + buildEditControls: (el, rebuild, signConvention) => + buildSphericalLensControls(el as PlanoConvexLens, rebuild, signConvention), + }, + { + guard: (el) => el instanceof PlanoConcaveLens, + createView: (el, mvt) => new PlanoConcaveLensView(el as PlanoConcaveLens, mvt), + buildEditControls: (el, rebuild, signConvention) => + buildSphericalLensControls(el as PlanoConcaveLens, rebuild, signConvention), + }, + { + guard: (el) => el instanceof SphericalLens, + createView: (el, mvt) => new SphericalLensView(el as SphericalLens, mvt), + buildEditControls: (el, rebuild, signConvention) => + buildSphericalLensControls(el as SphericalLens, rebuild, signConvention), + }, + // Typed prisms — checked before generic Glass. + { + guard: (el) => + el instanceof EquilateralPrism || + el instanceof RightAnglePrism || + el instanceof PorroPrism || + el instanceof SlabGlass || + el instanceof ParallelogramPrism || + el instanceof DovePrism, + createView: (el, mvt) => + new TypedPrismView( + el as EquilateralPrism | RightAnglePrism | PorroPrism | SlabGlass | ParallelogramPrism | DovePrism, + mvt, + ), + buildEditControls: (el, rebuild) => { + if (el instanceof EquilateralPrism) { + return buildEquilateralPrismControls(el, rebuild); + } + if (el instanceof RightAnglePrism) { + return buildRightAnglePrismControls(el, rebuild); + } + if (el instanceof PorroPrism) { + return buildPorroPrismControls(el, rebuild); + } + if (el instanceof SlabGlass) { + return buildSlabGlassControls(el, rebuild); + } + if (el instanceof ParallelogramPrism) { + return buildParallelogramPrismControls(el, rebuild); + } + if (el instanceof DovePrism) { + return buildDovePrismControls(el, rebuild); + } + return { controls: [], refreshCallback: null }; + }, + }, + { + guard: (el) => el instanceof Glass, + createView: (el, mvt) => new GlassView(el as Glass, mvt), + buildEditControls: (el, rebuild) => buildRefractiveIndexControls(el as BaseGlass, rebuild), + }, + { + guard: (el) => el instanceof HalfPlaneGlass, + createView: (el, mvt) => new HalfPlaneGlassView(el as HalfPlaneGlass, mvt), + buildEditControls: (el, rebuild) => buildRefractiveIndexControls(el as BaseGlass, rebuild), + }, + + // ── Gratings ─────────────────────────────────────────────────────────────── + { + guard: (el) => el instanceof TransmissionGrating, + createView: (el, mvt) => new TransmissionGratingView(el as TransmissionGrating, mvt), + buildEditControls: (el, rebuild) => buildGratingControls(el as TransmissionGrating, rebuild), + }, + { + guard: (el) => el instanceof ReflectionGrating, + createView: (el, mvt) => new ReflectionGratingView(el as ReflectionGrating, mvt), + buildEditControls: (el, rebuild) => buildGratingControls(el as ReflectionGrating, rebuild), + }, + + // ── Detectors ────────────────────────────────────────────────────────────── + { + guard: (el) => el instanceof DetectorElement, + createView: (el, mvt) => new DetectorView(el as DetectorElement, mvt), + buildEditControls: (el, rebuild) => buildDetectorControls(el as DetectorElement, rebuild), + }, + + // ── Blockers ─────────────────────────────────────────────────────────────── + { + guard: (el) => el instanceof ApertureElement, + createView: (el, mvt) => new ApertureView(el as ApertureElement, mvt), + // No editable properties for ApertureElement. + }, + { + guard: (el) => el instanceof LineBlocker, + createView: (el, mvt) => new LineBlockerView(el as LineBlocker, mvt), + buildEditControls: (el, rebuild) => buildSegmentControls(el as LineBlocker, rebuild), + }, + + // ── Guides ───────────────────────────────────────────────────────────────── + { + guard: (el) => el instanceof TrackElement, + createView: (el, mvt) => new TrackView(el as TrackElement, mvt), + buildEditControls: (el, rebuild) => buildSegmentControls(el as TrackElement, rebuild), + }, +]; + +/** + * Create and return a Scenery Node that visually represents the given optical + * element, or null if no descriptor matches. + */ +export function createOpticalElementView( + element: OpticalElement, + modelViewTransform: ModelViewTransform2, +): OpticalElementView | null { + for (const descriptor of ELEMENT_REGISTRY) { + if (descriptor.guard(element)) { + return descriptor.createView(element, modelViewTransform); + } + } + return null; +} + +/** + * Build the property controls appropriate for the given optical element. + * Returns empty controls when the element has no editable properties or no + * registry entry matches. + */ +export function buildEditControls( + element: OpticalElement, + triggerRebuild: () => void, + signConvention: SignConvention, +): EditControlsResult { + for (const descriptor of ELEMENT_REGISTRY) { + if (descriptor.guard(element) && descriptor.buildEditControls) { + return descriptor.buildEditControls(element, triggerRebuild, signConvention); + } + } + return { controls: [], refreshCallback: null }; +} diff --git a/src/common/view/OpticalElementViewFactory.ts b/src/common/view/OpticalElementViewFactory.ts index fb80483..468d44a 100644 --- a/src/common/view/OpticalElementViewFactory.ts +++ b/src/common/view/OpticalElementViewFactory.ts @@ -2,203 +2,29 @@ * OpticalElementViewFactory.ts * * Factory that creates the appropriate Scenery Node for any OpticalElement. - * Maps model types to their corresponding view classes. + * Dispatch is now handled by the central ElementRegistry so that adding a new + * element type only requires updating ElementRegistry.ts. */ import type { ModelViewTransform2 } from "scenerystack/phetcommon"; -import type { Node, RichDragListener } from "scenerystack/scenery"; import opticsLab from "../../OpticsLabNamespace.js"; -import { ApertureElement } from "../model/blockers/ApertureElement.js"; -import { LineBlocker } from "../model/blockers/LineBlocker.js"; -import { DetectorElement } from "../model/detectors/DetectorElement.js"; -import { BiconcaveLens } from "../model/glass/BiconcaveLens.js"; -import { BiconvexLens } from "../model/glass/BiconvexLens.js"; -import { CircleGlass } from "../model/glass/CircleGlass.js"; -import { DovePrism } from "../model/glass/DovePrism.js"; -import { EquilateralPrism } from "../model/glass/EquilateralPrism.js"; -import { Glass } from "../model/glass/Glass.js"; -import { HalfPlaneGlass } from "../model/glass/HalfPlaneGlass.js"; -import { IdealLens } from "../model/glass/IdealLens.js"; -import { ParallelogramPrism } from "../model/glass/ParallelogramPrism.js"; -import { PlanoConcaveLens } from "../model/glass/PlanoConcaveLens.js"; -import { PlanoConvexLens } from "../model/glass/PlanoConvexLens.js"; -import { PorroPrism } from "../model/glass/PorroPrism.js"; -import { RightAnglePrism } from "../model/glass/RightAnglePrism.js"; -import { SlabGlass } from "../model/glass/SlabGlass.js"; -import { SphericalLens } from "../model/glass/SphericalLens.js"; -import { ReflectionGrating } from "../model/gratings/ReflectionGrating.js"; -import { TransmissionGrating } from "../model/gratings/TransmissionGrating.js"; -import { TrackElement } from "../model/guides/TrackElement.js"; -import { ArcLightSource } from "../model/light-sources/ArcLightSource.js"; -import { BeamSource } from "../model/light-sources/BeamSource.js"; -import { ContinuousSpectrumSource } from "../model/light-sources/ContinuousSpectrumSource.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"; -import { ApertureView } from "./blockers/ApertureView.js"; -import { LineBlockerView } from "./blockers/LineBlockerView.js"; -import { DetectorView } from "./detectors/DetectorView.js"; -import { BiconcaveLensView } from "./glass/BiconcaveLensView.js"; -import { BiconvexLensView } from "./glass/BiconvexLensView.js"; -import { CircleGlassView } from "./glass/CircleGlassView.js"; -import { GlassView } from "./glass/GlassView.js"; -import { HalfPlaneGlassView } from "./glass/HalfPlaneGlassView.js"; -import { IdealLensView } from "./glass/IdealLensView.js"; -import { PlanoConcaveLensView } from "./glass/PlanoConcaveLensView.js"; -import { PlanoConvexLensView } from "./glass/PlanoConvexLensView.js"; -import { SphericalLensView } from "./glass/SphericalLensView.js"; -import { TypedPrismView } from "./glass/TypedPrismView.js"; -import { ReflectionGratingView } from "./gratings/ReflectionGratingView.js"; -import { TransmissionGratingView } from "./gratings/TransmissionGratingView.js"; -import { TrackView } from "./guides/TrackView.js"; -import { ArcLightSourceView } from "./light-sources/ArcLightSourceView.js"; -import { BeamSourceView } from "./light-sources/BeamSourceView.js"; -import { ContinuousSpectrumSourceView } from "./light-sources/ContinuousSpectrumSourceView.js"; -import { PointSourceView } from "./light-sources/PointSourceView.js"; -import { SingleRaySourceView } from "./light-sources/SingleRaySourceView.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"; +import type { OpticalElementView } from "./ElementRegistry.js"; +import { createOpticalElementView as createFromRegistry } from "./ElementRegistry.js"; -/** - * An optical element view is a Node that also exposes its body-drag listener - * so that carousel drag-forwarding can press it programmatically. - */ -export type OpticalElementView = Node & { readonly bodyDragListener: RichDragListener }; - -/** Returns true for typed (fixed-shape) Glass prisms that use TypedPrismView. */ -function isTypedPrism( - element: OpticalElement, -): element is EquilateralPrism | RightAnglePrism | PorroPrism | SlabGlass | ParallelogramPrism | DovePrism { - return ( - element instanceof EquilateralPrism || - element instanceof RightAnglePrism || - element instanceof PorroPrism || - element instanceof SlabGlass || - element instanceof ParallelogramPrism || - element instanceof DovePrism - ); -} - -/** Create a view for glass/lens elements; returns null if not a glass/lens type. */ -function createGlassView(element: OpticalElement, mvt: ModelViewTransform2): OpticalElementView | null { - if (element instanceof IdealLens) { - return new IdealLensView(element, mvt); - } - if (element instanceof CircleGlass) { - return new CircleGlassView(element, mvt); - } - // Typed SphericalLens subclasses must be checked before SphericalLens itself. - if (element instanceof BiconvexLens) { - return new BiconvexLensView(element, mvt); - } - if (element instanceof BiconcaveLens) { - return new BiconcaveLensView(element, mvt); - } - if (element instanceof PlanoConvexLens) { - return new PlanoConvexLensView(element, mvt); - } - if (element instanceof PlanoConcaveLens) { - return new PlanoConcaveLensView(element, mvt); - } - if (element instanceof SphericalLens) { - return new SphericalLensView(element, mvt); - } - // Typed Glass prisms must be checked before the generic Glass fallback. - if (isTypedPrism(element)) { - return new TypedPrismView(element, mvt); - } - if (element instanceof Glass) { - return new GlassView(element, mvt); - } - if (element instanceof HalfPlaneGlass) { - return new HalfPlaneGlassView(element, mvt); - } - return null; -} +// Re-export so callers that previously imported OpticalElementView from this +// file continue to work without any import-path changes. +export type { OpticalElementView } from "./ElementRegistry.js"; /** * Create and return a Scenery Node that visually represents the given - * optical element. Returns null if the element type has no view. + * optical element. Returns null if the element type has no registered view. */ export function createOpticalElementView( element: OpticalElement, modelViewTransform: ModelViewTransform2, ): OpticalElementView | null { - // ── Light Sources ───────────────────────────────────────────────────────── - if (element instanceof ArcLightSource) { - return new ArcLightSourceView(element, modelViewTransform); - } - if (element instanceof PointSourceElement) { - return new PointSourceView(element, modelViewTransform); - } - if (element instanceof BeamSource) { - return new BeamSourceView(element, modelViewTransform); - } - if (element instanceof SingleRaySource) { - return new SingleRaySourceView(element, modelViewTransform); - } - if (element instanceof ContinuousSpectrumSource) { - return new ContinuousSpectrumSourceView(element, modelViewTransform); - } - - // ── Mirrors ─────────────────────────────────────────────────────────────── - if (element instanceof SegmentMirror) { - return new SegmentMirrorView(element, modelViewTransform); - } - if (element instanceof ArcMirror) { - return new ArcMirrorView(element, modelViewTransform); - } - if (element instanceof ParabolicMirror) { - return new ParabolicMirrorView(element, modelViewTransform); - } - if (element instanceof IdealCurvedMirror) { - return new IdealCurvedMirrorView(element, modelViewTransform); - } - if (element instanceof BeamSplitterElement) { - return new BeamSplitterView(element, modelViewTransform); - } - - // ── Glass / Lenses ──────────────────────────────────────────────────────── - const glassView = createGlassView(element, modelViewTransform); - if (glassView) { - return glassView; - } - - // ── Gratings ───────────────────────────────────────────────────────────── - if (element instanceof TransmissionGrating) { - return new TransmissionGratingView(element, modelViewTransform); - } - if (element instanceof ReflectionGrating) { - return new ReflectionGratingView(element, modelViewTransform); - } - - // ── Detectors ───────────────────────────────────────────────────────────── - if (element instanceof DetectorElement) { - return new DetectorView(element, modelViewTransform); - } - - // ── Blockers ────────────────────────────────────────────────────────────── - if (element instanceof ApertureElement) { - return new ApertureView(element, modelViewTransform); - } - if (element instanceof LineBlocker) { - return new LineBlockerView(element, modelViewTransform); - } - - // ── Guides ─────────────────────────────────────────────────────────────── - if (element instanceof TrackElement) { - return new TrackView(element, modelViewTransform); - } - - return null; + return createFromRegistry(element, modelViewTransform); } opticsLab.register("createOpticalElementView", createOpticalElementView); diff --git a/src/common/view/carousel/CarouselIcons.ts b/src/common/view/carousel/CarouselIcons.ts new file mode 100644 index 0000000..59ace29 --- /dev/null +++ b/src/common/view/carousel/CarouselIcons.ts @@ -0,0 +1,854 @@ +/** + * CarouselIcons.ts + * + * SVG icon builder functions for every optical component available in the + * carousel toolbox. Each function returns a Scenery Node ready to be used + * as a carousel thumbnail. + * + * Functions are grouped by element family and exported individually so that + * ComponentCarousel can import exactly what it needs. + */ + +import { Shape } from "scenerystack/kite"; +import { Circle, Node, Path } from "scenerystack/scenery"; +import { VisibleColor } from "scenerystack/scenery-phet"; +import OpticsLabColors from "../../../OpticsLabColors.js"; +import { CONT_SPECTRUM_SAMPLE_WL } from "../../../OpticsLabConstants.js"; + +// ── Light Sources ───────────────────────────────────────────────────────────── + +export function pointSourceIcon(): Node { + const node = new Node(); + const coreGlowRadius = 6; + const glowOutlineWidth = 1.5; + const spokeCount = 8; + const spokeInnerRadius = 7; + const spokeOuterRadius = 14; + const spokeLineWidth = 1; + const fullTurnRadians = Math.PI * 2; + + const glow = new Circle(coreGlowRadius, { + fill: OpticsLabColors.arcSourceGlowFillProperty, + stroke: OpticsLabColors.arcSourceGlowStrokeProperty, + lineWidth: glowOutlineWidth, + }); + node.addChild(glow); + const spokeShape = new Shape(); + for (let i = 0; i < spokeCount; i++) { + const a = (i / spokeCount) * fullTurnRadians; + spokeShape.moveTo(Math.cos(a) * spokeInnerRadius, Math.sin(a) * spokeInnerRadius); + spokeShape.lineTo(Math.cos(a) * spokeOuterRadius, Math.sin(a) * spokeOuterRadius); + } + node.addChild( + new Path(spokeShape, { + stroke: OpticsLabColors.arcSourceSpokeStrokeProperty, + lineWidth: spokeLineWidth, + }), + ); + return node; +} + +export function arcSourceIcon(): Node { + const node = new Node(); + const arcRadius = 14; + const rimHalfAngleTurns = 0.6; + const sectorHalfAngleTurns = 0.3; + const rimLineWidth = 1; + const sectorLineWidth = 1.2; + const spokeIndexMin = -2; + const spokeIndexMax = 2; + const spokeIndexScale = 2; + const spokeFanHalfAngleTurns = 0.55; + const spokeInnerRadius = 4; + const spokeOuterRadius = 12; + const spokeLineWidth = 1; + const coreRadius = 4; + const coreOutlineWidth = 1.5; + + const rimShape = new Shape().arc(0, 0, arcRadius, -Math.PI * rimHalfAngleTurns, Math.PI * rimHalfAngleTurns, false); + node.addChild(new Path(rimShape, { stroke: OpticsLabColors.arcSourceRimStrokeProperty, lineWidth: rimLineWidth })); + const sectorShape = new Shape() + .moveTo(0, 0) + .arc(0, 0, arcRadius, -Math.PI * sectorHalfAngleTurns, Math.PI * sectorHalfAngleTurns, false) + .close(); + node.addChild( + new Path(sectorShape, { + fill: OpticsLabColors.arcSourceSectorFillProperty, + stroke: OpticsLabColors.arcSourceSectorStrokeProperty, + lineWidth: sectorLineWidth, + }), + ); + const spokeShape = new Shape(); + for (let i = spokeIndexMin; i <= spokeIndexMax; i++) { + const a = (i / spokeIndexScale) * Math.PI * spokeFanHalfAngleTurns; + spokeShape.moveTo(Math.cos(a) * spokeInnerRadius, Math.sin(a) * spokeInnerRadius); + spokeShape.lineTo(Math.cos(a) * spokeOuterRadius, Math.sin(a) * spokeOuterRadius); + } + node.addChild( + new Path(spokeShape, { stroke: OpticsLabColors.arcSourceSpokeStrokeProperty, lineWidth: spokeLineWidth }), + ); + node.addChild( + new Circle(coreRadius, { + fill: OpticsLabColors.arcSourceGlowFillProperty, + stroke: OpticsLabColors.arcSourceGlowStrokeProperty, + lineWidth: coreOutlineWidth, + }), + ); + return node; +} + +export function beamSourceIcon(): Node { + const node = new Node(); + const rayVerticalSpacing = 8; + const rayVerticalMin = -rayVerticalSpacing; + const rayVerticalMax = rayVerticalSpacing; + const rayTailX = -12; + const rayHeadX = 8; + const rayLineWidth = 1.5; + const arrowBaseX = 5; + const arrowTipX = 12; + const arrowHalfHeight = 4; + + for (let dy = rayVerticalMin; dy <= rayVerticalMax; dy += rayVerticalSpacing) { + const shape = new Shape().moveTo(rayTailX, dy).lineTo(rayHeadX, dy); + node.addChild(new Path(shape, { stroke: OpticsLabColors.iconRayStrokeProperty, lineWidth: rayLineWidth })); + const arrow = new Shape() + .moveTo(arrowBaseX, dy - arrowHalfHeight) + .lineTo(arrowTipX, dy) + .lineTo(arrowBaseX, dy + arrowHalfHeight); + node.addChild( + new Path(arrow, { + stroke: OpticsLabColors.iconRayStrokeProperty, + lineWidth: rayLineWidth, + lineCap: "round", + lineJoin: "round", + }), + ); + } + return node; +} + +export function singleRayIcon(): Node { + const node = new Node(); + const tailX = -14; + const shaftEndX = 10; + const rayLineWidth = 2; + const arrowBaseX = 6; + const arrowTipX = 14; + const arrowHalfHeight = 5; + + const shape = new Shape().moveTo(tailX, 0).lineTo(shaftEndX, 0); + node.addChild(new Path(shape, { stroke: OpticsLabColors.iconRayStrokeProperty, lineWidth: rayLineWidth })); + const arrow = new Shape() + .moveTo(arrowBaseX, -arrowHalfHeight) + .lineTo(arrowTipX, 0) + .lineTo(arrowBaseX, arrowHalfHeight); + node.addChild( + new Path(arrow, { + stroke: OpticsLabColors.iconRayStrokeProperty, + lineWidth: rayLineWidth, + lineCap: "round", + lineJoin: "round", + }), + ); + return node; +} + +export function contSpectrumIcon(): Node { + const node = new Node(); + const spectrumArcRadius = 9; + const spectrumStrokeAlpha = 0.9; + const spectrumArcLineWidth = 2.5; + const directionShaftLength = 14; + const directionLineWidth = 1.5; + const directionArrowBaseX = 10; + const directionArrowTipX = 14; + const directionArrowHalfHeight = 4; + const fullTurnRadians = Math.PI * 2; + + // Rainbow disc: one arc per sampled wavelength. + const arcSpan = fullTurnRadians / CONT_SPECTRUM_SAMPLE_WL.length; + for (let i = 0; i < CONT_SPECTRUM_SAMPLE_WL.length; i++) { + const wl = CONT_SPECTRUM_SAMPLE_WL[i] ?? CONT_SPECTRUM_SAMPLE_WL[0]; + const c = VisibleColor.wavelengthToColor(wl); + const shape = new Shape().arc(0, 0, spectrumArcRadius, i * arcSpan, (i + 1) * arcSpan); + node.addChild( + new Path(shape, { + stroke: `rgba(${c.r},${c.g},${c.b},${spectrumStrokeAlpha})`, + lineWidth: spectrumArcLineWidth, + }), + ); + } + const line = new Shape().moveTo(0, 0).lineTo(directionShaftLength, 0); + node.addChild(new Path(line, { stroke: OpticsLabColors.sourceDirLineStrokeProperty, lineWidth: directionLineWidth })); + const arrow = new Shape() + .moveTo(directionArrowBaseX, -directionArrowHalfHeight) + .lineTo(directionArrowTipX, 0) + .lineTo(directionArrowBaseX, directionArrowHalfHeight); + node.addChild( + new Path(arrow, { + stroke: OpticsLabColors.sourceDirArrowStrokeProperty, + lineWidth: directionLineWidth, + lineCap: "round", + lineJoin: "round", + }), + ); + return node; +} + +// ── Mirrors ─────────────────────────────────────────────────────────────────── + +export function segmentMirrorIcon(): Node { + const node = new Node(); + const halfLength = 14; + const backLineWidth = 4; + const frontLineWidth = 2; + + const shape = new Shape().moveTo(-halfLength, 0).lineTo(halfLength, 0); + node.addChild( + new Path(shape, { stroke: OpticsLabColors.mirrorBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), + ); + node.addChild( + new Path(shape, { stroke: OpticsLabColors.mirrorFrontStrokeProperty, lineWidth: frontLineWidth, lineCap: "round" }), + ); + return node; +} + +export function arcMirrorIcon(): Node { + const node = new Node(); + const arcCenterYOffset = 20; + const arcRadius = 24; + const arcStartAngleTurns = -0.75; + const arcEndAngleTurns = -0.25; + const backLineWidth = 4; + const frontLineWidth = 2; + + const shape = new Shape().arc( + 0, + arcCenterYOffset, + arcRadius, + Math.PI * arcStartAngleTurns, + Math.PI * arcEndAngleTurns, + ); + node.addChild( + new Path(shape, { stroke: OpticsLabColors.mirrorBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), + ); + node.addChild( + new Path(shape, { stroke: OpticsLabColors.mirrorFrontStrokeProperty, lineWidth: frontLineWidth, lineCap: "round" }), + ); + return node; +} + +export function parabolicMirrorIcon(): Node { + const node = new Node(); + const shape = new Shape(); + const segmentCount = 20; + const horizontalHalfExtent = 14; + const parabolaDepth = 10; + const parabolaVerticalOffset = 4; + const backLineWidth = 4; + const frontLineWidth = 2; + + for (let i = 0; i <= segmentCount; i++) { + const t = (i / segmentCount) * 2 - 1; + const x = t * horizontalHalfExtent; + const y = -t * t * parabolaDepth + parabolaVerticalOffset; + if (i === 0) { + shape.moveTo(x, y); + } else { + shape.lineTo(x, y); + } + } + node.addChild( + new Path(shape, { + stroke: OpticsLabColors.mirrorBackStrokeProperty, + lineWidth: backLineWidth, + lineCap: "round", + lineJoin: "round", + }), + ); + node.addChild( + new Path(shape, { + stroke: OpticsLabColors.mirrorFrontStrokeProperty, + lineWidth: frontLineWidth, + lineCap: "round", + lineJoin: "round", + }), + ); + return node; +} + +export function idealCurvedMirrorIcon(): Node { + const node = new Node(); + const halfLength = 14; + const backLineWidth = 4; + const frontLineWidth = 2; + const focalPointMarkerRadius = 2.5; + const focalPointMarkerYOffset = -8; + + const lineShape = new Shape().moveTo(-halfLength, 0).lineTo(halfLength, 0); + node.addChild( + new Path(lineShape, { + stroke: OpticsLabColors.mirrorBackStrokeProperty, + lineWidth: backLineWidth, + lineCap: "round", + }), + ); + node.addChild( + new Path(lineShape, { + stroke: OpticsLabColors.mirrorFrontStrokeProperty, + lineWidth: frontLineWidth, + lineCap: "round", + }), + ); + node.addChild( + new Circle(focalPointMarkerRadius, { + x: 0, + y: focalPointMarkerYOffset, + fill: OpticsLabColors.pointSourceFillProperty, + }), + ); + return node; +} + +export function beamSplitterIcon(): Node { + const node = new Node(); + const plateHalfExtent = 10; + const bodyLineWidth = 2; + const dashOnLength = 4; + const dashGapLength = 3; + const reflectedRayLineWidth = 1.5; + + const lineShape = new Shape().moveTo(-plateHalfExtent, plateHalfExtent).lineTo(plateHalfExtent, -plateHalfExtent); + node.addChild( + new Path(lineShape, { + stroke: OpticsLabColors.beamSplitterIconBodyStrokeProperty, + lineWidth: bodyLineWidth, + lineDash: [dashOnLength, dashGapLength], + }), + ); + node.addChild( + new Path(new Shape().moveTo(0, 0).lineTo(-plateHalfExtent, -plateHalfExtent), { + stroke: OpticsLabColors.mirrorFrontStrokeProperty, + lineWidth: reflectedRayLineWidth, + }), + ); + node.addChild( + new Path(new Shape().moveTo(0, 0).lineTo(plateHalfExtent, plateHalfExtent), { + stroke: OpticsLabColors.mirrorFrontStrokeProperty, + lineWidth: reflectedRayLineWidth, + }), + ); + return node; +} + +// ── Lenses / Glass ──────────────────────────────────────────────────────────── + +export function idealLensIcon(): Node { + const node = new Node(); + const halfHeight = 14; + const verticalLineWidth = 2.5; + const arrowLineWidth = 2; + const arrowHalfWidth = 5; + const arrowTipInsetFromEnd = 4; + + const lineShape = new Shape().moveTo(0, -halfHeight).lineTo(0, halfHeight); + node.addChild( + new Path(lineShape, { + stroke: OpticsLabColors.idealLensStrokeProperty, + lineWidth: verticalLineWidth, + lineCap: "round", + }), + ); + const topArrowTipY = -halfHeight; + const topArrowBaseY = topArrowTipY + arrowTipInsetFromEnd; + const botArrowTipY = halfHeight; + const botArrowBaseY = botArrowTipY - arrowTipInsetFromEnd; + const topArrow = new Shape() + .moveTo(-arrowHalfWidth, topArrowBaseY) + .lineTo(0, topArrowTipY) + .lineTo(arrowHalfWidth, topArrowBaseY); + const botArrow = new Shape() + .moveTo(-arrowHalfWidth, botArrowBaseY) + .lineTo(0, botArrowTipY) + .lineTo(arrowHalfWidth, botArrowBaseY); + node.addChild( + new Path(topArrow, { + stroke: OpticsLabColors.idealLensStrokeProperty, + lineWidth: arrowLineWidth, + lineCap: "round", + lineJoin: "round", + }), + ); + node.addChild( + new Path(botArrow, { + stroke: OpticsLabColors.idealLensStrokeProperty, + lineWidth: arrowLineWidth, + lineCap: "round", + lineJoin: "round", + }), + ); + return node; +} + +export function circleGlassIcon(): Node { + const node = new Node(); + const discRadius = 12; + const outlineWidth = 1.5; + + node.addChild( + new Circle(discRadius, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function sphericalLensIcon(): Node { + const node = new Node(); + const lensCenterOffsetX = 12; + const surfaceArcRadius = 18; + const arcHalfAngle = Math.PI / 4; + const outlineWidth = 1.5; + + const shape = new Shape() + .arc(-lensCenterOffsetX, 0, surfaceArcRadius, -arcHalfAngle, arcHalfAngle) + .arc(lensCenterOffsetX, 0, surfaceArcRadius, Math.PI - arcHalfAngle, Math.PI + arcHalfAngle) + .close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function biconvexLensIcon(): Node { + const node = new Node(); + const lensCenterOffsetX = 8; + const surfaceArcRadius = 14; + const arcHalfAngle = Math.PI / 3.5; + const outlineWidth = 1.5; + + const shape = new Shape() + .arc(-lensCenterOffsetX, 0, surfaceArcRadius, -arcHalfAngle, arcHalfAngle) + .arc(lensCenterOffsetX, 0, surfaceArcRadius, Math.PI - arcHalfAngle, Math.PI + arcHalfAngle) + .close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function biconcaveLensIcon(): Node { + const node = new Node(); + const lensCenterOffsetX = 18; + const surfaceArcRadius = 12; + const arcHalfAngle = Math.PI / 2.5; + const outlineWidth = 1.5; + + const shape = new Shape() + .arc(lensCenterOffsetX, 0, surfaceArcRadius, Math.PI - arcHalfAngle, Math.PI + arcHalfAngle) + .arc(-lensCenterOffsetX, 0, surfaceArcRadius, -arcHalfAngle, arcHalfAngle) + .close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function planoConvexLensIcon(): Node { + const node = new Node(); + const halfHeight = 12; + const flatEdgeX = 10; + const convexArcCenterX = 16; + const convexDepth = 20; + const outlineWidth = 1.5; + + const shape = new Shape() + .moveTo(flatEdgeX, -halfHeight) + .arc( + convexArcCenterX, + 0, + Math.hypot(convexDepth, halfHeight), + -Math.atan2(halfHeight, convexDepth), + +Math.atan2(halfHeight, convexDepth), + false, + ) + .lineTo(flatEdgeX, halfHeight) + .close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function planoConcaveLensIcon(): Node { + const node = new Node(); + const halfHeight = 12; + const flatEdgeX = -4; + const concaveArcCenterX = 16; + const concaveDepth = 10; + const outlineWidth = 1.5; + + const shape = new Shape() + .moveTo(flatEdgeX, halfHeight) + .arc( + concaveArcCenterX, + 0, + Math.hypot(concaveDepth, halfHeight), + Math.PI - Math.atan2(halfHeight, concaveDepth), + Math.PI + Math.atan2(halfHeight, concaveDepth), + ) + .lineTo(flatEdgeX, -halfHeight) + .close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function polygonGlassIcon(): Node { + const node = new Node(); + const apexY = -12; + const baseHalfWidth = 12; + const baseY = 10; + const outlineWidth = 1.5; + + const shape = new Shape().moveTo(0, apexY).lineTo(baseHalfWidth, baseY).lineTo(-baseHalfWidth, baseY).close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function equilateralPrismIcon(): Node { + const node = new Node(); + const apexY = -12; + const baseCornerOffsetX = 11; + const baseY = 9; + const outlineWidth = 1.5; + + const shape = new Shape().moveTo(0, apexY).lineTo(baseCornerOffsetX, baseY).lineTo(-baseCornerOffsetX, baseY).close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function rightAnglePrismIcon(): Node { + const node = new Node(); + const legHalfLength = 9; + const outlineWidth = 1.5; + + const shape = new Shape() + .moveTo(-legHalfLength, legHalfLength) + .lineTo(legHalfLength, legHalfLength) + .lineTo(-legHalfLength, -legHalfLength) + .close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function porroPrismIcon(): Node { + const node = new Node(); + const verticalEdgeX = -4; + const verticalHalfExtent = 11; + const apexX = 8; + const outlineWidth = 1.5; + + const shape = new Shape() + .moveTo(verticalEdgeX, -verticalHalfExtent) + .lineTo(verticalEdgeX, verticalHalfExtent) + .lineTo(apexX, 0) + .close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function slabGlassIcon(): Node { + const node = new Node(); + const halfWidth = 12; + const halfHeight = 6; + const outlineWidth = 1.5; + + const shape = new Shape() + .moveTo(-halfWidth, -halfHeight) + .lineTo(halfWidth, -halfHeight) + .lineTo(halfWidth, halfHeight) + .lineTo(-halfWidth, halfHeight) + .close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function parallelogramPrismIcon(): Node { + const node = new Node(); + const topLeftX = -10; + const topRightX = 5; + const topY = 8; + const bottomRightX = 10; + const bottomLeftX = -5; + const bottomY = -8; + const outlineWidth = 1.5; + + const shape = new Shape() + .moveTo(topLeftX, topY) + .lineTo(topRightX, topY) + .lineTo(bottomRightX, bottomY) + .lineTo(bottomLeftX, bottomY) + .close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function dovePrismIcon(): Node { + const node = new Node(); + const topHalfWidth = 12; + const topY = 8; + const bottomRightX = 6; + const bottomLeftX = -6; + const bottomY = -8; + const outlineWidth = 1.5; + + const shape = new Shape() + .moveTo(-topHalfWidth, topY) + .lineTo(topHalfWidth, topY) + .lineTo(bottomRightX, bottomY) + .lineTo(bottomLeftX, bottomY) + .close(); + node.addChild( + new Path(shape, { + fill: OpticsLabColors.glassFillProperty, + stroke: OpticsLabColors.glassStrokeProperty, + lineWidth: outlineWidth, + }), + ); + return node; +} + +export function halfPlaneGlassIcon(): Node { + const node = new Node(); + const boundaryHalfLength = 14; + const boundaryLineWidth = 2; + const hatchStartX = -12; + const hatchEndX = 12; + const hatchSpacing = 5; + const hatchTopY = 2; + const hatchBottomY = 10; + const hatchSkew = 4; + const hatchLineWidth = 1; + + const lineShape = new Shape().moveTo(-boundaryHalfLength, 0).lineTo(boundaryHalfLength, 0); + node.addChild(new Path(lineShape, { stroke: OpticsLabColors.glassStrokeProperty, lineWidth: boundaryLineWidth })); + const hatchShape = new Shape(); + for (let x = hatchStartX; x <= hatchEndX; x += hatchSpacing) { + hatchShape.moveTo(x, hatchTopY).lineTo(x - hatchSkew, hatchBottomY); + } + node.addChild(new Path(hatchShape, { stroke: OpticsLabColors.glassHatchStrokeProperty, lineWidth: hatchLineWidth })); + return node; +} + +// ── Blockers / Detectors ────────────────────────────────────────────────────── + +export function lineBlockerIcon(): Node { + const node = new Node(); + const halfLength = 14; + const backLineWidth = 4; + const frontLineWidth = 2; + + const shape = new Shape().moveTo(-halfLength, 0).lineTo(halfLength, 0); + node.addChild( + new Path(shape, { stroke: OpticsLabColors.blockerBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), + ); + node.addChild( + new Path(shape, { + stroke: OpticsLabColors.blockerFrontStrokeProperty, + lineWidth: frontLineWidth, + lineCap: "round", + }), + ); + return node; +} + +export function detectorIcon(): Node { + const node = new Node(); + const bodyHalfLength = 14; + const backLineWidth = 4; + const frontLineWidth = 2; + const tickStartX = -10; + const tickEndX = 10; + const tickSpacing = 5; + const tickHalfHeight = 3; + const tickLineWidth = 1; + + const shape = new Shape().moveTo(-bodyHalfLength, 0).lineTo(bodyHalfLength, 0); + node.addChild( + new Path(shape, { stroke: OpticsLabColors.detectorBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), + ); + node.addChild( + new Path(shape, { + stroke: OpticsLabColors.detectorFrontStrokeProperty, + lineWidth: frontLineWidth, + lineCap: "round", + }), + ); + const ticks = new Shape(); + for (let x = tickStartX; x <= tickEndX; x += tickSpacing) { + ticks.moveTo(x, -tickHalfHeight).lineTo(x, tickHalfHeight); + } + node.addChild(new Path(ticks, { stroke: OpticsLabColors.detectorFrontStrokeProperty, lineWidth: tickLineWidth })); + return node; +} + +export function apertureIcon(): Node { + const node = new Node(); + const outerHalfExtent = 14; + const gapHalfWidth = 4; + const backLineWidth = 4; + const frontLineWidth = 2; + + const left = new Shape().moveTo(-outerHalfExtent, 0).lineTo(-gapHalfWidth, 0); + const right = new Shape().moveTo(gapHalfWidth, 0).lineTo(outerHalfExtent, 0); + node.addChild( + new Path(left, { stroke: OpticsLabColors.blockerBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), + ); + node.addChild( + new Path(left, { stroke: OpticsLabColors.blockerFrontStrokeProperty, lineWidth: frontLineWidth, lineCap: "round" }), + ); + node.addChild( + new Path(right, { stroke: OpticsLabColors.blockerBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), + ); + node.addChild( + new Path(right, { + stroke: OpticsLabColors.blockerFrontStrokeProperty, + lineWidth: frontLineWidth, + lineCap: "round", + }), + ); + return node; +} + +// ── Gratings ────────────────────────────────────────────────────────────────── + +export function transmissionGratingIcon(): Node { + const node = new Node(); + const halfHeight = 14; + const bodyLineWidth = 2.5; + const grooveStartY = -10; + const grooveEndY = 10; + const grooveSpacing = 5; + const grooveHalfLength = 4; + const grooveLineWidth = 1; + + const line = new Shape().moveTo(0, -halfHeight).lineTo(0, halfHeight); + node.addChild( + new Path(line, { stroke: OpticsLabColors.glassStrokeProperty, lineWidth: bodyLineWidth, lineCap: "round" }), + ); + const ticks = new Shape(); + for (let y = grooveStartY; y <= grooveEndY; y += grooveSpacing) { + ticks.moveTo(-grooveHalfLength, y).lineTo(grooveHalfLength, y); + } + node.addChild(new Path(ticks, { stroke: OpticsLabColors.glassStrokeProperty, lineWidth: grooveLineWidth })); + return node; +} + +export function reflectionGratingIcon(): Node { + const node = new Node(); + const halfHeight = 14; + const backLineWidth = 4; + const frontLineWidth = 2; + const hatchStartY = -10; + const hatchEndY = 10; + const hatchVerticalStep = 4; + const hatchHorizontalExtent = -5; + const hatchVerticalDrop = 2; + const hatchLineWidth = 1; + + const line = new Shape().moveTo(0, -halfHeight).lineTo(0, halfHeight); + node.addChild( + new Path(line, { stroke: OpticsLabColors.mirrorBackStrokeProperty, lineWidth: backLineWidth, lineCap: "round" }), + ); + node.addChild( + new Path(line, { stroke: OpticsLabColors.mirrorFrontStrokeProperty, lineWidth: frontLineWidth, lineCap: "round" }), + ); + const hatches = new Shape(); + for (let y = hatchStartY; y <= hatchEndY; y += hatchVerticalStep) { + hatches.moveTo(0, y).lineTo(hatchHorizontalExtent, y + hatchVerticalDrop); + } + node.addChild(new Path(hatches, { stroke: OpticsLabColors.mirrorFrontStrokeProperty, lineWidth: hatchLineWidth })); + return node; +} + +// ── Guides ──────────────────────────────────────────────────────────────────── + +export function trackIcon(): Node { + const node = new Node(); + const halfLength = 14; + const lineWidth = 2; + const dashOnLength = 6; + const dashGapLength = 3; + + const shape = new Shape().moveTo(-halfLength, 0).lineTo(halfLength, 0); + node.addChild( + new Path(shape, { + stroke: OpticsLabColors.trackStrokeProperty, + lineWidth, + lineDash: [dashOnLength, dashGapLength], + lineCap: "round", + }), + ); + return node; +} diff --git a/src/common/view/edit-controls/EditControlHelpers.ts b/src/common/view/edit-controls/EditControlHelpers.ts new file mode 100644 index 0000000..cb03c16 --- /dev/null +++ b/src/common/view/edit-controls/EditControlHelpers.ts @@ -0,0 +1,228 @@ +/** + * EditControlHelpers.ts + * + * Shared utilities for building edit-panel controls: widget factories, + * a wavelength control, and segment-resize helpers. All symbols are + * re-used across the per-element-family control builders. + */ + +import { NumberProperty, type ReadOnlyProperty } from "scenerystack/axon"; +import { Dimension2, Range } from "scenerystack/dot"; +import type { Node } from "scenerystack/scenery"; +import { NumberControl, SpectrumSliderTrack, VisibleColor } from "scenerystack/scenery-phet"; +import { Tandem } from "scenerystack/tandem"; +import OpticsLabColors from "../../../OpticsLabColors.js"; +import { + SEGMENT_LENGTH_MAX, + SEGMENT_LENGTH_MIN, + SLIDER_THUMB_HEIGHT, + SLIDER_THUMB_WIDTH, + SLIDER_TRACK_HEIGHT, + SLIDER_TRACK_WIDTH, + WAVELENGTH_CONTROL_DELTA, +} from "../../../OpticsLabConstants.js"; +import { SymmetricWavelengthThumb } from "../SymmetricWavelengthThumb.js"; + +export type { EditControlsResult } from "./EditControlsResult.js"; + +// ── Module-level constants ──────────────────────────────────────────────────── + +export const SLIDER_TRACK_SIZE = new Dimension2(SLIDER_TRACK_WIDTH, SLIDER_TRACK_HEIGHT); +export const SLIDER_THUMB_SIZE = new Dimension2(SLIDER_THUMB_WIDTH, SLIDER_THUMB_HEIGHT); +export const LABEL_FONT = "11px sans-serif"; + +// ── Private widget helpers ──────────────────────────────────────────────────── + +/** Clamp a number to a range, replacing non-finite values with a fallback. */ +export function safeClamp(value: number, min: number, max: number, fallback: number): number { + return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback; +} + +/** Number of decimal places to show for a given step size. */ +export function decimalPlacesForDelta(delta: number): number { + const abs = Math.abs(delta); + if (abs >= 1) { + return 0; + } + if (abs >= 0.1) { + return 1; + } + return 2; +} + +/** + * Build a NumberControl: a labeled slider that also displays the current value. + */ +export function makeControl( + label: string | ReadOnlyProperty, + initValue: number, + range: Range, + delta: number, + onSet: (v: number) => void, + onAfterSet: () => void, +): Node { + const clampedInit = Math.max(range.min, Math.min(range.max, initValue)); + const prop = new NumberProperty(clampedInit, { range, tandem: Tandem.OPT_OUT }); + prop.lazyLink((v) => { + onSet(v); + onAfterSet(); + }); + return new NumberControl(label, prop, range, { + delta, + includeArrowButtons: true, + soundGenerator: null, + layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), + titleNodeOptions: { + fill: OpticsLabColors.overlayLabelFillProperty, + font: LABEL_FONT, + }, + numberDisplayOptions: { + decimalPlaces: decimalPlacesForDelta(delta), + textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, + backgroundFill: "rgba(0,0,0,0.35)", + backgroundStroke: "rgba(100,100,120,0.6)", + }, + sliderOptions: { + trackSize: SLIDER_TRACK_SIZE, + thumbSize: SLIDER_THUMB_SIZE, + tandem: Tandem.OPT_OUT, + }, + tandem: Tandem.OPT_OUT, + }); +} + +/** + * Build a wavelength control with a spectrum-coloured track and a custom + * symmetric thumb (centred on the track, not hanging below it). + */ +export function makeWavelengthControl( + initValue: number, + range: Range, + onSet: (v: number) => void, + onAfterSet: () => void, +): Node { + const clampedInit = Math.max(range.min, Math.min(range.max, initValue)); + const prop = new NumberProperty(clampedInit, { range, tandem: Tandem.OPT_OUT }); + prop.lazyLink((v) => { + onSet(v); + onAfterSet(); + }); + + const trackNode = new SpectrumSliderTrack(prop, range, { + valueToColor: VisibleColor.wavelengthToColor, + size: SLIDER_TRACK_SIZE, + tandem: Tandem.OPT_OUT, + }); + + const thumbNode = new SymmetricWavelengthThumb(prop); + + return new NumberControl("λ", prop, range, { + delta: WAVELENGTH_CONTROL_DELTA, + includeArrowButtons: true, + soundGenerator: null, + layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), + titleNodeOptions: { + fill: OpticsLabColors.overlayLabelFillProperty, + font: LABEL_FONT, + }, + numberDisplayOptions: { + decimalPlaces: 0, + valuePattern: "{0} nm", + textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, + backgroundFill: "rgba(0,0,0,0.35)", + backgroundStroke: "rgba(100,100,120,0.6)", + }, + sliderOptions: { + trackNode, + thumbNode, + tandem: Tandem.OPT_OUT, + }, + tandem: Tandem.OPT_OUT, + }); +} + +/** + * Shared NumberControl options used throughout the panel. + */ +export function numberControlOptions(delta: number, decimalPlaces: number) { + return { + delta, + includeArrowButtons: true, + soundGenerator: null, + layoutFunction: NumberControl.createLayoutFunction4({ verticalSpacing: 4 }), + titleNodeOptions: { fill: OpticsLabColors.overlayLabelFillProperty, font: LABEL_FONT }, + numberDisplayOptions: { + decimalPlaces, + textOptions: { fill: OpticsLabColors.overlayValueFillProperty, font: LABEL_FONT }, + backgroundFill: "rgba(0,0,0,0.35)", + backgroundStroke: "rgba(100,100,120,0.6)", + }, + sliderOptions: { + trackSize: SLIDER_TRACK_SIZE, + thumbSize: SLIDER_THUMB_SIZE, + tandem: Tandem.OPT_OUT, + }, + tandem: Tandem.OPT_OUT, + }; +} + +// ── Shared segment helpers ──────────────────────────────────────────────────── + +/** Euclidean length of a two-point segment in model space. */ +export function segmentLength(p1: { x: number; y: number }, p2: { x: number; y: number }): number { + return Math.hypot(p2.x - p1.x, p2.y - p1.y); +} + +/** Resize a segment to newLength while keeping its centre and orientation fixed. */ +export function resizeSegment( + p1: { x: number; y: number }, + p2: { x: number; y: number }, + newLength: number, +): { p1: { x: number; y: number }; p2: { x: number; y: number } } { + const cx = (p1.x + p2.x) / 2; + const cy = (p1.y + p2.y) / 2; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const len = Math.hypot(dx, dy); + const ux = len > 1e-10 ? dx / len : 1; + const uy = len > 1e-10 ? dy / len : 0; + const half = newLength / 2; + return { + p1: { x: cx - ux * half, y: cy - uy * half }, + p2: { x: cx + ux * half, y: cy + uy * half }, + }; +} + +/** + * Build the standard segment-length NumberControl for any element with p1/p2 + * endpoints. Returns the control node and a refresh function that syncs the + * displayed value after an external drag changes the geometry. + */ +export function buildSegmentLengthControl( + element: { p1: { x: number; y: number }; p2: { x: number; y: number } }, + label: string | ReadOnlyProperty, + triggerRebuild: () => void, +): { control: Node; refresh: () => void } { + const L_RANGE = new Range(SEGMENT_LENGTH_MIN, SEGMENT_LENGTH_MAX); + const lenProp = new NumberProperty(safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0), { + range: L_RANGE, + tandem: Tandem.OPT_OUT, + }); + let lenDriving = false; + lenProp.lazyLink((v) => { + lenDriving = true; + const resized = resizeSegment(element.p1, element.p2, v); + element.p1 = resized.p1; + element.p2 = resized.p2; + triggerRebuild(); + lenDriving = false; + }); + const control = new NumberControl(label, lenProp, L_RANGE, numberControlOptions(0.05, 2)); + const refresh = (): void => { + if (lenDriving) { + return; + } + lenProp.value = safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0); + }; + return { control, refresh }; +} diff --git a/src/common/view/edit-controls/EditControlsResult.ts b/src/common/view/edit-controls/EditControlsResult.ts new file mode 100644 index 0000000..22aebd8 --- /dev/null +++ b/src/common/view/edit-controls/EditControlsResult.ts @@ -0,0 +1,13 @@ +/** + * EditControlsResult.ts + * + * Shared result type returned by all per-element edit-control builders. + */ + +import type { Node } from "scenerystack/scenery"; + +export type EditControlsResult = { + controls: Node[]; + /** Called by EditContainerNode.refresh() to sync controls after a geometry drag. */ + refreshCallback: (() => void) | null; +}; diff --git a/src/common/view/edit-controls/GlassEditControls.ts b/src/common/view/edit-controls/GlassEditControls.ts new file mode 100644 index 0000000..63466aa --- /dev/null +++ b/src/common/view/edit-controls/GlassEditControls.ts @@ -0,0 +1,402 @@ +/** + * GlassEditControls.ts + * + * Edit-panel control builders for glass and lens element types: + * SphericalLens, IdealLens, BaseGlass (generic refractive-index control), + * and the typed fixed-shape prisms (EquilateralPrism, RightAnglePrism, + * PorroPrism, SlabGlass, ParallelogramPrism, DovePrism). + */ + +import { NumberProperty } from "scenerystack/axon"; +import { Range } from "scenerystack/dot"; +import { NumberControl } from "scenerystack/scenery-phet"; +import { Tandem } from "scenerystack/tandem"; +import { StringManager } from "../../../i18n/StringManager.js"; +import { + FOCAL_LENGTH_MAX_M, + FOCAL_LENGTH_MIN_M, + REFRACTIVE_INDEX_MAX, + REFRACTIVE_INDEX_MIN, + SEGMENT_LENGTH_MAX, + SEGMENT_LENGTH_MIN, + SPHERICAL_R1_FALLBACK, + SPHERICAL_R2_FALLBACK, + SPHERICAL_RADIUS_MAX, + SPHERICAL_RADIUS_MIN, +} from "../../../OpticsLabConstants.js"; +import type { SignConvention } from "../../../preferences/OpticsLabPreferencesModel.js"; +import type { BaseGlass } from "../../model/glass/BaseGlass.js"; +import type { DovePrism } from "../../model/glass/DovePrism.js"; +import type { EquilateralPrism } from "../../model/glass/EquilateralPrism.js"; +import type { IdealLens } from "../../model/glass/IdealLens.js"; +import type { ParallelogramPrism } from "../../model/glass/ParallelogramPrism.js"; +import type { PorroPrism } from "../../model/glass/PorroPrism.js"; +import type { RightAnglePrism } from "../../model/glass/RightAnglePrism.js"; +import type { SlabGlass } from "../../model/glass/SlabGlass.js"; +import type { SphericalLens } from "../../model/glass/SphericalLens.js"; +import { + buildSegmentLengthControl, + makeControl, + numberControlOptions, + resizeSegment, + safeClamp, + segmentLength, +} from "./EditControlHelpers.js"; +import type { EditControlsResult } from "./EditControlsResult.js"; + +export function buildSphericalLensControls( + element: SphericalLens, + triggerRebuild: () => void, + signConvention: SignConvention, +): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + const { r1, r2 } = element.getDR1R2(); + const R_RANGE = new Range(SPHERICAL_RADIUS_MIN, SPHERICAL_RADIUS_MAX); + const L_RANGE = new Range(SEGMENT_LENGTH_MIN, SEGMENT_LENGTH_MAX); + + // In Real-is-Positive mode, R₂ is negated for display: a biconvex lens + // has R₁ > 0, R₂ > 0 (instead of R₁ > 0, R₂ < 0 in New Cartesian). + const isRIP = signConvention === "realIsPositive"; + const r2Display = isRIP ? -r2 : r2; + + const r1Prop = new NumberProperty(safeClamp(r1, R_RANGE.min, R_RANGE.max, SPHERICAL_R1_FALLBACK), { + range: R_RANGE, + tandem: Tandem.OPT_OUT, + }); + const r2Prop = new NumberProperty(safeClamp(r2Display, R_RANGE.min, R_RANGE.max, SPHERICAL_R2_FALLBACK), { + range: R_RANGE, + tandem: Tandem.OPT_OUT, + }); + const lenProp = new NumberProperty(safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0), { + range: L_RANGE, + tandem: Tandem.OPT_OUT, + }); + + // sliderDriving: slider is changing the model → suppress refreshCallback. + // viewDriving: view is updating the slider display → suppress lazyLinks. + let sliderDriving = false; + let viewDriving = false; + r1Prop.lazyLink((v) => { + if (viewDriving) { + return; + } + sliderDriving = true; + // Move only the left arc apex — same as the blue curvature drag handle. + element.applyRadiusKeepingCorners("r1", v); + triggerRebuild(); + sliderDriving = false; + }); + r2Prop.lazyLink((v) => { + if (viewDriving) { + return; + } + sliderDriving = true; + // In RIP mode the slider holds −R₂_model; invert before storing. + const modelR2 = isRIP ? -v : v; + // Move only the right arc apex — same as the blue curvature drag handle. + element.applyRadiusKeepingCorners("r2", modelR2); + triggerRebuild(); + sliderDriving = false; + }); + lenProp.lazyLink((v) => { + if (viewDriving) { + return; + } + sliderDriving = true; + const resized = resizeSegment(element.p1, element.p2, v); + element.p1 = resized.p1; + element.p2 = resized.p2; + const { d, r1: cr1, r2: cr2 } = element.getDR1R2(); + element.createLensWithDR1R2(d, cr1, cr2); + triggerRebuild(); + sliderDriving = false; + }); + + const refreshCallback = (): void => { + if (sliderDriving) { + return; + } + viewDriving = true; + const { r1: newR1, r2: newR2 } = element.getDR1R2(); + r1Prop.value = safeClamp(newR1, R_RANGE.min, R_RANGE.max, SPHERICAL_R1_FALLBACK); + r2Prop.value = safeClamp(isRIP ? -newR2 : newR2, R_RANGE.min, R_RANGE.max, SPHERICAL_R2_FALLBACK); + lenProp.value = safeClamp(segmentLength(element.p1, element.p2), L_RANGE.min, L_RANGE.max, 1.0); + viewDriving = false; + }; + + const r2Label = isRIP ? ctrl.r2RightRIPStringProperty : ctrl.r2RightSurfaceStringProperty; + + return { + controls: [ + new NumberControl(ctrl.r1LeftSurfaceStringProperty, r1Prop, R_RANGE, numberControlOptions(0.1, 1)), + new NumberControl(r2Label, r2Prop, R_RANGE, numberControlOptions(0.1, 1)), + new NumberControl(ctrl.lengthStringProperty, lenProp, L_RANGE, numberControlOptions(0.05, 2)), + makeControl( + ctrl.refractiveIndexStringProperty, + element.refIndex, + new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), + 0.05, + (v) => { + element.refIndex = v; + }, + triggerRebuild, + ), + ], + refreshCallback, + }; +} + +export function buildIdealLensControls(element: IdealLens, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + const { control: lenControl, refresh } = buildSegmentLengthControl( + element, + ctrl.lengthStringProperty, + triggerRebuild, + ); + return { + controls: [ + makeControl( + ctrl.focalLengthStringProperty, + element.focalLength, + new Range(FOCAL_LENGTH_MIN_M, FOCAL_LENGTH_MAX_M), + 0.1, + (v) => { + element.focalLength = v; + }, + triggerRebuild, + ), + lenControl, + ], + refreshCallback: refresh, + }; +} + +/** Generic refractive-index-only control for plain Glass / CircleGlass / HalfPlaneGlass. */ +export function buildRefractiveIndexControls(element: BaseGlass, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + return { + controls: [ + makeControl( + ctrl.refractiveIndexStringProperty, + element.refIndex, + new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), + 0.05, + (v) => { + element.refIndex = v; + }, + triggerRebuild, + ), + ], + refreshCallback: null, + }; +} + +export function buildEquilateralPrismControls( + element: EquilateralPrism, + triggerRebuild: () => void, +): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + return { + controls: [ + makeControl( + ctrl.sizeStringProperty, + element.size, + new Range(0.1, 2.0), + 0.05, + (v) => { + element.setSize(v); + }, + triggerRebuild, + ), + makeControl( + ctrl.refractiveIndexStringProperty, + element.refIndex, + new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), + 0.05, + (v) => { + element.refIndex = v; + }, + triggerRebuild, + ), + ], + refreshCallback: null, + }; +} + +export function buildRightAnglePrismControls(element: RightAnglePrism, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + return { + controls: [ + makeControl( + ctrl.legLengthStringProperty, + element.legLength, + new Range(0.1, 2.0), + 0.05, + (v) => { + element.setLegLength(v); + }, + triggerRebuild, + ), + makeControl( + ctrl.refractiveIndexStringProperty, + element.refIndex, + new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), + 0.05, + (v) => { + element.refIndex = v; + }, + triggerRebuild, + ), + ], + refreshCallback: null, + }; +} + +export function buildPorroPrismControls(element: PorroPrism, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + return { + controls: [ + makeControl( + ctrl.legLengthStringProperty, + element.legLength, + new Range(0.1, 2.0), + 0.05, + (v) => { + element.setLegLength(v); + }, + triggerRebuild, + ), + makeControl( + ctrl.refractiveIndexStringProperty, + element.refIndex, + new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), + 0.05, + (v) => { + element.refIndex = v; + }, + triggerRebuild, + ), + ], + refreshCallback: null, + }; +} + +export function buildSlabGlassControls(element: SlabGlass, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + return { + controls: [ + makeControl( + ctrl.widthStringProperty, + element.width, + new Range(0.1, 3.0), + 0.05, + (v) => { + element.setWidth(v); + }, + triggerRebuild, + ), + makeControl( + ctrl.heightStringProperty, + element.height, + new Range(0.1, 2.0), + 0.05, + (v) => { + element.setHeight(v); + }, + triggerRebuild, + ), + makeControl( + ctrl.refractiveIndexStringProperty, + element.refIndex, + new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), + 0.05, + (v) => { + element.refIndex = v; + }, + triggerRebuild, + ), + ], + refreshCallback: null, + }; +} + +export function buildParallelogramPrismControls( + element: ParallelogramPrism, + triggerRebuild: () => void, +): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + return { + controls: [ + makeControl( + ctrl.widthStringProperty, + element.width, + new Range(0.1, 3.0), + 0.05, + (v) => { + element.setWidth(v); + }, + triggerRebuild, + ), + makeControl( + ctrl.heightStringProperty, + element.height, + new Range(0.1, 2.0), + 0.05, + (v) => { + element.setHeight(v); + }, + triggerRebuild, + ), + makeControl( + ctrl.refractiveIndexStringProperty, + element.refIndex, + new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), + 0.05, + (v) => { + element.refIndex = v; + }, + triggerRebuild, + ), + ], + refreshCallback: null, + }; +} + +export function buildDovePrismControls(element: DovePrism, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + return { + controls: [ + makeControl( + ctrl.widthStringProperty, + element.width, + new Range(0.1, 3.0), + 0.05, + (v) => { + element.setWidth(v); + }, + triggerRebuild, + ), + makeControl( + ctrl.heightStringProperty, + element.height, + new Range(0.1, 2.0), + 0.05, + (v) => { + element.setHeight(v); + }, + triggerRebuild, + ), + makeControl( + ctrl.refractiveIndexStringProperty, + element.refIndex, + new Range(REFRACTIVE_INDEX_MIN, REFRACTIVE_INDEX_MAX), + 0.05, + (v) => { + element.refIndex = v; + }, + triggerRebuild, + ), + ], + refreshCallback: null, + }; +} diff --git a/src/common/view/edit-controls/LightSourceEditControls.ts b/src/common/view/edit-controls/LightSourceEditControls.ts new file mode 100644 index 0000000..7000cd6 --- /dev/null +++ b/src/common/view/edit-controls/LightSourceEditControls.ts @@ -0,0 +1,159 @@ +/** + * LightSourceEditControls.ts + * + * Edit-panel control builders for light-source element types: + * ArcLightSource, PointSourceElement, BeamSource, SingleRaySource. + */ + +import { Range } from "scenerystack/dot"; +import { StringManager } from "../../../i18n/StringManager.js"; +import { + BRIGHTNESS_MAX, + BRIGHTNESS_MIN, + DIVERGENCE_MAX_DEG, + DIVERGENCE_MIN_DEG, + EMISSION_ANGLE_MAX_DEG, + EMISSION_ANGLE_MIN_DEG, + WAVELENGTH_MAX_NM, + WAVELENGTH_MIN_NM, +} from "../../../OpticsLabConstants.js"; +import type { ArcLightSource } from "../../model/light-sources/ArcLightSource.js"; +import type { BeamSource } from "../../model/light-sources/BeamSource.js"; +import type { PointSourceElement } from "../../model/light-sources/PointSourceElement.js"; +import type { SingleRaySource } from "../../model/light-sources/SingleRaySource.js"; +import { buildSegmentLengthControl, makeControl, makeWavelengthControl } from "./EditControlHelpers.js"; +import type { EditControlsResult } from "./EditControlsResult.js"; + +export function buildArcLightSourceControls(element: ArcLightSource, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + return { + controls: [ + makeControl( + ctrl.brightnessStringProperty, + element.brightness, + new Range(BRIGHTNESS_MIN, BRIGHTNESS_MAX), + 0.05, + (v) => { + element.brightness = v; + }, + triggerRebuild, + ), + makeControl( + ctrl.emissionAngleStringProperty, + element.emissionAngle * (180 / Math.PI), + new Range(EMISSION_ANGLE_MIN_DEG, EMISSION_ANGLE_MAX_DEG), + 1, + (v) => { + element.emissionAngle = v * (Math.PI / 180); + }, + triggerRebuild, + ), + makeWavelengthControl( + element.wavelength, + new Range(WAVELENGTH_MIN_NM, WAVELENGTH_MAX_NM), + (v) => { + element.wavelength = v; + }, + triggerRebuild, + ), + ], + refreshCallback: null, + }; +} + +export function buildPointSourceControls(element: PointSourceElement, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + return { + controls: [ + makeControl( + ctrl.brightnessStringProperty, + element.brightness, + new Range(BRIGHTNESS_MIN, BRIGHTNESS_MAX), + 0.05, + (v) => { + element.brightness = v; + }, + triggerRebuild, + ), + makeWavelengthControl( + element.wavelength, + new Range(WAVELENGTH_MIN_NM, WAVELENGTH_MAX_NM), + (v) => { + element.wavelength = v; + }, + triggerRebuild, + ), + ], + refreshCallback: null, + }; +} + +export function buildBeamSourceControls(element: BeamSource, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + const { control: heightControl, refresh } = buildSegmentLengthControl( + element, + ctrl.heightStringProperty, + triggerRebuild, + ); + return { + controls: [ + makeControl( + ctrl.brightnessStringProperty, + element.brightness, + new Range(BRIGHTNESS_MIN, BRIGHTNESS_MAX), + 0.05, + (v) => { + element.brightness = v; + }, + triggerRebuild, + ), + makeControl( + ctrl.divergenceStringProperty, + element.emisAngle, + new Range(DIVERGENCE_MIN_DEG, DIVERGENCE_MAX_DEG), + 1, + (v) => { + element.emisAngle = v; + }, + triggerRebuild, + ), + heightControl, + makeWavelengthControl( + element.wavelength, + new Range(WAVELENGTH_MIN_NM, WAVELENGTH_MAX_NM), + (v) => { + element.wavelength = v; + }, + triggerRebuild, + ), + ], + refreshCallback: refresh, + }; +} + +export function buildSingleRaySourceControls(element: SingleRaySource, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + return { + controls: [ + makeControl( + ctrl.brightnessStringProperty, + element.brightness, + new Range(BRIGHTNESS_MIN, BRIGHTNESS_MAX), + 0.05, + (v) => { + element.brightness = v; + }, + triggerRebuild, + ), + makeWavelengthControl( + element.wavelength, + new Range(WAVELENGTH_MIN_NM, WAVELENGTH_MAX_NM), + (v) => { + element.wavelength = v; + }, + triggerRebuild, + ), + ], + refreshCallback: null, + }; +} diff --git a/src/common/view/edit-controls/MirrorEditControls.ts b/src/common/view/edit-controls/MirrorEditControls.ts new file mode 100644 index 0000000..c85073c --- /dev/null +++ b/src/common/view/edit-controls/MirrorEditControls.ts @@ -0,0 +1,177 @@ +/** + * MirrorEditControls.ts + * + * Edit-panel control builders for mirrors, gratings, detectors, segment + * elements (SegmentMirror / LineBlocker / TrackElement), and BeamSplitter. + */ + +import { NumberProperty } from "scenerystack/axon"; +import { Range } from "scenerystack/dot"; +import { NumberControl } from "scenerystack/scenery-phet"; +import { Tandem } from "scenerystack/tandem"; +import { StringManager } from "../../../i18n/StringManager.js"; +import { + ARC_MIRROR_RADIUS_MAX, + ARC_MIRROR_RADIUS_MIN, + FOCAL_LENGTH_MAX_M, + FOCAL_LENGTH_MIN_M, + LINES_DENSITY_CONTROL_DELTA, +} from "../../../OpticsLabConstants.js"; +import type { LineBlocker } from "../../model/blockers/LineBlocker.js"; +import type { DetectorElement } from "../../model/detectors/DetectorElement.js"; +import type { ReflectionGrating } from "../../model/gratings/ReflectionGrating.js"; +import type { TransmissionGrating } from "../../model/gratings/TransmissionGrating.js"; +import type { TrackElement } from "../../model/guides/TrackElement.js"; +import type { ArcMirror } from "../../model/mirrors/ArcMirror.js"; +import type { BeamSplitterElement } from "../../model/mirrors/BeamSplitterElement.js"; +import type { IdealCurvedMirror } from "../../model/mirrors/IdealCurvedMirror.js"; +import type { SegmentMirror } from "../../model/mirrors/SegmentMirror.js"; +import { buildSegmentLengthControl, makeControl, numberControlOptions, safeClamp } from "./EditControlHelpers.js"; +import type { EditControlsResult } from "./EditControlsResult.js"; + +export function buildArcMirrorControls(element: ArcMirror, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + const R_RANGE = new Range(ARC_MIRROR_RADIUS_MIN, ARC_MIRROR_RADIUS_MAX); + const currentRadius = safeClamp( + element.getRadius() ?? ARC_MIRROR_RADIUS_MAX, + R_RANGE.min, + R_RANGE.max, + ARC_MIRROR_RADIUS_MAX, + ); + const radiusProp = new NumberProperty(currentRadius, { range: R_RANGE, tandem: Tandem.OPT_OUT }); + let sliderDriving = false; + radiusProp.lazyLink((v) => { + sliderDriving = true; + element.setRadius(v); + triggerRebuild(); + sliderDriving = false; + }); + const refreshCallback = (): void => { + if (sliderDriving) { + return; + } + radiusProp.value = safeClamp( + element.getRadius() ?? ARC_MIRROR_RADIUS_MAX, + R_RANGE.min, + R_RANGE.max, + ARC_MIRROR_RADIUS_MAX, + ); + }; + return { + controls: [ + new NumberControl(ctrl.radiusOfCurvatureStringProperty, radiusProp, R_RANGE, numberControlOptions(0.1, 1)), + ], + refreshCallback, + }; +} + +export function buildIdealCurvedMirrorControls( + element: IdealCurvedMirror, + triggerRebuild: () => void, +): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + const { control: lenControl, refresh } = buildSegmentLengthControl( + element, + ctrl.lengthStringProperty, + triggerRebuild, + ); + return { + controls: [ + makeControl( + ctrl.focalLengthStringProperty, + element.focalLength, + new Range(FOCAL_LENGTH_MIN_M, FOCAL_LENGTH_MAX_M), + 0.1, + (v) => { + element.focalLength = v; + }, + triggerRebuild, + ), + lenControl, + ], + refreshCallback: refresh, + }; +} + +export function buildDetectorControls(element: DetectorElement, triggerRebuild: () => void): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + const { control: lenControl, refresh } = buildSegmentLengthControl( + element, + ctrl.lengthStringProperty, + triggerRebuild, + ); + return { controls: [lenControl], refreshCallback: refresh }; +} + +export function buildSegmentControls( + element: SegmentMirror | LineBlocker | TrackElement, + triggerRebuild: () => void, +): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + const { control: lenControl, refresh } = buildSegmentLengthControl( + element, + ctrl.lengthStringProperty, + triggerRebuild, + ); + return { controls: [lenControl], refreshCallback: refresh }; +} + +export function buildGratingControls( + element: TransmissionGrating | ReflectionGrating, + triggerRebuild: () => void, +): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + const { control: lenControl, refresh } = buildSegmentLengthControl( + element, + ctrl.lengthStringProperty, + triggerRebuild, + ); + return { + controls: [ + makeControl( + ctrl.linesDensityStringProperty, + element.linesDensity, + new Range(1, 2500), + LINES_DENSITY_CONTROL_DELTA, + (v) => { + element.linesDensity = v; + }, + triggerRebuild, + ), + makeControl( + ctrl.dutyCycleStringProperty, + element.dutyCycle, + new Range(0.01, 0.99), + 0.01, + (v) => { + element.dutyCycle = v; + }, + triggerRebuild, + ), + lenControl, + ], + refreshCallback: refresh, + }; +} + +export function buildBeamSplitterControls( + element: BeamSplitterElement, + triggerRebuild: () => void, +): EditControlsResult { + const ctrl = StringManager.getInstance().getControlStrings(); + return { + controls: [ + makeControl( + ctrl.transmissionRatioStringProperty, + element.transRatio, + new Range(0, 1), + 0.05, + (v) => { + element.transRatio = v; + }, + triggerRebuild, + ), + ], + refreshCallback: null, + }; +}