439 lines
14 KiB
TypeScript
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 };
|
|
}
|