/*
* Copyright 2026 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.
*/
/**
* @module shapeImport
* @description Pure helpers used by the "Import shapes" pipeline to
* translate parsed vector shapes (see {@link module:svgImport}) into the
* data the scene objects expect.
*
* Constructing scene objects, pushing them onto the scene graph, and
* refreshing the simulator stay in `app.js`; this module builds the payloads
* (`prepareImportedPaths`, `buildImportedObjectSpecs`) those steps consume.
*/
import {
computePathsBBox,
flattenArcSegments,
flattenPathToPolyline,
simplifyPaths,
} from './svgImport.js';
/* -------------------------------------------------------------------------- */
/* Color utilities */
/* -------------------------------------------------------------------------- */
function clamp01(v) { return v < 0 ? 0 : (v > 1 ? 1 : v); }
/** Linear RGB mix toward `target`; hue is preserved compared to photographic inversion. */
function mixRgbToward(color, target, t) {
return {
r: clamp01(color.r + (target.r - color.r) * t),
g: clamp01(color.g + (target.g - color.g) * t),
b: clamp01(color.b + (target.b - color.b) * t),
a: color.a ?? 1,
};
}
function luminanceContrast(colorLum, bgLum) {
return Math.abs(colorLum - bgLum);
}
/**
* Compute a stable RGB hex key for a color object (alpha is intentionally
* ignored: the user groups paths by RGB only).
* @param {({r:number,g:number,b:number,a:(number|undefined)}|null)} color
* @returns {string | null}
*/
export function colorToKey(color) {
if (!color) return null;
const r = Math.round(clamp01(color.r) * 255);
const g = Math.round(clamp01(color.g) * 255);
const b = Math.round(clamp01(color.b) * 255);
return '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
}
/**
* Collect the distinct stroke / fill color keys used by a list of parsed
* paths, keeping an exemplar `{r, g, b, a}` for each so the UI can show a
* proper swatch and (later) apply reversal if needed. Fill colors are only
* collected for closed paths, since open paths don't receive a fill in the
* importer.
*
* Entries are sorted by usage count, most-used first. Ties keep their
* insertion order (i.e. the order in which colors were first seen).
*
* @param {Array<{stroke: any, fill: any, closed: boolean}>} paths
* @returns {{strokes: Array<{key: string, color: {r:number,g:number,b:number,a:number}, count: number}>, fills: Array<{key: string, color: {r:number,g:number,b:number,a:number}, count: number}>}}
*/
export function collectShapeColors(paths) {
const strokes = new Map();
const fills = new Map();
let order = 0;
for (const path of paths || []) {
if (path.stroke) {
const key = colorToKey(path.stroke);
if (!strokes.has(key)) strokes.set(key, { key, color: { ...path.stroke }, count: 0, _order: order++ });
strokes.get(key).count++;
}
if (path.fill && path.closed) {
const key = colorToKey(path.fill);
if (!fills.has(key)) fills.set(key, { key, color: { ...path.fill }, count: 0, _order: order++ });
fills.get(key).count++;
}
}
const byCountDesc = (a, b) => (b.count - a.count) || (a._order - b._order);
const strip = ({ _order, ...rest }) => rest;
return {
strokes: [...strokes.values()].sort(byCountDesc).map(strip),
fills: [...fills.values()].sort(byCountDesc).map(strip),
};
}
/** Relative luminance per ITU-R BT.709 of a normalized RGB color. */
export function relativeLuminance(c) {
return 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
}
/**
* If the stroke color's luminance is too close to the background's to stay
* visible, blend the RGB toward white (dark theme) or toward black (light
* theme) until luminance separation exceeds the threshold. Unlike
* `rgb → 1-rgb` inversion, this preserves hue — only lightness moves.
*
* @param {({r:number,g:number,b:number,a:(number|undefined)}|null)} color
* @param {({r:number,g:number,b:number,a:(number|undefined)}|null)} background
* @param {number} [contrastThreshold=0.2]
*/
export function adjustColorForBackground(color, background, contrastThreshold = 0.2) {
if (!color || !background) return color;
const bl = relativeLuminance(background);
if (luminanceContrast(relativeLuminance(color), bl) >= contrastThreshold) {
return color;
}
const towardWhite = bl < 0.5;
const target = towardWhite ? { r: 1, g: 1, b: 1 } : { r: 0, g: 0, b: 0 };
let lo = 0;
let hi = 1;
let best = mixRgbToward(color, target, 1);
for (let i = 0; i < 18; i++) {
const t = (lo + hi) / 2;
const mixed = mixRgbToward(color, target, t);
if (luminanceContrast(relativeLuminance(mixed), bl) >= contrastThreshold) {
best = mixed;
hi = t;
} else {
lo = t;
}
}
return best;
}
/* -------------------------------------------------------------------------- */
/* Geometry utilities */
/* -------------------------------------------------------------------------- */
/**
* Apply the affine `{scale, offsetX, offsetY}` chosen in the import modal to
* a parsed path, returning a new path in scene coordinates.
*
* - Line (`L`) and cubic (`C`) segments are simply mapped point-wise.
* - Elliptical arc (`A`) segments survive exactly: the 2×2 shape matrix
* scales with the uniform factor, and the center / endpoint are mapped
* with the full affine. No flattening happens here — the caller should
* run {@link module:svgImport.flattenArcSegments} afterwards with the
* desired scene-space tolerance.
*
* @param {ParsedPath} path
* @param {{scale:number, offsetX:number, offsetY:number}} opts
*/
export function transformPathToScene(path, opts) {
const map = (pt) => ({ x: opts.offsetX + pt.x * opts.scale, y: opts.offsetY + pt.y * opts.scale });
const s = opts.scale;
return {
...path,
start: map(path.start),
segments: path.segments.map((seg) => {
if (seg.type === 'L') return { type: 'L', end: map(seg.end) };
if (seg.type === 'C') return { type: 'C', c1: map(seg.c1), c2: map(seg.c2), end: map(seg.end) };
return {
type: 'A',
center: map(seg.center),
mat: {
a: seg.mat.a * s,
b: seg.mat.b * s,
c: seg.mat.c * s,
d: seg.mat.d * s,
},
theta0: seg.theta0,
theta1: seg.theta1,
end: map(seg.end),
};
}),
};
}
/**
* Build the `points` array expected by CurveObjMixin from a parsed path
* whose arcs have already been flattened to `L` / `C` segments.
*
* Each entry has the shape `{a1, c1, c2}` where `a1` is the anchor and
* `c1`/`c2` are the two outgoing control points leading to the next anchor.
* Open curves append a trailing `{a1}` (no outgoing controls).
*
* Straight `L` segments are expressed as cubics with collinear control
* points so the resulting curve stays exactly a line.
*
* For a closed path:
* - If the last segment already returns to the starting anchor, we drop the
* redundant entry (the mixin closes the loop implicitly).
* - Otherwise we append a straight wrap-around segment.
*
* @param {{x:number, y:number}} start
* @param {Array<{type: ('L'|'C'), c1: *, c2: *, end: *}>} segments
* @param {boolean} closed
*/
export function buildCurvePoints(start, segments, closed) {
const points = [];
let anchor = { x: start.x, y: start.y };
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const end = { x: seg.end.x, y: seg.end.y };
let c1, c2;
if (seg.type === 'C') {
c1 = { x: seg.c1.x, y: seg.c1.y };
c2 = { x: seg.c2.x, y: seg.c2.y };
} else {
c1 = { x: anchor.x + (end.x - anchor.x) / 3, y: anchor.y + (end.y - anchor.y) / 3 };
c2 = { x: anchor.x + 2 * (end.x - anchor.x) / 3, y: anchor.y + 2 * (end.y - anchor.y) / 3 };
}
points.push({ a1: { x: anchor.x, y: anchor.y }, c1, c2 });
anchor = end;
}
if (closed) {
const first = points[0]?.a1;
if (first && Math.hypot(anchor.x - first.x, anchor.y - first.y) < 1e-6) {
return points;
}
const c1 = { x: anchor.x + (first.x - anchor.x) / 3, y: anchor.y + (first.y - anchor.y) / 3 };
const c2 = { x: anchor.x + 2 * (first.x - anchor.x) / 3, y: anchor.y + 2 * (first.y - anchor.y) / 3 };
points.push({ a1: { x: anchor.x, y: anchor.y }, c1, c2 });
return points;
}
points.push({ a1: { x: anchor.x, y: anchor.y } });
return points;
}
/* -------------------------------------------------------------------------- */
/* Defaults / bounding boxes */
/* -------------------------------------------------------------------------- */
/**
* Given the imported SVG bounding box and the current viewport metrics,
* derive `{scale, offsetX, offsetY}` that centers the imported geometry in
* the visible viewport and has it occupy roughly `fillRatio` of the shorter
* viewport dimension. Mirrors how the observer and the crop box initialize
* themselves.
*
* The caller is responsible for feeding in the current viewport width /
* height / origin / scale (this keeps the helper fully pure).
*
* @param {{minX:number,minY:number,maxX:number,maxY:number} | null} bbox
* @param {{viewportWidth: number, viewportHeight: number, sceneOriginX: number, sceneOriginY: number, sceneScale: number, fillRatio: (number|undefined)}} viewport
* @returns {{ scale:number, offsetX:number, offsetY:number, viewportCenter:{x:number,y:number} }}
*/
export function computeImportPlacement(bbox, viewport) {
const {
viewportWidth,
viewportHeight,
sceneOriginX,
sceneOriginY,
sceneScale,
fillRatio = 0.6,
} = viewport;
const viewportCenter = {
x: (viewportWidth * 0.5 - sceneOriginX) / sceneScale,
y: (viewportHeight * 0.5 - sceneOriginY) / sceneScale,
};
if (!bbox) {
return { scale: 1, offsetX: viewportCenter.x, offsetY: viewportCenter.y, viewportCenter };
}
const w = Math.max(bbox.maxX - bbox.minX, 1e-6);
const h = Math.max(bbox.maxY - bbox.minY, 1e-6);
const targetW = (viewportWidth / sceneScale) * fillRatio;
const targetH = (viewportHeight / sceneScale) * fillRatio;
const suggestedScale = Math.min(targetW / w, targetH / h);
const scale = Number.isFinite(suggestedScale) && suggestedScale > 0 ? suggestedScale : 1;
const cx = (bbox.minX + bbox.maxX) / 2;
const cy = (bbox.minY + bbox.maxY) / 2;
return {
scale,
offsetX: viewportCenter.x - cx * scale,
offsetY: viewportCenter.y - cy * scale,
viewportCenter,
};
}
/**
* Vertical gap from the bottom of the imported bbox to the handle anchor (`p1.y`).
* Uses twice the scene-space “handle radius” implied by the theme — same scaling as
* {@link Handle#checkMouseOver}: `(handleArrow.size / 24) * 20 * scene.lengthScale`.
*
* @param {Object|null} scene
*/
export function importedHandleOffsetBelowBBox(scene) {
const size = scene?.theme?.handleArrow?.size ?? 24;
const ls = scene?.lengthScale ?? 1;
const radiusScene = (size / 24) * 20 * ls;
return 2 * radiusScene;
}
/* -------------------------------------------------------------------------- */
/* Parsed paths → placement defaults */
/* -------------------------------------------------------------------------- */
/**
* Scale / offset defaults for the import modal from parsed paths and viewport
* metrics (same math as before in `app.js`, without reading `window` or scene).
*
* @param {Array} paths - Parsed paths (pre-placement SVG space).
* @param {{viewportWidth: number, viewportHeight: number, sceneOriginX: number, sceneOriginY: number, sceneScale: number, fillRatio: (number|undefined)}} viewport
* @returns {{ scale: number, offsetX: number, offsetY: number, bbox: Object|null, viewportCenter: {x:number,y:number} }}
*/
export function computeImportShapesDefaults(paths, viewport) {
const bbox = computePathsBBox(paths);
const placement = computeImportPlacement(bbox, viewport);
return { ...placement, bbox };
}
/* -------------------------------------------------------------------------- */
/* Parsed paths → scene-space geometry → object specs */
/* -------------------------------------------------------------------------- */
/**
* Placement, arc flattening, and cubic merge — same order as the live import.
*
* @param {Array} paths - Parsed paths from {@link module:svgImport.parseShapesFile}.
* @param {{ scale:number, offsetX:number, offsetY:number, tolerance:number }} opts
*/
export function prepareImportedPaths(paths, opts) {
const placed = paths.map((p) => transformPathToScene(p, opts));
const arcTolerance = opts.tolerance > 0 ? opts.tolerance : 0.1;
const flattened = flattenArcSegments(placed, arcTolerance);
return opts.tolerance > 0 ? simplifyPaths(flattened, opts.tolerance) : flattened;
}
/**
* Turn simplified scene-space paths into constructor payloads for scene objects.
*
* @param {Array} simplifiedPaths - Output of {@link prepareImportedPaths}.
* @param {{tolerance: number, strokeActions: Object<string, {action: string}>, fillActions: Object<string, {action: string, refIndex: (number|undefined), cauchyB: (number|undefined)}>, backgroundColor: ({r:number,g:number,b:number,a:(number|undefined)}|null)}} opts
* @returns {Array<{ type: string, props: Object }>}
*/
export function buildImportedObjectSpecs(simplifiedPaths, opts) {
const tolerance = opts.tolerance > 0 ? opts.tolerance : 0.1;
const strokeActions = opts.strokeActions || {};
const fillActions = opts.fillActions || {};
const bg = opts.backgroundColor ?? null;
const specs = [];
/** Merged Drawing index by display-color key. */
/** @type {Map<string, number>} */
const drawingsByColor = new Map();
for (const path of simplifiedPaths) {
if (!path || !path.segments || path.segments.length < 1) continue;
if (path.stroke) {
const strokeKey = colorToKey(path.stroke);
const strokeCfg = strokeActions[strokeKey];
const strokeAction = strokeCfg ? strokeCfg.action : 'none';
if (strokeAction === 'CurveMirror' || strokeAction === 'CustomCurveSurface') {
const pts = buildCurvePoints(path.start, path.segments, !!path.closed);
if (pts.length >= 2) {
specs.push({
type: strokeAction,
props: {
points: pts,
isClosed: !!path.closed,
notDone: false,
},
});
}
} else if (strokeAction === 'Drawing') {
const poly = flattenPathToPolyline(path, Math.max(tolerance, 0.1));
if (poly.length >= 2) {
if (path.closed && poly.length > 0) {
const first = poly[0];
const last = poly[poly.length - 1];
if (first.x !== last.x || first.y !== last.y) poly.push({ x: first.x, y: first.y });
}
const stroke = [];
for (const pt of poly) stroke.push(pt.x, pt.y);
const displayColor = adjustColorForBackground(path.stroke, bg);
const drawingKey = colorToKey(displayColor);
if (!drawingsByColor.has(drawingKey)) {
drawingsByColor.set(drawingKey, specs.length);
specs.push({
type: 'Drawing',
props: {
strokes: [],
isDrawing: false,
lineStyle: {
color: { r: displayColor.r, g: displayColor.g, b: displayColor.b, a: displayColor.a ?? 1 },
},
},
});
}
const drawingIdx = drawingsByColor.get(drawingKey);
specs[drawingIdx].props.strokes.push(stroke);
}
}
}
if (path.closed && path.fill) {
const fillKey = colorToKey(path.fill);
const fillCfg = fillActions[fillKey];
const fillAction = fillCfg ? fillCfg.action : 'none';
if (fillAction === 'CurveGlass') {
const pts = buildCurvePoints(path.start, path.segments, true);
if (pts.length >= 2) {
const props = {
points: pts,
notDone: false,
};
if (Number.isFinite(fillCfg.refIndex)) props.refIndex = fillCfg.refIndex;
if (Number.isFinite(fillCfg.cauchyB)) props.cauchyB = fillCfg.cauchyB;
specs.push({ type: 'CurveGlass', props });
}
} else if (fillAction === 'CurveGrinGlass') {
const pts = buildCurvePoints(path.start, path.segments, true);
if (pts.length >= 2) {
specs.push({
type: 'CurveGrinGlass',
props: {
points: pts,
notDone: false,
},
});
}
}
}
}
return specs;
}
/**
* Bounding box of geometry described by imported specs (mirrors scene-object
* bbox logic used for handle placement).
*
* @param {Array<{ type: string, props: Object }>} specs
* @returns {({minX:number,minY:number,maxX:number,maxY:number}|null)}
*/
export function boundingBoxFromImportedSpecs(specs) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let any = false;
const push = (pt) => {
if (!pt || !Number.isFinite(pt.x) || !Number.isFinite(pt.y)) return;
if (pt.x < minX) minX = pt.x;
if (pt.y < minY) minY = pt.y;
if (pt.x > maxX) maxX = pt.x;
if (pt.y > maxY) maxY = pt.y;
any = true;
};
for (const spec of specs || []) {
const props = spec.props;
if (!props) continue;
if (props.points && props.points.length) {
for (const row of props.points) {
if (row.a1) push(row.a1);
if (row.c1) push(row.c1);
if (row.c2) push(row.c2);
}
}
if (props.strokes) {
for (const stroke of props.strokes) {
for (let k = 0; k + 1 < stroke.length; k += 2) {
push({ x: stroke[k], y: stroke[k + 1] });
}
}
}
}
return any ? { minX, minY, maxX, maxY } : null;
}