Files

439 lines
14 KiB
TypeScript

import { drawMediaError } from "./imageProcessor.js";
import { t } from "../../app/i18n.js";
const MODEL3D_ANIMATION_SPEEDS = [0.25, 0.5, 1.0, 1.5, 2.0];
const MODEL3D_ANIM_BAR_HEIGHT_PX = 44;
export function stopModel3DEvent(event: Event, { preventDefault = false, immediate = false }: { preventDefault?: boolean; immediate?: boolean } = {}): void {
if (!event) return;
try {
if (preventDefault) event.preventDefault?.();
} catch (e: any) {
console.debug?.(e);
}
try {
event.stopPropagation?.();
if (immediate) event.stopImmediatePropagation?.();
} catch (e: any) {
console.debug?.(e);
}
}
export function formatModel3DAnimTime(totalSeconds: number): string {
const s = Math.max(0, Number(totalSeconds) || 0);
const m = Math.floor(s / 60);
const ss = Math.floor(s % 60);
return m > 0 ? `${m}:${String(ss).padStart(2, "0")}` : `${Math.floor(s)}s`;
}
export function createModel3DViewportButton(label: string, title: string): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.textContent = String(label || "");
button.title = String(title || label || "");
button.setAttribute("aria-label", String(title || label || t("model3d.viewportAction", "3D viewport action")));
button.style.cssText = [
"height:28px",
"padding:0 10px",
"border-radius:999px",
"border:1px solid rgba(255,255,255,0.16)",
"background:rgba(13,15,20,0.78)",
"color:rgba(255,255,255,0.92)",
"font:600 11px system-ui,sans-serif",
"letter-spacing:0.02em",
"cursor:pointer",
"backdrop-filter:blur(8px)",
"display:inline-flex",
"align-items:center",
"justify-content:center",
"box-shadow:0 4px 18px rgba(0,0,0,0.24)",
].join(";");
return button;
}
export function setModel3DViewportButtonActive(button: any, active: boolean): void {
if (!button) return;
const on = Boolean(active);
button.dataset.active = on ? "1" : "0";
button.style.background = on ? "rgba(76,175,80,0.18)" : "rgba(13,15,20,0.78)";
button.style.borderColor = on ? "rgba(76,175,80,0.42)" : "rgba(255,255,255,0.16)";
button.style.color = on ? "rgba(230,255,235,0.96)" : "rgba(255,255,255,0.92)";
}
export function drawModel3DMessage(canvas: HTMLCanvasElement, title: string, hint: string = "", accent: string = "#4CAF50"): void {
if (!canvas) return;
try {
const w = Math.max(480, Number(canvas.width) || 0 || 960);
const h = Math.max(270, Number(canvas.height) || 0 || 540);
canvas.width = w;
canvas.height = h;
} catch (e: any) {
console.debug?.(e);
}
const ctx = (() => {
try {
return canvas.getContext("2d");
} catch {
return null;
}
})();
if (!ctx) return;
const width = Number(canvas.width) || 960;
const height = Number(canvas.height) || 540;
const cx = width / 2;
const cy = height / 2;
try {
const bg = ctx.createLinearGradient(0, 0, width, height);
bg.addColorStop(0, "#0f1419");
bg.addColorStop(1, "#11181f");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, width, height);
ctx.save();
ctx.translate(cx, cy - 32);
ctx.strokeStyle = accent;
ctx.lineWidth = 3;
ctx.globalAlpha = 0.95;
ctx.beginPath();
ctx.moveTo(0, -44);
ctx.lineTo(42, -22);
ctx.lineTo(42, 22);
ctx.lineTo(0, 44);
ctx.lineTo(-42, 22);
ctx.lineTo(-42, -22);
ctx.closePath();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, -44);
ctx.lineTo(0, 0);
ctx.lineTo(42, 22);
ctx.moveTo(0, 0);
ctx.lineTo(-42, 22);
ctx.moveTo(-42, -22);
ctx.lineTo(0, 0);
ctx.moveTo(42, -22);
ctx.lineTo(0, 0);
ctx.stroke();
ctx.restore();
ctx.fillStyle = "rgba(255,255,255,0.94)";
ctx.font = "600 18px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(title || t("model3d.preview", "3D preview")), cx, cy + 44);
if (hint) {
ctx.fillStyle = "rgba(255,255,255,0.62)";
ctx.font = "13px system-ui, sans-serif";
ctx.fillText(String(hint), cx, cy + 72);
}
} catch (e: any) {
console.debug?.(e);
try {
drawMediaError(canvas, title, hint || t("model3d.unavailable", "3D viewer unavailable"));
} catch (err: any) {
console.debug?.(err);
}
}
}
export function createModel3DSettingsPanel({
defaultBgColor,
defaultFov,
defaultLightIntensity,
hasSkeleton,
}: Record<string, any> = {}): Record<string, any> {
const panel = document.createElement("div");
panel.className = "mjr-model3d-settings";
panel.style.cssText = [
"position:absolute",
"right:0",
"top:44px",
"width:210px",
"max-height:calc(100% - 100px)",
"overflow-y:auto",
"background:rgba(14,18,26,0.96)",
"border:1px solid rgba(255,255,255,0.10)",
"border-right:none",
"border-radius:10px 0 0 10px",
"padding:10px 10px 12px 10px",
"z-index:4",
"display:none",
"box-shadow:-6px 4px 24px rgba(0,0,0,0.5)",
"backdrop-filter:blur(12px)",
"font:13px system-ui,sans-serif",
"color:rgba(255,255,255,0.88)",
"scrollbar-width:thin",
].join(";");
[
"pointerdown",
"pointermove",
"pointerup",
"mousedown",
"mousemove",
"mouseup",
"wheel",
"click",
"contextmenu",
"dragstart",
"dragover",
"drop",
].forEach((evt: any) => {
panel.addEventListener(evt, (e: any) => stopModel3DEvent(e, { preventDefault: false }));
});
const addSectionHeader = (title: any) => {
const h = document.createElement("div");
h.style.cssText = [
"font:700 10px system-ui,sans-serif",
"letter-spacing:0.08em",
"color:rgba(255,255,255,0.40)",
"text-transform:uppercase",
"margin:10px 0 6px 0",
].join(";");
h.textContent = title;
panel.appendChild(h);
const sep = document.createElement("div");
sep.style.cssText = "height:1px;background:rgba(255,255,255,0.07);margin-bottom:8px;";
panel.appendChild(sep);
};
const addRow = (label: any, control: any) => {
const row = document.createElement("div");
row.style.cssText =
"display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;gap:6px;";
const lbl = document.createElement("span");
lbl.style.cssText =
"font:500 11px system-ui,sans-serif;color:rgba(255,255,255,0.65);white-space:nowrap;flex-shrink:0;";
lbl.textContent = label;
row.appendChild(lbl);
row.appendChild(control);
panel.appendChild(row);
return row;
};
const makeSelect = (opts: any, defaultVal: any) => {
const sel = document.createElement("select");
sel.style.cssText = [
"background:rgba(25,30,42,0.95)",
"color:rgba(255,255,255,0.88)",
"border:1px solid rgba(255,255,255,0.14)",
"border-radius:6px",
"padding:3px 5px",
"font:11px system-ui,sans-serif",
"cursor:pointer",
"flex:1",
"min-width:0",
].join(";");
for (const [val, lbl] of opts) {
const opt = document.createElement("option");
opt.value = val;
opt.textContent = lbl;
if (val === defaultVal) opt.selected = true;
sel.appendChild(opt);
}
return sel;
};
const makeSlider = (min: any, max: any, step: any, defaultVal: any) => {
const wrap = document.createElement("div");
wrap.style.cssText = "display:flex;align-items:center;gap:5px;flex:1;";
const slider = document.createElement("input");
slider.type = "range";
slider.min = String(min);
slider.max = String(max);
slider.step = String(step);
slider.value = String(defaultVal);
slider.style.cssText = "flex:1;cursor:pointer;accent-color:#4CAF50;height:3px;";
const valLbl = document.createElement("span");
valLbl.style.cssText =
"font:11px system-ui,sans-serif;color:rgba(255,255,255,0.45);min-width:26px;text-align:right;";
valLbl.textContent = String(defaultVal);
slider.addEventListener("input", () => {
valLbl.textContent = slider.value;
});
wrap.appendChild(slider);
wrap.appendChild(valLbl);
return { wrap, slider, valLbl };
};
addSectionHeader("Scene");
const bgInput = document.createElement("input");
bgInput.type = "color";
bgInput.value = defaultBgColor || "#282828";
bgInput.style.cssText =
"width:34px;height:24px;cursor:pointer;border:1px solid rgba(255,255,255,0.18);border-radius:4px;padding:1px;background:none;flex-shrink:0;";
addRow(t("model3d.background", "Background"), bgInput);
addSectionHeader("Model");
const materialSel = makeSelect(
[
["original", "Original"],
["normal", "Normal map"],
["depth", "Depth"],
["wireframe", "Wireframe"],
["pointcloud", "Point cloud"],
],
"original",
);
addRow(t("model3d.material", "Material"), materialSel);
const upSel = makeSelect(
[
["original", "Original"],
["+y", "+Y (default)"],
["-y", "-Y"],
["+z", "+Z (CAD)"],
["-z", "-Z"],
["+x", "+X"],
["-x", "-X"],
],
"original",
);
addRow("Up", upSel);
let skeletonRow: any = null;
let skeletonToggle: any = null;
if (hasSkeleton) {
skeletonToggle = document.createElement("input");
skeletonToggle.type = "checkbox";
skeletonToggle.checked = false;
skeletonToggle.style.cssText = "cursor:pointer;accent-color:#4CAF50;flex-shrink:0;";
skeletonRow = addRow(t("model3d.skeleton", "Skeleton"), skeletonToggle);
}
addSectionHeader("Camera");
const fovData = makeSlider(15, 120, 1, defaultFov ?? 75);
addRow("FOV", fovData.wrap);
addSectionHeader(t("model3d.lights", "Lights"));
const lightData = makeSlider(0, 5, 0.1, defaultLightIntensity ?? 1.0);
addRow(t("model3d.intensity", "Intensity"), lightData.wrap);
return {
panel,
bgInput,
materialSel,
upSel,
skeletonToggle,
skeletonRow,
fovSlider: fovData.slider,
fovValLbl: fovData.valLbl,
lightSlider: lightData.slider,
lightValLbl: lightData.valLbl,
};
}
export function createModel3DAnimationBar() {
const bar = document.createElement("div");
bar.className = "mjr-model3d-anim-bar";
bar.style.cssText = [
"position:absolute",
"bottom:0",
"left:0",
"right:0",
`height:${MODEL3D_ANIM_BAR_HEIGHT_PX}px`,
"display:none",
"align-items:center",
"gap:7px",
"padding:0 10px",
"background:rgba(10,13,20,0.90)",
"backdrop-filter:blur(10px)",
"border-top:1px solid rgba(255,255,255,0.07)",
"z-index:3",
].join(";");
[
"pointerdown",
"pointermove",
"pointerup",
"mousedown",
"mousemove",
"mouseup",
"wheel",
"click",
].forEach((evt: any) => {
bar.addEventListener(evt, (e: any) => stopModel3DEvent(e, { preventDefault: false }));
});
const playBtn = document.createElement("button");
playBtn.type = "button";
playBtn.textContent = t("model3d.play", "Play");
playBtn.title = t("model3d.playPause", "Play / Pause");
playBtn.style.cssText = [
"width:28px",
"height:28px",
"border-radius:50%",
"border:1px solid rgba(255,255,255,0.18)",
"background:rgba(76,175,80,0.14)",
"color:rgba(255,255,255,0.92)",
"font:600 12px system-ui,sans-serif",
"cursor:pointer",
"flex-shrink:0",
"padding:0",
"line-height:1",
"display:flex",
"align-items:center",
"justify-content:center",
].join(";");
const speedSel = document.createElement("select");
speedSel.title = t("model3d.playbackSpeed", "Playback speed");
speedSel.style.cssText = [
"background:rgba(20,24,34,0.9)",
"color:rgba(255,255,255,0.8)",
"border:1px solid rgba(255,255,255,0.14)",
"border-radius:6px",
"padding:3px 4px",
"font:11px system-ui,sans-serif",
"cursor:pointer",
"width:52px",
"flex-shrink:0",
].join(";");
for (const spd of MODEL3D_ANIMATION_SPEEDS) {
const opt = document.createElement("option");
opt.value = String(spd);
opt.textContent = `${spd}x`;
if (spd === 1.0) opt.selected = true;
speedSel.appendChild(opt);
}
const animSel = document.createElement("select");
animSel.title = "Animation clip";
animSel.style.cssText = [
"background:rgba(20,24,34,0.9)",
"color:rgba(255,255,255,0.8)",
"border:1px solid rgba(255,255,255,0.14)",
"border-radius:6px",
"padding:3px 5px",
"font:11px system-ui,sans-serif",
"cursor:pointer",
"max-width:130px",
"flex-shrink:1",
"min-width:0",
].join(";");
const progressSlider = document.createElement("input");
progressSlider.type = "range";
progressSlider.min = "0";
progressSlider.max = "1000";
progressSlider.value = "0";
progressSlider.style.cssText =
"flex:1;min-width:50px;cursor:pointer;accent-color:#4CAF50;height:3px;";
const timeLbl = document.createElement("span");
timeLbl.style.cssText = [
"font:500 11px system-ui,sans-serif",
"color:rgba(255,255,255,0.45)",
"white-space:nowrap",
"flex-shrink:0",
].join(";");
timeLbl.textContent = "0s / 0s";
bar.appendChild(playBtn);
bar.appendChild(speedSel);
bar.appendChild(animSel);
bar.appendChild(progressSlider);
bar.appendChild(timeLbl);
return { bar, playBtn, speedSel, animSel, progressSlider, timeLbl };
}