folder rename
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>3D Part Viewer</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
html,body{width:100%;height:100%;overflow:hidden;font-family:system-ui,-apple-system,sans-serif;background:#f0f2f5}
|
||||
#viewer-container{width:100%;height:100%}
|
||||
#loading{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:#6c757d;z-index:100}
|
||||
#loading .spinner{width:44px;height:44px;border:3px solid #dee2e6;border-top-color:#0d6efd;border-radius:50%;animation:spin .8s linear infinite;margin:0 auto 12px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
#error{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff3cd;border:1px solid #ffc107;border-radius:8px;padding:20px 28px;color:#664d03;max-width:80%;text-align:center;font-size:13px;z-index:100;display:none}
|
||||
#format-badge{position:absolute;top:10px;right:10px;font-size:11px;font-weight:600;padding:4px 10px;border-radius:4px;z-index:100;backdrop-filter:blur(4px)}
|
||||
.fmt-step{background:rgba(33,150,243,.15);color:#1565c0}
|
||||
.fmt-iges{background:rgba(156,39,176,.15);color:#7b1fa2}
|
||||
.fmt-stl{background:rgba(76,175,80,.15);color:#2e7d32}
|
||||
.fmt-brep{background:rgba(255,152,0,.15);color:#e65100}
|
||||
.fmt-other{background:rgba(158,158,158,.15);color:#616161}
|
||||
#toolbar{position:absolute;top:10px;left:10px;display:flex;gap:4px;z-index:100;flex-wrap:wrap;background:rgba(255,255,255,.85);padding:4px;border-radius:6px;backdrop-filter:blur(4px);box-shadow:0 1px 3px rgba(0,0,0,.1)}
|
||||
#toolbar button{background:#fff;border:1px solid #ced4da;border-radius:4px;padding:4px 8px;font-size:11px;font-weight:500;cursor:pointer;color:#495057;transition:all .15s;min-width:40px}
|
||||
#toolbar button:hover{background:#0d6efd;color:#fff;border-color:#0d6efd}
|
||||
#toolbar .btn-divider{width:1px;background:#dee2e6;margin:2px 4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="viewer-container"></div>
|
||||
<div id="toolbar">
|
||||
<button onclick="setView('top')" title="Top view">Top</button>
|
||||
<button onclick="setView('bottom')" title="Bottom view">Btm</button>
|
||||
<button onclick="setView('front')" title="Front view">Front</button>
|
||||
<button onclick="setView('back')" title="Back view">Back</button>
|
||||
<button onclick="setView('left')" title="Left view">Left</button>
|
||||
<button onclick="setView('right')" title="Right view">Right</button>
|
||||
<button onclick="setView('iso')" title="Isometric view">Iso</button>
|
||||
<span class="btn-divider"></span>
|
||||
<button onclick="fitToView()" title="Fit to view">Fit</button>
|
||||
<button onclick="takeScreenshot()" title="Take screenshot (PNG)">📷</button>
|
||||
</div>
|
||||
<div id="format-badge"></div>
|
||||
<div id="loading"><div class="spinner"></div><div id="loading-msg">Loading 3D model...</div></div>
|
||||
<div id="error"></div>
|
||||
|
||||
<script src="/fusion_plating_configurator/static/lib/o3dv/o3dv.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const container = document.getElementById('viewer-container');
|
||||
const loadingEl = document.getElementById('loading');
|
||||
const loadingMsg = document.getElementById('loading-msg');
|
||||
const errorEl = document.getElementById('error');
|
||||
const fmtBadge = document.getElementById('format-badge');
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const attachmentId = params.get('id');
|
||||
const fileName = params.get('name') || 'model.stl';
|
||||
|
||||
function detectFormat(name) {
|
||||
if (!name) return 'other';
|
||||
const n = name.toLowerCase();
|
||||
if (n.match(/\.(step|stp)$/)) return 'step';
|
||||
if (n.match(/\.(iges|igs)$/)) return 'iges';
|
||||
if (n.match(/\.(brep|brp)$/)) return 'brep';
|
||||
if (n.match(/\.stl$/)) return 'stl';
|
||||
if (n.match(/\.(obj)$/)) return 'other';
|
||||
if (n.match(/\.(gltf|glb)$/)) return 'other';
|
||||
if (n.match(/\.(3ds|fbx|dae|3mf|ply|off|wrl|3dm)$/)) return 'other';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function showFormat(fmt) {
|
||||
fmtBadge.className = 'fmt-' + fmt;
|
||||
fmtBadge.textContent = fmt.toUpperCase();
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
loadingEl.style.display = 'none';
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
if (!attachmentId) {
|
||||
showError('No model specified (missing ?id= parameter)');
|
||||
return;
|
||||
}
|
||||
|
||||
showFormat(detectFormat(fileName));
|
||||
|
||||
// Initialize the embedded viewer
|
||||
// Note: v0.18.0 loads WASM (occt-import-js) from CDN automatically
|
||||
const viewer = new OV.EmbeddedViewer(container, {
|
||||
backgroundColor: new OV.RGBAColor(240, 242, 245, 255),
|
||||
defaultColor: new OV.RGBColor(33, 150, 243),
|
||||
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
|
||||
});
|
||||
|
||||
// Fetch the file ourselves (with session credentials) then load as blob
|
||||
loadingMsg.textContent = 'Downloading ' + fileName + '...';
|
||||
const modelUrl = '/fp/3d-model/' + attachmentId + '/' + encodeURIComponent(fileName);
|
||||
|
||||
fetch(modelUrl, { credentials: 'same-origin' })
|
||||
.then(function(resp) {
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status + ': ' + resp.statusText);
|
||||
return resp.arrayBuffer();
|
||||
})
|
||||
.then(function(buffer) {
|
||||
loadingMsg.textContent = 'Parsing ' + fileName + '...';
|
||||
// Create a File object so O3DV can detect format from the name
|
||||
var file = new File([buffer], fileName, { type: 'application/octet-stream' });
|
||||
viewer.LoadModelFromFileList([file]);
|
||||
|
||||
// Poll for completion
|
||||
var checkCount = 0;
|
||||
var checkInterval = setInterval(function() {
|
||||
checkCount++;
|
||||
try {
|
||||
var model = viewer.GetModel();
|
||||
if (model && model.MeshCount() > 0) {
|
||||
loadingEl.style.display = 'none';
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
} catch(e) {}
|
||||
if (checkCount > 600) {
|
||||
clearInterval(checkInterval);
|
||||
if (loadingEl.style.display !== 'none') {
|
||||
showError('Timeout parsing model. STEP files may take a minute on first load (WASM engine init).');
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
})
|
||||
.catch(function(err) {
|
||||
showError('Failed to load model: ' + err.message);
|
||||
});
|
||||
|
||||
// ---- View preset functions (Top/Front/Side/Iso) ----
|
||||
// Online3DViewer's internal viewer exposes a Camera object we can manipulate.
|
||||
window.setView = function(view) {
|
||||
try {
|
||||
const v = viewer.GetViewer();
|
||||
if (!v) return;
|
||||
const camera = v.GetCamera();
|
||||
if (!camera) return;
|
||||
// Compute distance from current camera to keep zoom roughly consistent
|
||||
const eye = camera.eye;
|
||||
const center = camera.center;
|
||||
const dx = eye.x - center.x, dy = eye.y - center.y, dz = eye.z - center.z;
|
||||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) || 100;
|
||||
let newEye, newUp;
|
||||
switch (view) {
|
||||
case 'top':
|
||||
newEye = new OV.Coord3D(center.x, center.y, center.z + dist);
|
||||
newUp = new OV.Coord3D(0, 1, 0);
|
||||
break;
|
||||
case 'bottom':
|
||||
newEye = new OV.Coord3D(center.x, center.y, center.z - dist);
|
||||
newUp = new OV.Coord3D(0, 1, 0);
|
||||
break;
|
||||
case 'front':
|
||||
newEye = new OV.Coord3D(center.x, center.y - dist, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'back':
|
||||
newEye = new OV.Coord3D(center.x, center.y + dist, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'left':
|
||||
newEye = new OV.Coord3D(center.x - dist, center.y, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'right':
|
||||
newEye = new OV.Coord3D(center.x + dist, center.y, center.z);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
case 'iso':
|
||||
default:
|
||||
const d = dist / Math.sqrt(3);
|
||||
newEye = new OV.Coord3D(center.x + d, center.y - d, center.z + d);
|
||||
newUp = new OV.Coord3D(0, 0, 1);
|
||||
break;
|
||||
}
|
||||
const newCam = new OV.Camera(newEye, center, newUp, camera.fov);
|
||||
v.SetCamera(newCam);
|
||||
v.Render();
|
||||
} catch(e) {
|
||||
console.warn('setView failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
window.fitToView = function() {
|
||||
try {
|
||||
const v = viewer.GetViewer();
|
||||
if (v && v.FitSphereToWindow) {
|
||||
// FitSphereToWindow uses the model's bounding sphere
|
||||
v.FitSphereToWindow(v.GetBoundingSphere(() => true), false);
|
||||
v.Render();
|
||||
}
|
||||
} catch(e) {
|
||||
console.warn('fitToView failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
window.takeScreenshot = function() {
|
||||
try {
|
||||
const v = viewer.GetViewer();
|
||||
if (!v) return;
|
||||
// Get the renderer's canvas and convert to PNG
|
||||
const canvas = v.GetCanvas ? v.GetCanvas() : null;
|
||||
if (!canvas) {
|
||||
// Fallback: find canvas inside container
|
||||
const c = container.querySelector('canvas');
|
||||
if (!c) return;
|
||||
downloadCanvas(c);
|
||||
return;
|
||||
}
|
||||
downloadCanvas(canvas);
|
||||
} catch(e) {
|
||||
console.warn('screenshot failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
function downloadCanvas(canvas) {
|
||||
canvas.toBlob(function(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
a.download = (fileName.replace(/\.[^.]+$/, '') || 'model') + '-' + stamp + '.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 'image/png');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,118 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Plating -- 3D CAD Viewer (iframe wrapper)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Simple OWL field widget that embeds the standalone 3D viewer page
|
||||
// in an iframe. The viewer page uses Online3DViewer (o3dv) which
|
||||
// supports STEP, IGES, BREP, STL, OBJ, glTF, and 20+ more formats.
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
|
||||
export class Fp3dViewer extends Component {
|
||||
static template = "fusion_plating_configurator.Fp3dViewer";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.state = useState({ hasAttachment: false, iframeSrc: "" });
|
||||
this._updateState();
|
||||
}
|
||||
|
||||
get rawValue() {
|
||||
return this.props.record.data[this.props.name];
|
||||
}
|
||||
|
||||
get attachmentId() {
|
||||
const v = this.rawValue;
|
||||
if (!v) return 0;
|
||||
if (Array.isArray(v)) return v[0] || 0;
|
||||
if (typeof v === "object" && v.id) return v.id;
|
||||
return typeof v === "number" ? v : 0;
|
||||
}
|
||||
|
||||
get attachmentName() {
|
||||
const v = this.rawValue;
|
||||
if (!v) return "";
|
||||
if (Array.isArray(v)) return v[1] || "";
|
||||
if (typeof v === "object" && v.display_name) return v.display_name;
|
||||
return "";
|
||||
}
|
||||
|
||||
_updateState() {
|
||||
const aid = this.attachmentId;
|
||||
this.state.hasAttachment = !!aid;
|
||||
if (aid) {
|
||||
const name = encodeURIComponent(this.attachmentName);
|
||||
this.state.iframeSrc = `/fp/3d-viewer?id=${aid}&name=${name}`;
|
||||
}
|
||||
}
|
||||
|
||||
onPatched() {
|
||||
this._updateState();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("fp_3d_preview", {
|
||||
component: Fp3dViewer,
|
||||
supportedTypes: ["many2one"],
|
||||
});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// 3D Viewer Dialog component (full-screen embedded viewer)
|
||||
// =============================================================================
|
||||
export class Fp3dViewerDialog extends Component {
|
||||
static template = "fusion_plating_configurator.Fp3dViewerDialog";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
attachmentId: Number,
|
||||
name: { type: String, optional: true },
|
||||
close: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({ isMaximized: true });
|
||||
}
|
||||
|
||||
get iframeSrc() {
|
||||
const name = encodeURIComponent(this.props.name || "");
|
||||
return `/fp/3d-viewer?id=${this.props.attachmentId}&name=${name}`;
|
||||
}
|
||||
|
||||
get dialogSize() {
|
||||
return this.state.isMaximized ? "fullscreen" : "xl";
|
||||
}
|
||||
|
||||
get frameStyle() {
|
||||
if (this.state.isMaximized) {
|
||||
return "height: calc(98vh - 100px) !important;";
|
||||
}
|
||||
return "height: calc(85vh - 100px) !important;";
|
||||
}
|
||||
|
||||
toggleSize() {
|
||||
this.state.isMaximized = !this.state.isMaximized;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("dialog").add("Fp3dViewerDialog", Fp3dViewerDialog);
|
||||
|
||||
|
||||
// Client action handler — opens the 3D viewer in a dialog within the same window.
|
||||
// Triggered by Python returning:
|
||||
// { type: 'ir.actions.client', tag: 'fp_3d_viewer_open',
|
||||
// params: { attachment_id: N, name: "..." } }
|
||||
function fp3dViewerOpenAction(env, action) {
|
||||
const params = action.params || {};
|
||||
if (!params.attachment_id) return Promise.resolve();
|
||||
env.services.dialog.add(Fp3dViewerDialog, {
|
||||
attachmentId: params.attachment_id,
|
||||
name: params.name || "",
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_3d_viewer_open", fp3dViewerOpenAction);
|
||||
@@ -0,0 +1,81 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Plating -- PDF Drawing Preview Widget
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1
|
||||
//
|
||||
// Custom many2many_binary widget that opens PDFs in the fusion_pdf_preview
|
||||
// dialog instead of downloading them.
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import {
|
||||
Many2ManyBinaryField,
|
||||
many2ManyBinaryField,
|
||||
} from "@web/views/fields/many2many_binary/many2many_binary_field";
|
||||
|
||||
export class FpPdfPreviewBinary extends Many2ManyBinaryField {
|
||||
static template = "fusion_plating_configurator.FpPdfPreviewBinary";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dialogService = useService("dialog");
|
||||
}
|
||||
|
||||
onFileClick(ev, file) {
|
||||
const isPdf = (file.mimetype === "application/pdf") ||
|
||||
(file.name || "").toLowerCase().endsWith(".pdf");
|
||||
const dialogs = registry.category("dialog");
|
||||
|
||||
if (isPdf && dialogs.contains("PDFViewerDialog")) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const PDFViewerDialog = dialogs.get("PDFViewerDialog");
|
||||
const url = `/web/content/${file.id}?download=false`;
|
||||
this.dialogService.add(PDFViewerDialog, {
|
||||
url: url,
|
||||
title: file.name || "Drawing",
|
||||
reportName: "",
|
||||
recordIds: "",
|
||||
modelName: "ir.attachment",
|
||||
});
|
||||
}
|
||||
// For non-PDF or when preview not available, default browser behavior
|
||||
// (the <a href> with download attribute) kicks in because we don't
|
||||
// prevent default.
|
||||
}
|
||||
}
|
||||
|
||||
export const fpPdfPreviewBinary = {
|
||||
...many2ManyBinaryField,
|
||||
component: FpPdfPreviewBinary,
|
||||
};
|
||||
|
||||
registry.category("fields").add("fp_pdf_preview_binary", fpPdfPreviewBinary);
|
||||
|
||||
|
||||
// Client action handler: open a PDF attachment in the fusion_pdf_preview dialog.
|
||||
// Triggered by Python methods returning:
|
||||
// { type: 'ir.actions.client', tag: 'fp_pdf_preview_open',
|
||||
// params: { attachment_id: N, title: "..." } }
|
||||
function fpPdfPreviewOpenAction(env, action) {
|
||||
const params = action.params || {};
|
||||
const attId = params.attachment_id;
|
||||
if (!attId) return Promise.resolve();
|
||||
const dialogs = registry.category("dialog");
|
||||
const PDFViewerDialog = dialogs.contains("PDFViewerDialog") ? dialogs.get("PDFViewerDialog") : null;
|
||||
if (!PDFViewerDialog) {
|
||||
window.open(`/web/content/${attId}?download=false`, '_blank');
|
||||
return Promise.resolve();
|
||||
}
|
||||
const url = `/web/content/${attId}?download=false`;
|
||||
env.services.dialog.add(PDFViewerDialog, {
|
||||
url: url,
|
||||
title: params.title || 'Document',
|
||||
reportName: '',
|
||||
recordIds: '',
|
||||
modelName: 'ir.attachment',
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_pdf_preview_open", fpPdfPreviewOpenAction);
|
||||
@@ -0,0 +1,80 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Plating -- Inline PDF Preview field widget
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1
|
||||
//
|
||||
// Field widget for Many2one(ir.attachment) fields that embeds the
|
||||
// PDF.js viewer inline at a fixed height (one page at a time).
|
||||
// A "Full Screen" button below opens the fusion_pdf_preview dialog.
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
|
||||
export class FpPdfInlinePreview extends Component {
|
||||
static template = "fusion_plating_configurator.FpPdfInlinePreview";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.dialogService = useService("dialog");
|
||||
this.state = useState({ hasAttachment: false, iframeSrc: "", attId: 0, name: "" });
|
||||
this._updateState();
|
||||
}
|
||||
|
||||
get rawValue() {
|
||||
return this.props.record.data[this.props.name];
|
||||
}
|
||||
|
||||
_updateState() {
|
||||
const v = this.rawValue;
|
||||
let attId = 0;
|
||||
let name = "";
|
||||
if (v) {
|
||||
if (Array.isArray(v)) {
|
||||
attId = v[0] || 0;
|
||||
name = v[1] || "";
|
||||
} else if (typeof v === "object" && v.id) {
|
||||
attId = v.id;
|
||||
name = v.display_name || "";
|
||||
} else if (typeof v === "number") {
|
||||
attId = v;
|
||||
}
|
||||
}
|
||||
this.state.hasAttachment = !!attId;
|
||||
this.state.attId = attId;
|
||||
this.state.name = name;
|
||||
if (attId) {
|
||||
const fileUrl = `/web/content/${attId}?download=false`;
|
||||
// PDF.js URL params: zoom=page-fit, no thumbs sidebar, single-page mode
|
||||
this.state.iframeSrc =
|
||||
`/fusion_pdf_preview/static/lib/pdfjs/web/viewer.html` +
|
||||
`?file=${encodeURIComponent(fileUrl)}` +
|
||||
`#zoom=page-fit&pagemode=none&scrollmode=3`;
|
||||
}
|
||||
}
|
||||
|
||||
onPatched() {
|
||||
this._updateState();
|
||||
}
|
||||
|
||||
openFullScreen() {
|
||||
if (!this.state.attId) return;
|
||||
const dialogs = registry.category("dialog");
|
||||
if (!dialogs.contains("PDFViewerDialog")) return;
|
||||
const PDFViewerDialog = dialogs.get("PDFViewerDialog");
|
||||
const url = `/web/content/${this.state.attId}?download=false`;
|
||||
this.dialogService.add(PDFViewerDialog, {
|
||||
url: url,
|
||||
title: this.state.name || "Drawing",
|
||||
reportName: "",
|
||||
recordIds: "",
|
||||
modelName: "ir.attachment",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("fp_pdf_inline_preview", {
|
||||
component: FpPdfInlinePreview,
|
||||
supportedTypes: ["many2one"],
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating -- 3D Viewer + Configurator Layout
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
// =============================================================================
|
||||
|
||||
// -- Configurator two-column layout: 3/4 fields + 1/4 preview --
|
||||
// When the preview column is hidden (no 3D model AND no drawings), the
|
||||
// fields column expands to full width via the :has() selector below.
|
||||
.o_fp_cfg_layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
// Full width when right column has no visible content
|
||||
.o_fp_cfg_layout:has(> .o_fp_cfg_preview.o_invisible_modifier),
|
||||
.o_fp_cfg_layout:has(> .o_fp_cfg_preview[style*="display: none"]),
|
||||
.o_fp_cfg_layout:has(> .o_fp_cfg_preview[style*="display:none"]) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.o_fp_cfg_fields {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.o_fp_cfg_preview {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
|
||||
// Force all field widgets (3D viewer, Html drawing preview) to be
|
||||
// block-level + full width so the 3D and PDF iframes match exactly.
|
||||
.o_field_widget,
|
||||
> div > .o_field_widget {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive: stack on narrow screens
|
||||
@media (max-width: 1200px) {
|
||||
.o_fp_cfg_layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.o_fp_cfg_preview {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
// -- 3D viewer widget --
|
||||
.o_fp_3d_viewer_root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_3d_placeholder {
|
||||
border: 2px dashed $border-color;
|
||||
border-radius: 0.5rem;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bs-secondary-color);
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
|
||||
.o_fp_3d_iframe {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #f0f2f5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// Inside the preview column: same height as the PDF preview iframe
|
||||
.o_fp_cfg_preview .o_fp_3d_iframe {
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
// -- 3D Viewer Dialog (full-screen modal) --
|
||||
.o_fp_3d_dialog {
|
||||
.modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_3d_dialog_body {
|
||||
width: 100%;
|
||||
background-color: #f0f2f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o_fp_3d_dialog_iframe {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.o_fp_3d_dialog_actions {
|
||||
padding: 8px 12px;
|
||||
text-align: right;
|
||||
border-top: 1px solid var(--bs-border-color, #dee2e6);
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
// -- Inline PDF preview widget (fp_pdf_inline_preview) --
|
||||
.o_fp_pdf_inline_root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_pdf_inline_frame_wrap {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.o_fp_pdf_inline_iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.o_fp_pdf_inline_placeholder {
|
||||
border: 2px dashed $border-color;
|
||||
border-radius: 0.5rem;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_configurator.Fp3dViewer">
|
||||
<div class="o_fp_3d_viewer_root">
|
||||
<t t-if="!state.hasAttachment">
|
||||
<div class="o_fp_3d_placeholder text-center text-muted p-4">
|
||||
<i class="fa fa-cube fa-3x mb-2 d-block"/>
|
||||
<span>Upload a 3D model (STL, STEP, IGES) to preview it here.</span>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="state.hasAttachment">
|
||||
<iframe t-att-src="state.iframeSrc"
|
||||
class="o_fp_3d_iframe"
|
||||
frameborder="0"
|
||||
allowfullscreen="true"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_plating_configurator.Fp3dViewerDialog">
|
||||
<Dialog title.translate="3D Model Viewer"
|
||||
size="dialogSize"
|
||||
contentClass="'o_fp_3d_dialog'"
|
||||
footer="false">
|
||||
<div class="o_fp_3d_dialog_body">
|
||||
<iframe t-att-src="iframeSrc"
|
||||
t-att-style="frameStyle"
|
||||
class="o_fp_3d_dialog_iframe"
|
||||
frameborder="0"
|
||||
allowfullscreen="true"/>
|
||||
</div>
|
||||
<div class="o_fp_3d_dialog_actions">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
t-on-click="toggleSize">
|
||||
<i t-att-class="state.isMaximized ? 'fa fa-compress me-1' : 'fa fa-expand me-1'"/>
|
||||
<t t-if="state.isMaximized">Restore</t>
|
||||
<t t-else="">Maximize</t>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-secondary ms-2"
|
||||
t-on-click="props.close">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_configurator.FpPdfPreviewBinary">
|
||||
<div t-attf-class="oe_fileupload {{props.className ? props.className : ''}}" aria-atomic="true">
|
||||
<div class="o_attachments">
|
||||
<t t-foreach="files" t-as="file" t-key="file_index">
|
||||
<t t-set="editable" t-value="!props.readonly"/>
|
||||
<t t-set="ext" t-value="getExtension(file)"/>
|
||||
<t t-set="url" t-value="getUrl(file.id)"/>
|
||||
<t t-set="isPdf" t-value="(file.mimetype === 'application/pdf') or (file.name and file.name.toLowerCase().endsWith('.pdf'))"/>
|
||||
<div t-attf-class="o_attachment o_attachment_many2many #{ editable ? 'o_attachment_editable' : '' }"
|
||||
t-att-title="file.name">
|
||||
<div class="o_attachment_wrap">
|
||||
<div class="o_image_box float-start"
|
||||
t-att-data-tooltip="isPdf ? 'Preview ' + file.name : 'Download ' + file.name">
|
||||
<a t-att-href="url"
|
||||
t-on-click="(ev) => this.onFileClick(ev, file)"
|
||||
t-att-download="isPdf ? undefined : ''"
|
||||
aria-label="Open">
|
||||
<img t-if="isImage(file)"
|
||||
class="o_preview_image o_hover object-fit-cover rounded align-baseline"
|
||||
t-attf-src="/web/image/{{ file.id }}"
|
||||
onerror="this.src = '/web/static/img/mimetypes/image.svg'"/>
|
||||
<span t-else="" class="o_image o_preview_image o_hover"
|
||||
t-att-data-mimetype="file.mimetype"
|
||||
t-att-data-ext="ext" role="img"/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="caption">
|
||||
<a class="ml4"
|
||||
t-att-data-tooltip="isPdf ? 'Preview ' + file.name : 'Download ' + file.name"
|
||||
t-att-href="url"
|
||||
t-on-click="(ev) => this.onFileClick(ev, file)"
|
||||
t-att-download="isPdf ? undefined : ''"><t t-esc="file.name"/></a>
|
||||
</div>
|
||||
<div class="caption small">
|
||||
<a class="ml4 small text-uppercase"
|
||||
t-att-href="url"
|
||||
t-on-click="(ev) => this.onFileClick(ev, file)"
|
||||
t-att-download="isPdf ? undefined : ''">
|
||||
<b><t t-esc="ext"/></b>
|
||||
</a>
|
||||
</div>
|
||||
<div class="o_attachment_uploaded">
|
||||
<i class="text-success fa fa-check" role="img"
|
||||
aria-label="Uploaded" title="Uploaded"/>
|
||||
</div>
|
||||
<div t-if="editable" class="o_attachment_delete"
|
||||
t-on-click.stop="() => this.onFileRemove(file.id)">
|
||||
<span role="img" aria-label="Delete" title="Delete">×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="!props.readonly and (!props.numberOfFiles or files.length < props.numberOfFiles)"
|
||||
class="oe_add">
|
||||
<FileInput acceptedFileExtensions="props.acceptedFileExtensions"
|
||||
multiUpload="true"
|
||||
onUpload.bind="onFileUploaded"
|
||||
resModel="props.record.resModel"
|
||||
resId="props.record.resId or 0">
|
||||
<button class="btn btn-secondary o_attach" data-tooltip="Attach">
|
||||
<span class="fa fa-paperclip" aria-label="Attach"/>
|
||||
<t t-esc="uploadText"/>
|
||||
</button>
|
||||
</FileInput>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_configurator.FpPdfInlinePreview">
|
||||
<div class="o_fp_pdf_inline_root">
|
||||
<t t-if="state.hasAttachment">
|
||||
<div class="o_fp_pdf_inline_frame_wrap">
|
||||
<iframe t-att-src="state.iframeSrc"
|
||||
class="o_fp_pdf_inline_iframe"
|
||||
frameborder="0"
|
||||
title="PDF Preview"/>
|
||||
</div>
|
||||
<div class="text-center mt-2">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
t-on-click="openFullScreen">
|
||||
<i class="fa fa-expand me-1"/>Full Screen
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="!state.hasAttachment">
|
||||
<div class="o_fp_pdf_inline_placeholder text-center text-muted p-4">
|
||||
<i class="fa fa-file-pdf-o fa-3x mb-2 d-block"/>
|
||||
<span>No PDF attached.</span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user