/*
* Copyright 2025 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 geometry from '../geometry.js';
import Simulator from '../Simulator.js';
import BaseSceneObj from './BaseSceneObj.js';
import i18next from 'i18next';
import { Bezier } from 'bezier-js';
/**
* The mixin for the scene objects whose boundary is defined by a sequence of
* cubic Bezier curves, either forming a closed loop or an open polyline.
*
* Subclasses that extend this mixin may override:
* - `static allowOpen` (default `false`): whether construction allows the user
* to finish with an open (non-closed) curve by double-clicking the last
* committed point. When `false`, the only way to finish is to click on the
* first anchor point (closed shape), matching the legacy {@link CurveGlass}
* behavior.
*
* @template {typeof BaseSceneObj} T
* @param {T} Base
* @returns {T}
*/
const CurveObjMixin = Base => class extends Base {
static allowOpen = false;
static getPropertySchema(objData, scene) {
return [
{ key: 'points', type: 'array', label: i18next.t('simulator:sceneObjs.common.bezierControlPoints'),
itemSchema: [
{ key: 'a1', type: 'point', label: 'a' },
{ key: 'c1', type: 'point', label: 'c1' },
{ key: 'c2', type: 'point', label: 'c2' },
],
},
...super.getPropertySchema(objData, scene),
];
}
/**
* @param {Scene} scene - The scene the object belongs to.
* @param {Object|null} jsonObj - The JSON object to be deserialized, if any.
*/
constructor(scene, jsonObj) {
super(scene, jsonObj);
// Initialize curves and bboxes
this.curves = [];
this.bboxes = [];
// Default `isClosed` to true (for backward compatibility with the legacy
// CurveGlass/CurveGrinGlass, which did not store this flag).
if (this.isClosed === undefined) {
this.isClosed = (jsonObj && jsonObj.isClosed !== undefined) ? jsonObj.isClosed : true;
}
// Rebuild the Bezier curves from the serialized `points` array.
if (jsonObj && jsonObj.points && jsonObj.points.length > 0) {
const pts = jsonObj.points;
const numCurves = this.isClosed ? pts.length : Math.max(0, pts.length - 1);
for (let i = 0; i < numCurves; i++) {
const next = this.isClosed ? pts[(i + 1) % pts.length] : pts[i + 1];
this.newCurve([pts[i].a1, pts[i].c1, pts[i].c2, next.a1], -1);
}
}
}
serialize() {
let jsonObj = super.serialize();
if (this.curves && this.curves.length > 0) {
const pts = this.curves.map(curve => {
const points = JSON.parse(JSON.stringify(curve)).points.slice(0, 3).map(p => geometry.point(p.x, p.y));
return { a1: points[0], c1: points[1], c2: points[2] };
});
if (!this.isClosed) {
// For an open curve append the final anchor (second endpoint of the
// last curve). It has no outgoing c1/c2 since no further curve follows.
const lastCurve = this.curves[this.curves.length - 1];
const lastEndpoint = JSON.parse(JSON.stringify(lastCurve)).points[3];
pts.push({ a1: geometry.point(lastEndpoint.x, lastEndpoint.y) });
}
jsonObj.points = pts;
} else {
jsonObj.points = [];
}
delete jsonObj.curves;
delete jsonObj.bboxes;
if (jsonObj.path) delete jsonObj.path;
return jsonObj;
}
move(diffX, diffY) {
for (let i = 0; i < this.curves.length; i++) {
for (let j = 0; j < this.curves[i].points.length; j++) {
this.curves[i].points[j].x += diffX;
this.curves[i].points[j].y += diffY;
}
this.newCurve(this.curves[i].points, i);
}
return true;
}
rotate(angle, center) {
const rotationCenter = center || this.getDefaultCenter();
const pointsInCurve = 4;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
for (let i = 0; i < this.curves.length; i++) {
for (let j = 0; j < pointsInCurve; j++) {
const dx = this.curves[i].points[j].x - rotationCenter.x;
const dy = this.curves[i].points[j].y - rotationCenter.y;
this.curves[i].points[j].x = rotationCenter.x + dx * cosA - dy * sinA;
this.curves[i].points[j].y = rotationCenter.y + dx * sinA + dy * cosA;
}
this.newCurve(this.curves[i].points, i);
}
return true;
}
scale(scale, center) {
const scalingCenter = center || this.getDefaultCenter();
const pointsInCurve = 4;
for (let i = 0; i < this.curves.length; i++) {
for (let j = 0; j < pointsInCurve; j++) {
const dx = this.curves[i].points[j].x - scalingCenter.x;
const dy = this.curves[i].points[j].y - scalingCenter.y;
this.curves[i].points[j].x = scalingCenter.x + dx * scale;
this.curves[i].points[j].y = scalingCenter.y + dy * scale;
}
this.newCurve(this.curves[i].points, i);
}
return true;
}
getDefaultCenter() {
// Use the average of the anchor points (endpoints of each curve).
const anchors = [];
for (let i = 0; i < this.curves.length; i++) {
anchors.push(this.curves[i].points[0]);
}
if (!this.isClosed && this.curves.length > 0) {
// Include the trailing endpoint in the open case.
anchors.push(this.curves[this.curves.length - 1].points[3]);
}
if (anchors.length === 0) {
return geometry.point(0, 0);
}
return geometry.point(
Math.round(anchors.reduce((s, p) => s + p.x, 0) / anchors.length),
Math.round(anchors.reduce((s, p) => s + p.y, 0) / anchors.length)
);
}
onConstructMouseDown(mouse, ctrl, shift) {
const mousePos = mouse.getPosSnappedToGrid();
if (!this.notDone) {
this.notDone = true;
this.curves = [];
this.bboxes = [];
this.path = [{ x: mousePos.x, y: mousePos.y }];
}
if (this.path.length > 0) {
// Close the shape when clicking on the first anchor point.
if (this.path.length > 3 && mouse.snapsOnPoint(this.path[0])) {
this.path.length--; // Remove the trailing preview point
this.notDone = false;
this.isClosed = true;
this.generatePolyBezier();
return { isDone: true };
}
// Finish as an open curve when double-clicking the last committed point.
if (this.constructor.allowOpen && this.path.length > 2 && mouse.snapsOnPoint(this.path[this.path.length - 2])) {
this.path.length--; // Remove the trailing preview point
this.notDone = false;
this.isClosed = false;
this.generatePolyBezier();
return { isDone: true };
}
this.path.push({ x: mousePos.x, y: mousePos.y });
}
}
onConstructMouseMove(mouse, ctrl, shift) {
if (!this.notDone) return { isDone: true };
const mousePos = mouse.getPosSnappedToGrid();
this.path[this.path.length - 1] = { x: mousePos.x, y: mousePos.y };
}
onConstructMouseUp(mouse, ctrl, shift) {
if (!this.notDone) return { isDone: true };
}
onConstructUndo() {
if (this.path.length <= 2) {
return { isCancelled: true };
} else {
if (this.curves) {
this.curves.pop();
this.path.pop();
} else {
this.path.pop();
}
}
}
checkMouseOver(mouse) {
var dragContext = {};
const mousePos = mouse.getPosSnappedToGrid();
dragContext.mousePos0 = mousePos;
dragContext.mousePos1 = mousePos;
dragContext.snapContext = {};
// Anchor points (starts of each curve).
for (let c = 0; c < this.curves.length; c++) {
if (mouse.isOnPoint(this.curves[c].points[0])) {
dragContext.part = 1;
dragContext.targetPoint = this.curves[c].points[0];
dragContext.index = c;
return dragContext;
}
// Control points of each curve.
for (let i = 1; i < this.curves[c].points.length - 1; i++) {
if (mouse.isOnPoint(this.curves[c].points[i])) {
dragContext.part = i + 1;
dragContext.targetPoint = this.curves[c].points[i];
dragContext.index = c;
return dragContext;
}
}
}
// For the open case, the trailing endpoint of the last curve has no
// associated "next curve", so check it explicitly.
if (!this.isClosed && this.curves.length > 0) {
const last = this.curves[this.curves.length - 1];
if (mouse.isOnPoint(last.points[3])) {
dragContext.part = 1;
dragContext.targetPoint = last.points[3];
// Use index equal to curves.length to denote "trailing endpoint".
dragContext.index = this.curves.length;
return dragContext;
}
}
// On the curve itself. Done outside of the previous loop to avoid
// conflicts with the anchor-point detection.
for (let c = 0; c < this.curves.length; c++) {
if (mouse.isOnCurve(this.curves[c])) {
dragContext.part = 0;
return dragContext;
}
}
}
onDrag(mouse, dragContext, ctrl, shift) {
var mousePos;
var mod = 0;
if (shift) mod++;
if (ctrl) mod += 2;
if (dragContext.part === 1) {
const isTrailingEndpoint = (dragContext.index === this.curves.length);
const curIdx = isTrailingEndpoint ? -1 : dragContext.index;
const prevIdx = isTrailingEndpoint
? this.curves.length - 1
: (this.isClosed ? (dragContext.index - 1 + this.curves.length) % this.curves.length : dragContext.index - 1);
const hasCur = curIdx >= 0;
const hasPrev = prevIdx >= 0;
switch (mod) {
default:
mousePos = mouse.getPosSnappedToGrid();
dragContext.snapContext = {};
break;
case 1:
mousePos = mouse.getPosSnappedToDirection(dragContext.mousePos0, [{ x: 1, y: 0 }, { x: 0, y: 1 }], dragContext.snapContext);
break;
case 2: {
mousePos = mouse.getPosSnappedToGrid();
dragContext.snapContext = {};
// Drag the adjacent control points along with the anchor point.
const anchor = hasCur ? this.curves[curIdx].points[0] : this.curves[prevIdx].points[3];
const diffX = mousePos.x - anchor.x;
const diffY = mousePos.y - anchor.y;
if (hasCur) {
this.curves[curIdx].points[1].x += diffX;
this.curves[curIdx].points[1].y += diffY;
}
if (hasPrev) {
this.curves[prevIdx].points[2].x += diffX;
this.curves[prevIdx].points[2].y += diffY;
}
if (hasCur) this.newCurve(this.curves[curIdx].points, curIdx);
if (hasPrev) this.newCurve(this.curves[prevIdx].points, prevIdx);
break;
}
}
// Move the shared anchor point on both adjacent curves.
if (hasCur) {
this.curves[curIdx].points[0].x = mousePos.x;
this.curves[curIdx].points[0].y = mousePos.y;
this.newCurve(this.curves[curIdx].points, curIdx);
}
if (hasPrev) {
this.curves[prevIdx].points[3].x = mousePos.x;
this.curves[prevIdx].points[3].y = mousePos.y;
this.newCurve(this.curves[prevIdx].points, prevIdx);
}
}
else if (dragContext.part === 2 || dragContext.part === 3) {
if (shift) {
mousePos = mouse.getPosSnappedToDirection(dragContext.mousePos0, [{ x: 1, y: 0 }, { x: 0, y: 1 }], dragContext.snapContext);
} else {
mousePos = mouse.getPosSnappedToGrid();
dragContext.snapContext = {};
}
this.curves[dragContext.index].points[dragContext.part - 1].x = mousePos.x;
this.curves[dragContext.index].points[dragContext.part - 1].y = mousePos.y;
this.newCurve(this.curves[dragContext.index].points, dragContext.index);
}
else if (dragContext.part === 0) {
if (shift) {
mousePos = mouse.getPosSnappedToDirection(dragContext.mousePos0, [{ x: 1, y: 0 }, { x: 0, y: 1 }], dragContext.snapContext);
} else {
mousePos = mouse.getPosSnappedToGrid();
dragContext.snapContext = {};
}
this.move(mousePos.x - dragContext.mousePos1.x, mousePos.y - dragContext.mousePos1.y);
dragContext.mousePos1 = mousePos;
}
}
/**
* Counts intersections between the line segment (or ray) from `p3` to `p4`
* and the boundary curves. When `p4` is omitted, this is used as a
* crossing-number test (for closed shapes) and returns the number of
* intersections from `p3` with a pseudo-ray going outside the bounding box.
* When `p4` is provided, it also records per-curve intersections and the
* nearest one in `this.curIntersections`.
*/
countIntersections(p3, p4) {
var cnt = 0;
if (typeof p4 === "undefined") {
let minX = Infinity;
for (let i = 0; i < this.bboxes.length; i++) {
if (this.bboxes[i].x.min < minX) {
minX = this.bboxes[i].x.min;
}
}
const offset = (this.scene && typeof this.scene.rng === 'function') ? this.scene.rng() : 1.23456789;
const outsidePoint = { x: minX - offset - 100, y: p3.y + offset };
for (let i = 0; i < this.curves.length; i++) {
cnt += this.curves[i].lineIntersects(geometry.line(geometry.point(p3.x, p3.y), outsidePoint)).length;
}
} else {
this.curIntersections = {
curves: [],
shortest: { val: Infinity, i: -1, j: -1 }
};
let mod_len = 0;
for (let i = 0; i < this.bboxes.length; i++) {
mod_len += Math.abs(this.bboxes[i].x.max) > Math.abs(this.bboxes[i].y.max) ? Math.abs(this.bboxes[i].x.max) : Math.abs(this.bboxes[i].y.max);
}
for (let i = 0; i < this.curves.length; i++) {
this.curIntersections.curves.push(
this.curves[i].lineIntersects(geometry.line(p3, geometry.point(p4.x + ((p4.x - p3.x) * mod_len), p4.y + ((p4.y - p3.y) * mod_len))))
);
for (let j = 0; j < this.curIntersections.curves[i].length; j++) {
if (geometry.distanceSquared(p3, this.curves[i].get(this.curIntersections.curves[i][j])) < Simulator.MIN_RAY_SEGMENT_LENGTH_SQUARED * this.scene.lengthScale * this.scene.lengthScale) {
this.curIntersections.curves[i].splice(j, 1);
}
}
cnt += this.curIntersections.curves[i].length;
for (let j = 0; j < this.curIntersections.curves[i].length; j++) {
let tmp = geometry.distanceSquared(p3, this.curves[i].get(this.curIntersections.curves[i][j]));
if (tmp < this.curIntersections.shortest.val) {
this.curIntersections.shortest = { val: tmp, i: i, j: j };
}
}
}
}
return cnt;
}
/* Rendering helpers */
drawPoint(p1, canvasRenderer) {
const ctx = canvasRenderer.ctx;
const ls = canvasRenderer.lengthScale;
ctx.fillRect(p1.x - 1.5 * ls, p1.y - 1.5 * ls, 3 * ls, 3 * ls);
}
drawLine(p1, p2, canvasRenderer, strokeStyle) {
const ctx = canvasRenderer.ctx;
ctx.strokeStyle = strokeStyle || 'rgb(128,128,128)';
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
ctx.closePath();
ctx.fillStyle = 'rgb(255,0,0)';
[p1, p2].forEach((cur) => this.drawPoint(cur, canvasRenderer));
}
drawCurve(curve, canvasRenderer) {
const ctx = canvasRenderer.ctx;
const p = curve.points;
ctx.bezierCurveTo(p[1].x, p[1].y, p[2].x, p[2].y, p[3].x, p[3].y);
}
/**
* Draw the (polygonal) construction preview of the curve path while the
* user is still adding anchor points.
* @param {CanvasRenderer} canvasRenderer
*/
drawConstruction(canvasRenderer) {
const ctx = canvasRenderer.ctx;
const ls = canvasRenderer.lengthScale;
if (!this.path) return;
if (this.path.length === 2 && this.path[0].x === this.path[1].x && this.path[0].y === this.path[1].y) {
ctx.fillStyle = 'rgb(255,0,0)';
ctx.fillRect(this.path[0].x - 1.5 * ls, this.path[0].y - 1.5 * ls, 3 * ls, 3 * ls);
return;
}
ctx.beginPath();
ctx.moveTo(this.path[0].x, this.path[0].y);
for (let i = 0; i < this.path.length - 1; i++) {
ctx.lineTo(this.path[i + 1].x, this.path[i + 1].y);
ctx.fillStyle = 'rgb(255,0,0)';
this.drawPoint(this.path[i], canvasRenderer);
}
ctx.globalAlpha = 1;
ctx.strokeStyle = 'rgb(128,128,128)';
ctx.lineWidth = 1 * ls;
ctx.setLineDash([5 * ls, 5 * ls]);
ctx.stroke();
ctx.setLineDash([]);
}
/**
* Build a sub-path on the current canvas context that traces all Bezier
* curves from the first anchor to the last anchor. The caller is expected to
* have already called `ctx.beginPath()`. When the shape is closed, callers
* may additionally call `ctx.closePath()` before filling.
* @param {CanvasRenderer} canvasRenderer
*/
tracePath(canvasRenderer) {
const ctx = canvasRenderer.ctx;
if (this.curves.length === 0) return;
const first = this.curves[0].points[0];
ctx.moveTo(first.x, first.y);
for (let i = 0; i < this.curves.length; i++) {
this.drawCurve(this.curves[i], canvasRenderer);
}
}
/**
* Draw the anchor and control points with tangent guide lines.
* @param {CanvasRenderer} canvasRenderer
*/
drawControlHandles(canvasRenderer) {
const ctx = canvasRenderer.ctx;
if (this.curves.length === 0) return;
ctx.beginPath();
ctx.moveTo(this.curves[0].points[0].x, this.curves[0].points[0].y);
for (let i = 0; i < this.curves.length; i++) {
const p = this.curves[i].points;
ctx.strokeStyle = 'rgb(255,0,0)';
this.drawLine(p[0], p[1], canvasRenderer, ctx.strokeStyle);
this.drawLine(p[2], p[3], canvasRenderer, ctx.strokeStyle);
p.forEach((cur) => this.drawPoint(cur, canvasRenderer));
}
}
/* Curve construction helpers */
// Generate default control points from path.
generateDefaultControlPoints(pts) {
const cpVec1 = geometry.normalizeVec(geometry.point(pts[2].x - pts[0].x, pts[2].y - pts[0].y));
const cpVec2 = geometry.normalizeVec(geometry.point(pts[3].x - pts[1].x, pts[3].y - pts[1].y));
return [
geometry.point(pts[1].x + Math.floor(cpVec1.x * 50 + 0.5), pts[1].y + Math.floor(cpVec1.y * 50 + 0.5)),
geometry.point(pts[2].x - Math.floor(cpVec2.x * 50 + 0.5), pts[2].y - Math.floor(cpVec2.y * 50 + 0.5))
];
}
// Generate the set of Bezier curves from the polyline path.
generatePolyBezier() {
const isClosed = this.isClosed !== false;
const n = this.path.length;
const numCurves = isClosed ? n : Math.max(0, n - 1);
for (let i = 0; i < numCurves; i++) {
// Get neighbors (used to estimate tangent directions at the anchors).
// For the open case, mirror the endpoint back onto itself to avoid
// reaching beyond the path.
let prev, a, b, next;
if (isClosed) {
prev = this.path[(i - 1 + n) % n];
a = this.path[i];
b = this.path[(i + 1) % n];
next = this.path[(i + 2) % n];
} else {
a = this.path[i];
b = this.path[i + 1];
prev = this.path[i - 1] || a;
next = this.path[i + 2] || b;
}
const ctrl = this.generateDefaultControlPoints([prev, a, b, next]);
this.newCurve([
{ x: a.x, y: a.y },
{ x: ctrl[0].x, y: ctrl[0].y },
{ x: ctrl[1].x, y: ctrl[1].y },
{ x: b.x, y: b.y }
]);
}
}
// Create or replace a Bezier curve, keeping the cached bounding box in sync.
newCurve(pts, i) {
if (typeof i === "undefined" || i === -1) {
this.curves.push(new Bezier(pts));
this.bboxes.push(this.curves[this.curves.length - 1].bbox());
return this.curves[this.curves.length - 1];
} else {
this.curves[i] = new Bezier(pts);
this.bboxes[i] = this.curves[i].bbox();
return this.curves[i];
}
}
};
export default CurveObjMixin;