1116 lines
34 KiB
JavaScript
1116 lines
34 KiB
JavaScript
// 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.2–0.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);
|
||
}
|