dfg_3dviewer_js_library/viewer/viewer-utils.js
2026-06-25 09:11:23 +02:00

1116 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// viewer-utils.js
import THREE from "./init.js";
import { core, setCore } from './core.js';
import TWEEN, { add } from "three/examples/jsm/libs/tween.module.js";
import { normalizeColor, parseCssColor } from './utils.js';
import { t } from "./i18n-utils.js";
export const initClippingPlanes = () => {
const clippingPlanes = [
new THREE.Plane(new THREE.Vector3(-1, 0, 0), 0),
new THREE.Plane(new THREE.Vector3(0, -1, 0), 0),
new THREE.Plane(new THREE.Vector3(0, 0, -1), 0),
];
setCore('clippingPlanes', clippingPlanes);
return clippingPlanes;
};
const scaleXYZ = (v, s) =>
['x', 'y', 'z'].forEach(k => v[k] *= s);
const DEFAULT_NOTICE_DURATION = 4200;
function normalizeNoticeArgs(toneOrOptions, maybeOptions) {
let tone = "info";
let options = {};
if (typeof toneOrOptions === "string") {
tone = toneOrOptions;
options = maybeOptions ?? {};
} else if (toneOrOptions && typeof toneOrOptions === "object") {
options = toneOrOptions;
tone = toneOrOptions.tone ?? "info";
}
return { tone, options };
}
export const toastHelper = (key, toneOrOptions, maybeOptions) => {
return showToast(`toasts.${key}`, toneOrOptions, maybeOptions);
};
export const showToast = (message, toneOrOptions, maybeOptions) => {
if (!core.showNotifications) return;
const { tone, options } = normalizeNoticeArgs(toneOrOptions, maybeOptions);
const duration = Number.isFinite(options.duration)
? options.duration
: DEFAULT_NOTICE_DURATION;
const key = String(options.key ?? "");
const replace = options.replace === true;
const persistent = options.persistent === true;
const variant = String(options.variant ?? "");
let i18nKey = "";
let i18nVars = {};
const detailI18nKey = String(options.detailI18nKey ?? "");
const detailI18nVars = options.detailVars && typeof options.detailVars === "object" ? options.detailVars : options;
// Resolve i18n key if possible, otherwise use the message as-is (for backward compatibility)
let text;
let detail = "";
if (typeof message === "string" && message.includes(".")) {
// try to resolve as i18n key with optional variables
i18nKey = message;
i18nVars = options;
text = t(message, options);
} else {
// fallback (old way)
text = String(message);
}
if (detailI18nKey) {
detail = t(detailI18nKey, detailI18nVars);
} else if (options.detail != null) {
detail = String(options.detail);
}
if (window.__E2E__ && window.viewer) {
window.viewer.toasts ??= [];
window.viewer.toasts.push([text, detail].filter(Boolean).join(" "));
}
const statusNotice = core.statusNotice;
const enqueueStatusNotice = core.enqueueStatusNotice;
if (typeof enqueueStatusNotice === "function") {
enqueueStatusNotice({ message: text, detail, tone, duration, key, replace, persistent, variant, i18nKey, i18nVars, detailI18nKey, detailI18nVars });
return;
}
if (!statusNotice) {
console.info(`[viewer:${tone}] ${text}`);
return;
}
statusNotice.hidden = false;
statusNotice.textContent = [text, detail].filter(Boolean).join(" ");
statusNotice.dataset.tone = tone;
statusNotice.classList.remove("is-visible", "is-hiding");
void statusNotice.offsetWidth;
statusNotice.classList.add("is-visible");
if (core.statusNoticeTimer) {
window.clearTimeout(core.statusNoticeTimer);
}
core.statusNoticeTimer = window.setTimeout(() => {
statusNotice.classList.remove("is-visible");
statusNotice.classList.add("is-hiding");
window.setTimeout(() => {
if (!statusNotice.classList.contains("is-visible")) {
statusNotice.hidden = true;
statusNotice.classList.remove("is-hiding");
}
}, 220);
}, duration);
};
export function getErrorMessage(error) {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string") return error;
return String(error);
}
export function reportViewerError(error, options = {}) {
const {
context = "",
consoleLabel = "Viewer error",
toast = true,
e2e = true,
} = options;
const baseMessage = getErrorMessage(error);
const message = context ? `${context}: ${baseMessage}` : baseMessage;
console.error(consoleLabel, error);
if (e2e && window.__E2E__ && window.viewer) {
window.viewer.errors ??= [];
window.viewer.errors.push(message);
}
if (toast) {
showToast(message);
}
return message;
}
function fetchObjectFromConfig(_name) {
//console.log("Fetching config for", _name, core.objectsConfig);
return core.objectsConfig?.models?.find(model => model.name === _name);
}
function normalizeVec3(v) {
if (!v) return null;
if (Array.isArray(v) && v.length === 3) {
return { x: v[0], y: v[1], z: v[2] };
}
if (
typeof v === "object" &&
typeof v.x === "number" &&
typeof v.y === "number" &&
typeof v.z === "number"
) {
return v;
}
return null;
}
function normalizeGradient(gradient) {
return {
type: gradient.type,
shapeOrDirection: gradient.shapeOrDirection,
colors: gradient.colors
.map(normalizeColor)
.filter(Boolean)
};
}
function setupObjectHandler(_object, _metadata) {
if (!_metadata) return;
const pos = normalizeVec3(_metadata.position ?? _metadata.objPosition);
if (pos) {
_object.position.set(pos.x, pos.y, pos.z);
}
const scale = normalizeVec3(_metadata.scale ?? _metadata.objScale);
if (scale) {
_object.scale.set(scale.x, scale.y, scale.z);
}
const rot = normalizeVec3(_metadata.rotation ?? _metadata.objRotation);
if (rot) {
_object.rotation.set(
THREE.MathUtils.degToRad(rot.x),
THREE.MathUtils.degToRad(rot.y),
THREE.MathUtils.degToRad(rot.z)
);
}
}
function setupGeometryHandler (_object) {
_object.needsUpdate = true;
if (typeof _object.geometry !== "undefined") {
_object.geometry.computeBoundingBox();
_object.geometry.computeBoundingSphere();
}
_object.updateMatrix();
_object.updateMatrixWorld(true);
}
function centerObjectAtOrigin(_object) {
const boundingBox = new THREE.Box3();
boundingBox.setFromObject(_object, true);
if (boundingBox.isEmpty()) return;
const center = new THREE.Vector3();
boundingBox.getCenter(center);
_object.position.sub(center);
_object.updateMatrixWorld(true);
}
function setupCameraHandler(_object, meta) {
if (!meta) return;
const target = normalizeVec3(meta.controlsTarget);
const camPos = normalizeVec3(meta.cameraPosition);
if (!target && !camPos) return;
const wasDamping = core.controls.enableDamping;
core.controls.enableDamping = false;
if (target) {
core.controls.target?.set(target.x, target.y, target.z);
}
if (camPos) {
core.camera.position?.set(camPos.x, camPos.y, camPos.z);
}
core.camera.updateProjectionMatrix();
core.controls.update();
core.controls.saveState();
core.controls.enableDamping = wasDamping;
}
export const setupObject = (_object, _metadata) => {
let model;
if (typeof _object.children === "undefined" || _object.children.length == 0) {
model = fetchObjectFromConfig(_object.name);
} else if (_object.children.length > 0) {
model = fetchObjectFromConfig(_object.children[0].name); //TODO: check for multiple objects
}
if (_metadata != null) {
setupObjectHandler(_object, _metadata);
setupGeometryHandler(_object);
setupCameraHandler(_object, _metadata);
}
else if (typeof core.objectsConfig !== "undefined" && model) { //Setup from config
if ((!Array.isArray(core.objectsConfig.models) || core.objectsConfig.models.length === 0) && _metadata == null) {
if (model.position != null) _object.position.set(model.position.x, model.position.y, model.position.z);
if (model.scale != null) _object.scale.set(model.scale.x, model.scale.y, model.scale.z);
if (model.rotation != null) _object.rotation.set(THREE.MathUtils.degToRad(model.rotation.x), THREE.MathUtils.degToRad(model.rotation.y), THREE.MathUtils.degToRad(model.rotation.z));
} else {
let m = core.objectsConfig.models[core.objectsConfig.setupIndex];
if (m != undefined && _metadata == null) {
//console.log("Applying config for index", core.objectsConfig.setupIndex, m);
setupObjectHandler(_object, m);
} else if (_metadata != null) {
// Fallback to metadata
setupObjectHandler(_object, _metadata);
}
}
setupGeometryHandler(_object);
}
else {
var boundingBox = new THREE.Box3();
if (Array.isArray(_object)) {
for (let i = 0; i < _object.length; i++) {
boundingBox.setFromObject(_object[i]);
_object[i].position.set(
-(boundingBox.min.x + boundingBox.max.x) / 2,
-boundingBox.min.y,
-(boundingBox.min.z + boundingBox.max.z) / 2
);
_object[i].needsUpdate = true;
if (typeof _object[i].geometry !== "undefined") {
_object[i].geometry.computeBoundingBox();
_object[i].geometry.computeBoundingSphere();
}
_object[i].updateMatrixWorld();
}
} else if (_object.isGroup) {
// Keep non-presentation behavior, but center fully in presentation mode.
if (core.PRESENTATION_MODE) {
centerObjectAtOrigin(_object);
} else {
//workaround for specific Group case
boundingBox.setFromObject(_object);
_object.position.set(-(boundingBox.min.x+boundingBox.max.x)/2, -boundingBox.min.y, -(boundingBox.min.z+boundingBox.max.z)/2);
_object.updateMatrixWorld();
}
} else if (!core.PRESENTATION_MODE) {
boundingBox.setFromObject(_object);
_object.position.set((boundingBox.max.x - boundingBox.min.x ) / 2, (boundingBox.max.y - boundingBox.min.y) / 2, (boundingBox.max.z - boundingBox.min.z ) / 2);
setupGeometryHandler(_object);
} else {
// In presentation mode keep local hierarchy transforms and center only the whole object.
centerObjectAtOrigin(_object);
setupGeometryHandler(_object);
}
}
core.cameraLight.position.set(
core.camera.position.x,
core.camera.position.y,
core.camera.position.z
);
if (Array.isArray(_object)) {
core.cameraLightTarget.position.set(
_object[0].position.x,
_object[0].position.y,
_object[0].position.z
);
} else {
core.cameraLightTarget.position.set(
_object.position.x,
_object.position.y,
_object.position.z
);
}
core.cameraLight.target.updateMatrixWorld();
core.objectsConfig.setupIndex++;
}
async function setupEmptyCamera(_object) {
console.log("Setting up empty camera");
var boundingBox = new THREE.Box3();
if (Array.isArray(_object)) {
for (let i = 0; i < _object.length; i++) {
const box = new THREE.Box3().setFromObject(_object[i], true);
boundingBox.union(box);
}
} else {
boundingBox.setFromObject(_object, true);
}
var size = new THREE.Vector3();
boundingBox.getSize(size);
var center = new THREE.Vector3();
boundingBox.getCenter(center);
// Set camera position at the center level, behind the model
const distance = size.length();
core.camera.position.set(center.x, center.y, center.z + distance);
await fitCameraToCenteredObject(_object, true);
}
function parseColor(v) {
if (Array.isArray(v)) {
const [r, g, b, a = 1] = v;
return { r, g, b, a };
}
if (typeof v === "string") {
return parseCssColor(v); // #hex / rgb / rgba
}
return null;
}
function parseGradientArray(arr) {
if (!Array.isArray(arr) || arr.length === 0) return null;
// [r, g, b] → single color
if (
arr.length === 3 &&
arr.every(v => typeof v === "number")
) {
return {
type: "linear",
colors: [ { r: arr[0], g: arr[1], b: arr[2], a: 1 } ]
};
}
// list of colors
const colors = arr
.map(parseColor)
.filter(Boolean);
if (colors.length < 2) return null;
return {
type: "linear",
colors
};
}
function resolveBackground(meta, sceneId) {
const raw =
meta.scenes?.[sceneId]?.background ??
meta.scene?.background ??
meta.globals?.background ??
null;
if (!raw) return { kind: "default" };
// object + array of colors
if (typeof raw === "object" && Array.isArray(raw.value)) {
const gradient = parseGradientArray(raw.value);
if (gradient) {
const normalizedGradient = normalizeGradient(gradient);
return { kind: "gradient", normalizedGradient };
}
}
// css string
if (typeof raw === "string") {
const gradient = parseGradient(raw);
if (gradient) {
const normalizedGradient = normalizeGradient(gradient);
return { kind: "gradient", normalizedGradient };
}
return { kind: "color", color: raw };
}
return { kind: "default" };
}
export async function setupCamera(_object, _data) {
const _light = core.lightObjects[0];
const cfg = _data ?? core.CONFIG ?? null;
const fallback = _data ?? core.objectsConfig ?? null;
// --- CAMERA POSITION ---
const camPos = cfg?.cameraPosition ?? fallback?.camera?.position;
if (Array.isArray(camPos)) {
core.camera.position.set(camPos[0], camPos[1], camPos[2]);
} else if (camPos && typeof camPos === "object") {
core.camera.position.set(camPos.x, camPos.y, camPos.z);
} else {
await setupEmptyCamera(_object);
}
// --- CONTROLS TARGET + ZOOM ---
const target = cfg?.controlsTarget ?? fallback?.camera?.target;
if (Array.isArray(target)) {
core.controls.target.set(target[0], target[1], target[2]);
} else if (target) {
core.controls?.target.set(target.x, target.y, target.z);
}
const customZoom = cfg?.controlsZoom?.[0];
if (typeof customZoom === "number" && customZoom !== 0) {
const dir = new THREE.Vector3()
.subVectors(core.camera.position, core.controls?.target || new THREE.Vector3())
.normalize();
core.camera.position
.copy(core.controls?.target || new THREE.Vector3())
.add(dir.multiplyScalar(customZoom));
}
// --- LIGHTS ---
if (!cfg && fallback?.scene?.lights) {
fallback.scene.lights.forEach(light => {
switch (light.type) {
case "directional":
_light.position.set(light.position.x, light.position.y, light.position.z);
_light.color = new THREE.Color(normalizeColor(light.color));
_light.intensity = light.intensity;
break;
case "ambient":
core.ambientLight.color = new THREE.Color(normalizeColor(light.color));
core.ambientLight.intensity = light.intensity;
break;
case "point":
core.cameraLight.color = new THREE.Color(normalizeColor(light.color));
core.cameraLight.intensity = light.intensity;
break;
}
});
}
else if (cfg) {
if (cfg.lightAmbientColor) {
core.ambientLight.color = new THREE.Color(normalizeColor(cfg.lightAmbientColor[0]));
core.ambientLight.intensity = cfg.lightAmbientIntensity?.[0] ?? core.ambientLight.intensity;
}
if (cfg.lightColor) {
_light.color = new THREE.Color(normalizeColor(cfg.lightColor[0]));
_light.intensity = cfg.lightIntensity?.[0] ?? _light.intensity;
}
if (cfg.lightCameraColor) {
core.cameraLight.color = new THREE.Color(normalizeColor(cfg.lightCameraColor[0]));
core.cameraLight.intensity = cfg.lightCameraIntensity?.[0] ?? core.cameraLight.intensity;
}
}
// --- BACKGROUND ---
//const sceneBg = fallback?.scene?.background;
if (!core.PRESENTATION_MODE) {
const bg = resolveBackground(fallback, core.activeScene);
switch (bg.kind) {
case "gradient":
case "radial":
applyGradientCss(bg.normalizedGradient);
break;
case "color":
case "linear":
changeBackground("linear", bg.color);
break;
case "default":
case "unknown":
changeBackground(
"radial",
core.colors.BackgroundColor,
core.colors.BackgroundColorOuter
);
break;
}
} else {
const grandient = {type: "radial", colors: [{r: 255, g: 255, b: 255, a: 0}, {r: 255, g: 255, b: 255, a: 0}]};
applyGradientCss(grandient);
}
core.camera.updateProjectionMatrix();
core.controls?.update();
await fitCameraToCenteredObject(_object, false, cfg);
}
// Show interaction hint on first load
function showInteractionHint(boxCenter) {
if (window.__E2E__) return;
//if (localStorage.getItem("viewerHintSeen")) return;
if (core.GESTURE == null) return;
core.GESTURE.rotate = true;
core.GESTURE.target = boxCenter.clone();
core.controls.target.copy(core.GESTURE.target);
if (core.handHint == null) return;
core.handHint.hidden = false;
core.handHint.classList.add("hand-drag-animate");
}
function animateCameraToPose ({
finalCameraPos, // THREE.Vector3 (target camera position)
finalTarget, // THREE.Vector3 (target)
boundingBox, // THREE.Box3 (optional, near/far)
duration = 3500,
easing = TWEEN.Easing.Cubic.Out,
startOffsetFactor = 0.5, // % of moving back (0.20.4 should be good)
animate = true,
distanceOffsetFactor = 0, // additional factor to move closer (0.1 = 10% closer) (optional)
distanceOffsetUnits = 0, // additional world units to move closer (optional)
}) {
const endCamPos = finalCameraPos.clone();
const endTarget = finalTarget.clone();
const dir = endCamPos.clone().sub(endTarget).normalize();
const baseDistance = endCamPos.distanceTo(endTarget);
const distanceOffset =
baseDistance * distanceOffsetFactor + distanceOffsetUnits;
const startCamPos = endCamPos.clone().add(
dir.multiplyScalar(
baseDistance * startOffsetFactor + distanceOffset
)
);
const startTarget = endTarget.clone(); // target
if (!animate) {
core.camera.position.copy(endCamPos);
core.controls?.target.copy(endTarget);
core.controls?.update();
return;
}
core.camera.position.copy(startCamPos);
core.controls?.target.copy(startTarget);
core.controls?.update();
const camTweenPos = startCamPos.clone();
const targetTweenPos = startTarget.clone();
core.cameraTween = new TWEEN.Tween(camTweenPos)
.to(endCamPos, duration)
.easing(easing)
.onUpdate(() => {
core.camera.position.copy(camTweenPos);
});
core.targetTween = new TWEEN.Tween(targetTweenPos)
.to(endTarget, duration)
.easing(easing)
.onUpdate(() => {
core.controls?.target.copy(targetTweenPos);
core.controls?.update();
});
core.cameraTween.start();
core.targetTween.start();
// === (near / far / limits) ===
core.cameraTween.onComplete(() => {
core.camera.position.copy(endCamPos);
core.controls?.target.copy(endTarget);
core.controls?.update();
const boxCenter = boundingBox ? boundingBox.getCenter(new THREE.Vector3()) : new THREE.Vector3();
if (boundingBox) {
const boxSize = boundingBox.getSize(new THREE.Vector3()).length();
const maxDistance =
endCamPos.distanceTo(boxCenter) + boxSize;
core.camera.near = Math.max(maxDistance / 1000, 0.001);
core.camera.far = maxDistance * 10;
core.camera.updateProjectionMatrix();
if (core.controls) {
core.controls.maxDistance = maxDistance * 2;
}
}
showInteractionHint(boxCenter);
});
}
async function fitCameraToCenteredObject(object, _fit, cfg) {
const boundingBox = new THREE.Box3();
if (Array.isArray(object)) {
for (let i = 0; i < object.length; i++) {
const box = new THREE.Box3().setFromObject(object[i], true);
boundingBox.union(box);
}
} else {
boundingBox.setFromObject(object, true);
}
var size = new THREE.Vector3(), center = new THREE.Vector3();
boundingBox.getSize(size);
boundingBox.getCenter(center); // center point
// ground
var distance1 = new THREE.Vector3(
Math.abs(boundingBox.max.x - boundingBox.min.x),
Math.abs(boundingBox.max.y - boundingBox.min.y),
Math.abs(boundingBox.max.z - boundingBox.min.z)
);
core.gridSize = Math.max(distance1.x, distance1.y, distance1.z);
core.dirLightTarget = new THREE.Object3D();
core.dirLightTarget.position.set(0, 0, 0);
core.lightHelper = new THREE.DirectionalLightHelper(core.dirLight, core.gridSize);
core.scene.add(core.lightHelper);
core.lightHelper.visible = false;
core.scene.add(core.dirLightTarget);
core.dirLight.target = core.dirLightTarget;
core.dirLight.target.updateMatrixWorld();
var gridSizeScale = core.gridSize * 2.5;
if (core.basicGrid !== undefined) core.scene.remove(core.basicGrid);
core.basicGrid = new THREE.Group();
var planeMaterial = new THREE.ShadowMaterial({ opacity: 0.35 })
var planeMesh = new THREE.Mesh(new THREE.PlaneGeometry(gridSizeScale, gridSizeScale), planeMaterial);
planeMesh.rotation.x = -Math.PI / 2;
planeMesh.position.set(0, 0, 0);
planeMesh.receiveShadow = true;
core.basicGrid.add(planeMesh);
core.axesHelper = new THREE.AxesHelper(core.gridSize);
core.axesHelper.position.set(0, 0, 0);
core.axesHelper.visible = false;
core.basicGrid.add(core.axesHelper);
const grid = new THREE.GridHelper(gridSizeScale, 25, 0xaeaeae, 0x000000);
grid.material.opacity = 0.05;
grid.material.transparent = true;
grid.position.set(0, 0, 0);
core.basicGrid.add(grid);
core.scene.add(core.basicGrid);
// === fit camera distance ===
const halfHeight = size.y / 2;
const halfWidth = size.x / 2;
const fitHeightDistance =
halfHeight / Math.tan(THREE.MathUtils.degToRad(core.camera.fov / 2));
const fitWidthDistance =
halfWidth /
Math.tan(THREE.MathUtils.degToRad(core.camera.fov / 2)) /
core.camera.aspect;
const distance = Math.max(fitHeightDistance, fitWidthDistance) * 1.95;
// === target position ===
const dir = new THREE.Vector3(0.5, -0.25, -1).normalize(); // 45-degree angle perspective
dir.multiplyScalar(-distance);
const finalCameraPos = center.clone().add(dir);
const finalTarget = center.clone();
// === override from config if available ===
if (cfg?.cameraPosition?.length === 3) {
finalCameraPos.set(
cfg.cameraPosition[0],
cfg.cameraPosition[1],
cfg.cameraPosition[2]
);
}
if (cfg?.controlsTarget?.length === 3) {
finalTarget.set(
cfg.controlsTarget[0],
cfg.controlsTarget[1],
cfg.controlsTarget[2]
);
}
// Store reset position for "Reset camera" action
core.cameraCoords = finalCameraPos.clone();
core.controlsTarget = finalTarget.clone();
// === animate ===
animateCameraToPose({
finalCameraPos,
finalTarget,
boundingBox,
duration: 3500,
startOffsetFactor: 0.15,
distanceOffsetFactor: -0.5, // 0.1 = 10% closer
distanceOffsetUnits: 0, // +0.5 world units
});
if (_fit) {
var rotateMetadata = new THREE.Vector3();
rotateMetadata = new THREE.Vector3(
THREE.MathUtils.radToDeg(core.helperObjects[0]?.rotation.x || 1),
THREE.MathUtils.radToDeg(core.helperObjects[0]?.rotation.y || 5),
THREE.MathUtils.radToDeg(core.helperObjects[0]?.rotation.z || 1)
);
const rootObject = Array.isArray(object) ? object[0] : object;
core.objectsConfig.originalMetadata = {
objPosition: [rootObject?.position?.x || 0, rootObject?.position?.y || 0, rootObject?.position?.z || 0],
objRotation: [rotateMetadata.x, rotateMetadata.y, rotateMetadata.z],
objScale: [
core.helperObjects[0]?.scale.x || 1,
core.helperObjects[0]?.scale.y || 5,
core.helperObjects[0]?.scale.z || 1,
],
cameraPosition: [core.camera.position.x, core.camera.position.y, core.camera.position.z],
controlsTarget: [core.controls.target.x, core.controls.target.y, core.controls.target.z],
};
}
if (!core.PRESENTATION_MODE) {
setupClippingPlanes(object, {x: boundingBox.max.x*1.1, y: boundingBox.max.y*1.1, z: boundingBox.max.z*1.1});
}
}
function parseGradient(str) {
if (!str || typeof str !== "string") return null;
// Match "radial-gradient" or "linear-gradient"
const typeMatch = str.match(/(radial|linear)-gradient\s*\(([^,]+)/i);
const gradientType = typeMatch ? typeMatch[1].toLowerCase() : null;
const shapeOrDirection = typeMatch ? typeMatch[2].trim() : null;
const colors = [];
/* ==========================
RGB / RGBA
========================== */
const rgbMatches = str.matchAll(
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/gi
);
for (const [, r, g, b, a] of rgbMatches) {
colors.push({
r: +r,
g: +g,
b: +b,
a: a !== undefined ? +a : 1,
});
}
/* ==========================
HEX (#RGB / #RRGGBB)
========================== */
const hexMatches = str.matchAll(/#([0-9a-f]{3}|[0-9a-f]{6})/gi);
for (const [, hex] of hexMatches) {
const fullHex =
hex.length === 3
? hex.split("").map(c => c + c).join("")
: hex;
colors.push({
r: parseInt(fullHex.slice(0, 2), 16),
g: parseInt(fullHex.slice(2, 4), 16),
b: parseInt(fullHex.slice(4, 6), 16),
a: 1,
});
}
return {
type: gradientType, // "radial" | "linear"
shapeOrDirection, // "circle", "to right", etc.
colors, // [{ r, g, b, a }]
};
}
function rgbaToCss(c) {
return `rgba(${c.r}, ${c.g}, ${c.b}, ${c.a})`;
}
function changeBackgroundHelper(_color1, _color2, _alpha = 100) {
core.mainCanvas.style.setProperty(
"background",
`radial-gradient(circle, ${_color1} 0%, ${_color2} ${_alpha}%)`
);
}
export function applyGradientCss(gradient) {
if (!gradient || !Array.isArray(gradient.colors) || gradient.colors.length === 0) {
return;
}
const colors = gradient.colors;
// 1 color → solid background
if (colors.length === 1) {
const c = rgbaToCss(colors[0]);
changeBackground("linear", c);
return;
}
// 2 colors → legacy helper
if (colors.length === 2) {
const c1 = rgbaToCss(colors[0]);
const c2 = rgbaToCss(colors[1]);
changeBackground(gradient.type, c1, c2);
return;
}
// >= 3 stops → full CSS gradient
const stops = colors.map((c, i) => {
const t = Math.round((i / (colors.length - 1)) * 100);
return `${rgbaToCss(c)} ${t}%`;
});
const css =
gradient.type === "radial"
? `radial-gradient(circle, ${stops.join(", ")})`
: `linear-gradient(to bottom, ${stops.join(", ")})`;
core.mainCanvas.style.setProperty("background", css);
}
export function changeBackground(_type, _color1, _color2 = _color1, _alpha = 100) {
switch (_type) {
case "linear":
changeBackgroundHelper(_color1, _color1, _alpha);
break;
case "gradient":
case "radial":
changeBackgroundHelper(_color1, _color2, _alpha);
break;
}
}
function setupClippingPlanes(_geom, _distance) {
/*var _geometry;
if (_geom.isGroup)
_geometry = _geom.children;
else
_geometry = _geom.geometry.clone();*/
core.clippingPlanes[0].constant = _distance.x;
core.clippingPlanes[1].constant = _distance.y;
core.clippingPlanes[2].constant = _distance.z;
let clippingCenterY = 0;
if (_geom) {
const bounds = new THREE.Box3();
if (Array.isArray(_geom)) {
for (let i = 0; i < _geom.length; i++) {
bounds.union(new THREE.Box3().setFromObject(_geom[i], true));
}
} else {
bounds.setFromObject(_geom, true);
}
if (!bounds.isEmpty()) {
clippingCenterY = bounds.getCenter(new THREE.Vector3()).y;
}
}
const updatePlaneHelperPosition = (index, constantValue) => {
const helper = core.planeHelpers?.[index];
const plane = core.clippingPlanes?.[index];
if (!helper || !plane) return;
helper.position.copy(plane.normal).multiplyScalar(-constantValue);
};
if (core.EDITOR) {
if (core.transformControlClippingPlaneX && core.transformControlClippingPlaneY && core.transformControlClippingPlaneZ) {
core.scene.add(core.transformControlClippingPlaneX?.getHelper());
core.scene.add(core.transformControlClippingPlaneY?.getHelper());
core.scene.add(core.transformControlClippingPlaneZ?.getHelper());
}
let planeColor = new THREE.Color(0xffffff).getHexString();
if (core.scene.background != null) planeColor = core.scene.background.getHexString();
core.planeHelpers = core.clippingPlanes.map(
(p) => new THREE.PlaneHelper(p, core.gridSize * 2, invertHexColor(planeColor))
);
core.planeHelpers.forEach((ph, index) => {
ph.visible = false;
ph.name = "PlaneHelper";
updatePlaneHelperPosition(index, core.clippingPlanes[index].constant);
if (index === 0 || index === 2) {
ph.userData.clippingCenterY = clippingCenterY;
const baseUpdateMatrixWorld = ph.updateMatrixWorld.bind(ph);
ph.updateMatrixWorld = function updatePlaneHelperMatrix(force) {
baseUpdateMatrixWorld(force);
this.position.y = this.userData.clippingCenterY || 0;
this.updateMatrix();
if (this.parent) {
this.matrixWorld.multiplyMatrices(this.parent.matrixWorld, this.matrix);
} else {
this.matrixWorld.copy(this.matrix);
}
for (let i = 0; i < this.children.length; i++) {
this.children[i].updateMatrixWorld(true);
}
};
core.scene.add(ph);
} else {
core.scene.add(ph);
}
});
core.distanceGeometry = _distance;
scaleXYZ(core.distanceGeometry, 2);
const showClippingPlaneToast = (axisLabel, enabled) => {
toastHelper("clippingHelperToggle", "info", {
axis: axisLabel,
state: enabled
});
};
const refreshClippingHint = () => {
const update = core.updateClippingHintVisibility;
if (typeof update === "function") {
update();
return;
}
if (!core.clippingHint || !core.planeParams?.clippingMode) return;
const mode = core.planeParams.clippingMode;
core.clippingHint.hidden = !(mode.x || mode.y || mode.z);
};
const tr = (key, fallback) => window?.Viewer?.t?.(key, fallback) ?? fallback;
let displayHelper = {x: getOrAddGuiController(core.planeParams.planeX, "displayHelperX"), constantX: getOrAddGuiController(core.planeParams.planeX, "constantX"), y: getOrAddGuiController(core.planeParams.planeY, "displayHelperY"), constantY: getOrAddGuiController(core.planeParams.planeY, "constantY"), z: getOrAddGuiController(core.planeParams.planeZ, "displayHelperZ"), constantZ: getOrAddGuiController(core.planeParams.planeZ, "constantZ"), outline: getOrAddGuiController(core.planeParams.outline, "visible")};
displayHelper.x?.name?.(tr("gui.displayHelperX", "Show X helper"));
displayHelper.constantX?.name?.(tr("gui.constantX", "Constant X"));
displayHelper.y?.name?.(tr("gui.displayHelperY", "Show Y helper"));
displayHelper.constantY?.name?.(tr("gui.constantY", "Constant Y"));
displayHelper.z?.name?.(tr("gui.displayHelperZ", "Show Z helper"));
displayHelper.constantZ?.name?.(tr("gui.constantZ", "Constant Z"));
displayHelper.outline?.name?.(tr("gui.visible", "Visible"));
displayHelper.x?.onChange((v) => {
core.planeParams.clippingMode.x = core.planeHelpers[0].visible = v;
if (v) {
core.transformControlClippingPlaneX.attach(core.planeHelpers[0]);
if (core.planeParams.outline.visible) core.outlineClipping.visible = true;
} else {
core.transformControlClippingPlaneX.detach();
if (
!core.planeParams.clippingMode.y &&
!core.planeParams.clippingMode.z &&
!core.planeParams.outline.visible
)
core.outlineClipping.visible = false;
}
showClippingPlaneToast("X", v);
refreshClippingHint();
});
displayHelper?.constantX.min(-core.distanceGeometry.x)
.max(core.distanceGeometry.x)
.setValue(core.distanceGeometry.x)
.step(core.gridSize / 100)
.listen()
.onChange((d) => {
core.clippingPlanes[0].constant = d;
updatePlaneHelperPosition(0, d);
});
displayHelper.y?.onChange((v) => {
core.planeParams.clippingMode.y = core.planeHelpers[1].visible = v;
if (v) {
core.transformControlClippingPlaneY.attach(core.planeHelpers[1]);
if (core.planeParams.outline.visible) core.outlineClipping.visible = true;
} else {
core.transformControlClippingPlaneY.detach();
if (
!core.planeParams.clippingMode.x &&
!core.planeParams.clippingMode.z &&
!core.planeParams.outline.visible
)
core.outlineClipping.visible = false;
}
showClippingPlaneToast("Y", v);
refreshClippingHint();
});
displayHelper?.constantY
.min(-core.distanceGeometry.y)
.max(core.distanceGeometry.y)
.setValue(core.distanceGeometry.y)
.step(core.gridSize / 100)
.listen()
.onChange((d) => {
core.clippingPlanes[1].constant = d;
updatePlaneHelperPosition(1, d);
});
displayHelper.z?.onChange((v) => {
core.planeParams.clippingMode.z = core.planeHelpers[2].visible = v;
if (v) {
core.transformControlClippingPlaneZ.attach(core.planeHelpers[2]);
if (core.planeParams.outline.visible) core.outlineClipping.visible = true;
} else {
core.transformControlClippingPlaneZ.detach();
if (
!core.planeParams.clippingMode.x &&
!core.planeParams.clippingMode.y &&
!core.planeParams.outline.visible
)
core.outlineClipping.visible = false;
}
showClippingPlaneToast("Z", v);
refreshClippingHint();
});
displayHelper?.constantZ
.min(-core.distanceGeometry.z)
.max(core.distanceGeometry.z)
.setValue(core.distanceGeometry.z)
.step(core.gridSize / 100)
.listen()
.onChange((d) => {
core.clippingPlanes[2].constant = d;
updatePlaneHelperPosition(2, d);
});
displayHelper.outline.onChange((v) => {
core.outlineClipping.visible = v;
});
refreshClippingHint();
}
}
// Color helpers
export function invertHexColor(hexTripletColor) {
let color = hexTripletColor.substring(1);
color = parseInt(color, 16);
color = 0xffffff ^ color;
color = color.toString(16);
color = ("000000" + color).slice(-6);
return "#" + color;
}
export function getOrAddGuiController(object, prop) {
const findController = (folder) => {
if (!folder) return null;
const controller = folder.controllers?.find(c => c._name === prop || c.property === prop);
if (controller) return controller;
for (const subfolder of folder.folders || []) {
const found = findController(subfolder);
if (found) return found;
}
return null;
};
let controller = findController(core.clippingFolder);
if (controller) return controller;
return core.clippingFolder?.add(object, prop);
}