/*
* Copyright 2024 The Ray Optics Simulation authors and contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import BaseSceneObj from '../BaseSceneObj.js';
import LineObjMixin from '../LineObjMixin.js';
import Simulator from '../../Simulator.js';
import geometry from '../../geometry.js';
import i18next from 'i18next';
import { parseFormula } from '../../formula/formula-parser.js';
const BEAM_POINT_SOURCE_TYPE = {
name: 'Beam point',
paramNames: [
'x_0', 'y_0', 'normal', 'delta_angle', 'P', 'lambert', 'lambda_0'
],
dag: parseFormula(
`
pair = floor((i + 1) / 2);
parity = i - 2 * floor(i / 2);
angle = pair * delta_angle * (2 * parity - 1);
x = x_0;
y = y_0;
d_x = sin(normal + angle);
d_y = cos(normal + angle);
P_s = P * (1 - lambert + lambert * cos(angle));
P_p = P * (1 - lambert + lambert * cos(angle));
lambda = lambda_0;
`,
[
'i', 'x_0', 'y_0', 'normal', 'delta_angle', 'P', 'lambert',
'lambda_0'
]
)
};
// A parallel beam is one source instance whose invocation index selects the
// sampled position. Divergent beams deliberately use BEAM_POINT_SOURCE_TYPE
// once per position so all angular samples from one point remain adjacent.
const PARALLEL_BEAM_SOURCE_TYPE = {
name: 'Parallel beam',
paramNames: [
'x_0', 'y_0', 'step_x', 'step_y', 'd_x_0', 'd_y_0', 'P', 'lambda_0'
],
dag: parseFormula(
`
x = x_0 + i * step_x;
y = y_0 + i * step_y;
d_x = d_x_0;
d_y = d_y_0;
P_s = P;
P_p = P;
lambda = lambda_0;
`,
[
'i', 'x_0', 'y_0', 'step_x', 'step_y', 'd_x_0', 'd_y_0', 'P',
'lambda_0'
]
)
};
const RANDOM_BEAM_RAY_SOURCE_TYPE = {
name: 'Random beam ray',
paramNames: ['x_0', 'y_0', 'd_x_0', 'd_y_0', 'P_s_0', 'P_p_0', 'lambda_0'],
dag: parseFormula(
`
x = x_0;
y = y_0;
d_x = d_x_0;
d_y = d_y_0;
P_s = P_s_0;
P_p = P_p_0;
lambda = lambda_0;
`,
['x_0', 'y_0', 'd_x_0', 'd_y_0', 'P_s_0', 'P_p_0', 'lambda_0']
)
};
/**
* A parallel or divergent beam of light.
*
* Tools -> Light source -> beam
* @class
* @extends BaseSceneObj
* @memberof sceneObjs
* @property {Point} p1 - The first endpoint of the segment perpendicular to the beam.
* @property {Point} p2 - The second endpoint of the segment perpendicular to the beam.
* @property {number} brightness - The brightness of the beam.
* @property {number} wavelength - The wavelength of the beam in nm. Only effective when "Simulate Colors" is on.
* @property {number} emisAngle - The angle of divergence in degrees.
* @property {boolean} lambert - Whether the beam is Lambertian.
* @property {boolean} random - Whether the beam is random.
* @property {Array<number>} randomNumbers - Random numbers used for random beam.
*/
class Beam extends LineObjMixin(BaseSceneObj) {
static type = 'Beam';
static isOptical = true;
static serializableDefaults = {
p1: null,
p2: null,
brightness: 0.5,
wavelength: Simulator.GREEN_WAVELENGTH,
emisAngle: 0.0,
lambert: false,
random: false
};
static getDescription(objData, scene, detailed = false) {
return i18next.t('main:tools.Beam.title');
}
static getPropertySchema(objData, scene) {
return [
...super.getPropertySchema(objData, scene),
{ key: 'brightness', type: 'number', label: i18next.t('simulator:sceneObjs.common.brightness') },
{ key: 'wavelength', type: 'number', label: i18next.t('simulator:sceneObjs.common.wavelength') + ' (nm)' },
{ key: 'emisAngle', type: 'number', label: i18next.t('simulator:sceneObjs.common.emisAngle') + ' (\u00B0)' },
{ key: 'lambert', type: 'boolean', label: i18next.t('simulator:sceneObjs.common.lambert') },
{ key: 'random', type: 'boolean', label: i18next.t('simulator:sceneObjs.common.random') },
];
}
populateObjBar(objBar) {
if (this.scene.colorMode !== 'default') {
var brightnessInfo = i18next.t('simulator:sceneObjs.common.brightnessInfo.newColorModes');
} else {
var brightnessInfo = '<p>' + i18next.t('simulator:sceneObjs.common.brightnessInfo.rayDensity') + '</p><p>' + i18next.t('simulator:sceneObjs.common.brightnessInfo.rayDensitySlider') + '</p>';
}
objBar.setTitle(i18next.t('main:tools.Beam.title'));
objBar.createNumber(i18next.t('simulator:sceneObjs.common.brightness'), 0.01 / this.scene.lengthScale, 1 / this.scene.lengthScale, 0.01 / this.scene.lengthScale, this.brightness, function (obj, value) {
obj.brightness = value;
}, brightnessInfo);
if (this.scene.simulateColors) {
objBar.createNumber(i18next.t('simulator:sceneObjs.common.wavelength') + ' (nm)', Simulator.UV_WAVELENGTH, Simulator.INFRARED_WAVELENGTH, 1, this.wavelength, function (obj, value) {
obj.wavelength = value;
});
}
if (objBar.showAdvanced(!this.arePropertiesDefault(['emisAngle', 'lambert', 'random']))) {
objBar.createNumber(i18next.t('simulator:sceneObjs.common.emisAngle') + ' (°)', 0, 180, 1, this.emisAngle, function (obj, value) {
obj.emisAngle = value;
});
objBar.createBoolean(i18next.t('simulator:sceneObjs.common.lambert'), this.lambert, function (obj, value) {
obj.lambert = value;
});
objBar.createBoolean(i18next.t('simulator:sceneObjs.common.random'), this.random, function (obj, value) {
obj.random = value;
});
}
}
onConstructMouseDown(mouse, ctrl, shift) {
super.onConstructMouseDown(mouse, ctrl, shift);
if (this.scene.colorMode !== 'default') {
// In the new color modes, the default brightness for newly created sources is set to 0.1 instead.
this.brightness = 0.1;
}
}
draw(canvasRenderer, isAboveLight, isHovered) {
const ctx = canvasRenderer.ctx;
const ls = canvasRenderer.lengthScale;
if (this.p1.x == this.p2.x && this.p1.y == this.p2.y) {
ctx.fillStyle = 'rgb(128,128,128)';
ctx.fillRect(this.p1.x - 1.5 * ls, this.p1.y - 1.5 * ls, 3 * ls, 3 * ls);
return;
}
var a_l = Math.atan2(this.p1.x - this.p2.x, this.p1.y - this.p2.y) - Math.PI / 2;
const colorArray = this.scene.simulator.wavelengthToColor(this.wavelength, 1);
ctx.strokeStyle = isHovered ? this.scene.highlightColorCss : canvasRenderer.rgbaToCssColor(this.scene.simulateColors ? colorArray : this.scene.theme.lightSource.color);
ctx.lineWidth = this.scene.theme.lightSource.size * 4 / 5 * ls;
ctx.lineCap = 'butt';
ctx.beginPath();
ctx.moveTo(this.p1.x + Math.sin(a_l) * this.scene.theme.lightSource.size * 2 / 5 * ls, this.p1.y + Math.cos(a_l) * this.scene.theme.lightSource.size * 2 / 5 * ls);
ctx.lineTo(this.p2.x + Math.sin(a_l) * this.scene.theme.lightSource.size * 2 / 5 * ls, this.p2.y + Math.cos(a_l) * this.scene.theme.lightSource.size * 2 / 5 * ls);
ctx.stroke();
ctx.strokeStyle = canvasRenderer.rgbaToCssColor(this.scene.theme.beamShield.color);
ctx.lineWidth = this.scene.theme.beamShield.width * ls;
ctx.beginPath();
ctx.moveTo(this.p1.x, this.p1.y);
ctx.lineTo(this.p2.x, this.p2.y);
ctx.stroke();
ctx.lineWidth = 1 * ls;
ctx.lineCap = 'butt';
}
scale(scale, center) {
super.scale(scale, center);
this.brightness /= scale;
return true;
}
getPrimitives() {
this.brightnessScale = null;
if (!this.p1 || !this.p2) return [];
const length = geometry.segmentLength(this);
if (!(length > 0) || !Number.isFinite(length)) return [];
let rayDensity = this.scene.rayDensity;
let positionCount;
let randomRayCount;
let angleStep;
let angularCount;
let rayPower;
let expectedBrightness;
do {
const n = length * rayDensity / this.scene.lengthScale;
positionCount = Math.max(0, Math.floor(n + 0.5));
const angularSamples = parseInt(rayDensity * 500);
if (!(angularSamples > 0)) return [];
angleStep = Math.PI * 2 / angularSamples;
const halfAngle = this.emisAngle / 180 * Math.PI * 0.5;
angularCount = 1;
for (let angle = angleStep; angle < halfAngle; angle += angleStep) {
angularCount += 2;
}
const brightnessFactor =
1 / (1 + Math.floor(halfAngle / angleStep) * 2);
expectedBrightness =
this.brightness * this.scene.lengthScale / rayDensity *
brightnessFactor;
rayPower = Math.min(expectedBrightness, 1) * 0.5;
randomRayCount = Math.max(
0,
Math.ceil(n * (1 + Math.floor(halfAngle / angleStep) * 2))
);
if (this.scene.colorMode !== 'default' && expectedBrightness > 1) {
rayDensity += 1 / 500;
} else {
break;
}
} while (true);
const dx = this.p2.x - this.p1.x;
const dy = this.p2.y - this.p1.y;
const normal = Math.atan2(dx, dy) + Math.PI / 2;
const wavelength = this.scene.simulateColors
? this.wavelength
: Simulator.GREEN_WAVELENGTH;
if (this.scene.colorMode === 'default') {
this.brightnessScale =
Math.min(expectedBrightness, 1) / expectedBrightness;
}
if (this.random) {
this.initRandom();
const halfAngle = this.emisAngle / 180 * Math.PI * 0.5;
const primitives = [];
for (let i = 0; i < randomRayCount; i++) {
const position = this.getRandom(i * 2);
const angle = (this.getRandom(i * 2 + 1) * 2 - 1) * halfAngle;
const power = rayPower * (this.lambert ? Math.cos(angle) : 1);
primitives.push({
kind: 'source',
sourceType: RANDOM_BEAM_RAY_SOURCE_TYPE,
params: {
x_0: this.p1.x + position * dx,
y_0: this.p1.y + position * dy,
d_x_0: Math.sin(normal + angle),
d_y_0: Math.cos(normal + angle),
P_s_0: power,
P_p_0: power,
lambda_0: wavelength
},
rayCount: 1
});
}
return primitives;
}
// Keep the two-dimensional position/angle sampling split into one source
// per position. This keeps every source-local invocation index small and
// exactly representable by f32 engines.
const stepX = dx / (length * rayDensity / this.scene.lengthScale);
const stepY = dy / (length * rayDensity / this.scene.lengthScale);
if (angularCount === 1) {
return [{
kind: 'source',
sourceType: PARALLEL_BEAM_SOURCE_TYPE,
params: {
x_0: this.p1.x + 0.5 * stepX,
y_0: this.p1.y + 0.5 * stepY,
step_x: stepX,
step_y: stepY,
d_x_0: Math.sin(normal),
d_y_0: Math.cos(normal),
P: rayPower,
lambda_0: wavelength
},
rayCount: positionCount
}];
}
const primitives = [];
for (let positionIndex = 0; positionIndex < positionCount; positionIndex++) {
const position = positionIndex + 0.5;
primitives.push({
kind: 'source',
sourceType: BEAM_POINT_SOURCE_TYPE,
params: {
x_0: this.p1.x + position * stepX,
y_0: this.p1.y + position * stepY,
normal,
delta_angle: angleStep,
P: rayPower,
lambert: this.lambert ? 1 : 0,
lambda_0: wavelength
},
rayCount: angularCount
});
}
return primitives;
}
onSimulationStart() {
if ((this.scene.mode == 'images' || this.scene.mode == 'observer') && (this.emisAngle > 0 || this.random)) {
this.warning = i18next.t('simulator:sceneObjs.Beam.imageDetectionWarning');
} else {
this.warning = null;
}
let rayDensity = this.scene.rayDensity;
do {
var n = geometry.segmentLength(this) * rayDensity / this.scene.lengthScale;
var stepX = (this.p2.x - this.p1.x) / n;
var stepY = (this.p2.y - this.p1.y) / n;
var s = Math.PI * 2 / parseInt(rayDensity * 500);
var sizeX = (this.p2.x - this.p1.x);
var sizeY = (this.p2.y - this.p1.y);
var normal = Math.atan2(stepX, stepY) + Math.PI / 2.0;
var halfAngle = this.emisAngle / 180.0 * Math.PI * 0.5;
var numnAngledRays = 1.0 + Math.floor(halfAngle / s) * 2.0;
var rayBrightness = 1.0 / numnAngledRays;
var expectBrightness = this.brightness * this.scene.lengthScale / rayDensity * rayBrightness;
if (this.scene.colorMode !== 'default' && expectBrightness > 1) {
// In the new color modes, the brightness scale is always kept to 1 for consistent detector readings, so the ray density is overriden to keep the brightness scale to 1. Currently the strategy is to increase the number of angled rays until the brightness is less than 1. This may be improved in the future.
rayDensity += 1/500;
}
} while (this.scene.colorMode !== 'default' && expectBrightness > 1);
this.initRandom();
let newRays = [];
if (!this.random) {
for (var i = 0.5; i <= n; i++) {
var x = this.p1.x + i * stepX;
var y = this.p1.y + i * stepY;
newRays.push(this.newRay(x, y, normal, 0.0, i == 0, rayBrightness, rayDensity));
for (var angle = s; angle < halfAngle; angle += s) {
newRays.push(this.newRay(x, y, normal, angle, i == 0, rayBrightness, rayDensity));
newRays.push(this.newRay(x, y, normal, -angle, i == 0, rayBrightness, rayDensity));
}
}
} else {
for (var i = 0; i < n * numnAngledRays; i++) {
const position = this.getRandom(i * 2);
const angle = this.getRandom(i * 2 + 1);
newRays.push(this.newRay(
this.p1.x + position * sizeX,
this.p1.y + position * sizeY,
normal,
(angle * 2 - 1) * halfAngle,
i == 0,
rayBrightness, rayDensity));
}
}
return {
newRays: newRays,
brightnessScale: Math.min(this.brightness * this.scene.lengthScale / rayDensity * rayBrightness, 1) / (this.brightness * this.scene.lengthScale / rayDensity * rayBrightness)
};
}
/** Utility methods */
initRandom() {
if (this.randomNumbers == undefined || !this.random) {
this.clearRandom();
}
}
clearRandom() {
this.randomNumbers = [];
}
getRandom(i) {
for (let j = this.randomNumbers.length; j <= i; j++) {
this.randomNumbers.push(this.scene.rng());
}
return this.randomNumbers[i];
}
newRay(x, y, normal, angle, gap, brightness_factor = 1.0, rayDensity = this.scene.rayDensity) {
var ray1 = geometry.line(geometry.point(x, y), geometry.point(x + Math.sin(normal + angle), y + Math.cos(normal + angle)));
ray1.brightness_s = Math.min(this.brightness * this.scene.lengthScale / rayDensity * brightness_factor, 1) * 0.5;
ray1.brightness_p = Math.min(this.brightness * this.scene.lengthScale / rayDensity * brightness_factor, 1) * 0.5;
if (this.lambert) {
const lambert = Math.cos(angle)
ray1.brightness_s *= lambert;
ray1.brightness_p *= lambert;
}
ray1.isNew = true;
if (this.scene.simulateColors) {
ray1.wavelength = this.wavelength;
}
ray1.gap = gap;
return ray1;
}
};
export default Beam;