From 172b6395e92d0f634011a81419bb5d213675cd1e Mon Sep 17 00:00:00 2001 From: Ishwarya Date: Wed, 18 Mar 2026 14:35:21 +0530 Subject: [PATCH 1/4] Add preview for 3d models --- .pre-commit-config.yaml | 2 +- cloud_storage/cloud_storage/overrides/file.py | 51 ++++ .../public/js/components/FilePreview.vue | 275 ++++++++---------- cloud_storage/public/js/file.js | 247 ++++++++++++++-- cloud_storage/public/js/overrides.js | 249 ++++++++-------- 5 files changed, 520 insertions(+), 304 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9db6eef..f5a16bd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.19.0 + rev: v1.20.3 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/cloud_storage/cloud_storage/overrides/file.py b/cloud_storage/cloud_storage/overrides/file.py index 065e6ad..9892b87 100644 --- a/cloud_storage/cloud_storage/overrides/file.py +++ b/cloud_storage/cloud_storage/overrides/file.py @@ -751,3 +751,54 @@ def add_child_file_association(attached_to_doctype, attached_to_name): "user": frappe.session.user, "timestamp": get_datetime(), } + + +@frappe.whitelist() +def proxy_file(key: str): + """ + Fetch file from S3 server-side and stream back to browser. + Used for 3D preview to avoid CORS issues with direct S3 URLs. + """ + import requests + + if not key: + frappe.throw(_("Key not found")) + + # Check file permissions + file = frappe.get_value("File", {"s3_key": key}, ["name", "is_private"], as_dict=True) + if not file: + frappe.throw(_("File not found")) + + if file.is_private: + file_doc = frappe.get_doc("File", file.name) + frappe.has_permission( + doctype="File", ptype="read", doc=file_doc, user=frappe.session.user, throw=True + ) + + # Get presigned URL and fetch content server-side + client = get_cloud_storage_client() + signed_url = client.generate_presigned_url( + ClientMethod="get_object", + Params={"Bucket": client.bucket, "Key": key}, + ExpiresIn=60, + ) + + response = requests.get(signed_url) + response.raise_for_status() + + # Return file content directly to browser + ext = key.split(".")[-1].lower() + content_types = { + "obj": "text/plain", + "glb": "model/gltf-binary", + "gltf": "model/gltf+json", + "stl": "model/stl", + "ply": "application/octet-stream", + "fbx": "application/octet-stream", + "dae": "model/vnd.collada+xml", + } + + frappe.local.response.filename = key.split("/")[-1] + frappe.local.response.filecontent = response.content + frappe.local.response.type = "download" + frappe.local.response["Content-Type"] = content_types.get(ext, "application/octet-stream") diff --git a/cloud_storage/public/js/components/FilePreview.vue b/cloud_storage/public/js/components/FilePreview.vue index 29c4178..252a821 100644 --- a/cloud_storage/public/js/components/FilePreview.vue +++ b/cloud_storage/public/js/components/FilePreview.vue @@ -1,181 +1,146 @@ diff --git a/cloud_storage/public/js/file.js b/cloud_storage/public/js/file.js index 236168f..52ddbab 100644 --- a/cloud_storage/public/js/file.js +++ b/cloud_storage/public/js/file.js @@ -1,10 +1,17 @@ // Copyright (c) 2025, AgriTheory and contributors // For license information, please see license.txt +const THREE_D_EXTENSIONS = ['obj', 'glb', 'gltf', 'fbx', 'dae', 'ply', 'stl'] + +function is_3d_file(frm) { + const file_string = (frm.doc.file_type || frm.doc.file_name || '').toLowerCase() + return THREE_D_EXTENSIONS.some(ext => file_string.endsWith(ext)) +} + frappe.ui.form.on('File', { refresh: frm => { if (!frm.doc.is_folder) { - // add download button + // Share buttons frm.add_custom_button(__('Get Sharing Link', 'Share'), () => get_sharing_link(frm, false)) if (frm.doc.sharing_link) { frm.add_custom_button(__('Reset Sharing Link', 'Share'), () => get_sharing_link(frm, true)) @@ -12,20 +19,28 @@ frappe.ui.form.on('File', { } let file_string = frm.doc.file_type || frm.doc.file_name - file_string = file_string.toLowerCase() - if (['doc', 'docx'].some(extension => file_string.includes(extension))) { + + if (['doc', 'docx'].some(ext => file_string.includes(ext))) { frm.trigger('preview_doc_content') - } else if (['ppt', 'pptx', 'odp', 'key'].some(extension => file_string.includes(extension))) { + } else if (['ppt', 'pptx', 'odp', 'key'].some(ext => file_string.includes(ext))) { frm.trigger('preview_file_as_pdf') + } else if (is_3d_file(frm)) { + frm.trigger('preview_3d') } }, + preview_3d: function (frm) { + // Add "Preview 3D" button in the form toolbar + frm.add_custom_button(__('🧊 Preview 3D'), () => { + launch_3d_modal(frm.doc.file_url, frm.doc.file_name) + }) + }, + preview_file_as_pdf: async function (frm) { const response = await frm.call('get_pdf_preview') let pdf_content = response.message if (pdf_content) { - // Always treat as base64 string from backend const byteCharacters = atob(pdf_content) const byteNumbers = new Array(byteCharacters.length) for (let i = 0; i < byteCharacters.length; i++) { @@ -69,16 +84,12 @@ frappe.ui.form.on('File', { preview_file: function (frm) { let $preview = '' const file_extension = frm.doc.file_type.toLowerCase() - // Cloud Storage: replace # with %23 in PDFs const file_url = frm.doc.file_url.replace(/#/g, '%23') if (frappe.utils.is_image_file(file_url)) { $preview = $(`
- +
`) } else if (frappe.utils.is_video_file(file_url)) { $preview = $(`
@@ -90,12 +101,8 @@ frappe.ui.form.on('File', { } else if (file_extension === 'pdf') { $preview = $(`
- +
`) } else if (file_extension === 'mp3') { @@ -103,7 +110,7 @@ frappe.ui.form.on('File', { +
`) } @@ -121,3 +128,207 @@ function get_sharing_link(frm, reset) { frappe.msgprint(r, __('Sharing Link')) }) } + +// ─── 3D Modal ──────────────────────────────────────────────────────────────── + +const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.module.js' +const CDN = 'https://cdn.jsdelivr.net/npm/three@0.158.0/examples/jsm' + +// Inject importmap so loaders can resolve bare "three" specifier +function inject_importmap() { + if (document.querySelector('script[type="importmap"]')) return + const map = document.createElement('script') + map.type = 'importmap' + map.textContent = JSON.stringify({ + imports: { + three: THREE_CDN, + 'three/addons/': `${CDN}/`, + }, + }) + document.head.prepend(map) +} + +async function launch_3d_modal(file_url, filename) { + inject_importmap() + const dialog = new frappe.ui.Dialog({ + title: `🧊 ${filename}`, + size: 'extra-large', + }) + dialog.show() + + // Style the dialog body as a 3D viewport + const $body = dialog.$wrapper.find('.modal-body') + $body.css({ padding: 0, background: '#1a1a2e', 'min-height': '70vh' }) + + const container = document.createElement('div') + container.style.cssText = 'width:100%;height:70vh;position:relative;' + $body[0].appendChild(container) + + // Loading indicator + const $loading = $( + '
Loading 3D model...
' + ) + $(container).append($loading) + + try { + // Extract S3 key from the retrieve URL e.g. /api/method/retrieve?key=Item/Kiwi/cube.obj + const key = new URLSearchParams(file_url.split('?')[1]).get('key') + const proxy_url = `/api/method/cloud_storage.cloud_storage.overrides.file.proxy_file?key=${encodeURIComponent(key)}` + + // Fetch file server-side through Frappe proxy (avoids S3 CORS) + const response = await fetch(proxy_url, { + credentials: 'same-origin', + headers: { 'X-Frappe-CSRF-Token': frappe.csrf_token }, + }) + if (!response.ok) throw new Error(`Failed to fetch file: ${response.status}`) + const blob = await response.blob() + const blob_url = URL.createObjectURL(blob) + + const THREE = await import(THREE_CDN) + const { OrbitControls } = await import(`${CDN}/controls/OrbitControls.js`) + + const w = container.clientWidth + const h = container.clientHeight + + // Scene + const scene = new THREE.Scene() + scene.background = new THREE.Color(0x1a1a2e) + scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x333333)) + + // Camera + const camera = new THREE.PerspectiveCamera(60, w / h, 0.01, 10000) + camera.position.set(3, 3, 3) + + // Renderer + const renderer = new THREE.WebGLRenderer({ antialias: true }) + renderer.setSize(w, h) + renderer.setPixelRatio(window.devicePixelRatio) + renderer.shadowMap.enabled = true + container.appendChild(renderer.domElement) + + // Controls + const controls = new OrbitControls(camera, renderer.domElement) + controls.enableDamping = true + controls.dampingFactor = 0.05 + + // Lights + scene.add(new THREE.AmbientLight(0xffffff, 0.6)) + const dir = new THREE.DirectionalLight(0xffffff, 1) + dir.position.set(5, 10, 5) + dir.castShadow = true + scene.add(dir) + scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.4)) + + // Load model using blob URL (avoids CORS/auth issues with S3) + const ext = filename.split('.').pop().toLowerCase() + const object = await load_3d_model(THREE, ext, blob_url) + + // Center & fit camera + const box = new THREE.Box3().setFromObject(object) + const size = box.getSize(new THREE.Vector3()).length() + const center = box.getCenter(new THREE.Vector3()) + object.position.sub(center) + camera.near = size / 100 + camera.far = size * 100 + camera.position.set(size, size, size) + camera.lookAt(0, 0, 0) + camera.updateProjectionMatrix() + controls.maxDistance = size * 10 + controls.update() + + scene.add(object) + $loading.remove() + + // Animate + let running = true + function animate() { + if (!running) return + requestAnimationFrame(animate) + controls.update() + renderer.render(scene, camera) + } + animate() + + // Resize handler + const on_resize = () => { + const w = container.clientWidth + const h = container.clientHeight + camera.aspect = w / h + camera.updateProjectionMatrix() + renderer.setSize(w, h) + } + window.addEventListener('resize', on_resize) + + // Cleanup on dialog close + dialog.$wrapper.on('hidden.bs.modal', () => { + running = false + renderer.dispose() + URL.revokeObjectURL(blob_url) + window.removeEventListener('resize', on_resize) + }) + } catch (e) { + $loading.text('Failed to load 3D model: ' + e.message).css('color', '#ff6b6b') + console.error('3D Preview error:', e) + } +} + +async function load_3d_model(THREE, ext, file_url) { + switch (ext) { + case 'glb': + case 'gltf': { + const { GLTFLoader } = await import(`${CDN}/loaders/GLTFLoader.js`) + const { DRACOLoader } = await import(`${CDN}/loaders/DRACOLoader.js`) + const draco = new DRACOLoader() + draco.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/') + const loader = new GLTFLoader() + loader.setDRACOLoader(draco) + return new Promise((res, rej) => loader.load(file_url, g => res(g.scene), undefined, rej)) + } + case 'obj': { + const { OBJLoader } = await import(`${CDN}/loaders/OBJLoader.js`) + return new Promise((res, rej) => new OBJLoader().load(file_url, res, undefined, rej)) + } + case 'stl': { + const { STLLoader } = await import(`${CDN}/loaders/STLLoader.js`) + return new Promise((res, rej) => + new STLLoader().load( + file_url, + geometry => { + const mat = new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.3, roughness: 0.6 }) + res(new THREE.Mesh(geometry, mat)) + }, + undefined, + rej + ) + ) + } + case 'ply': { + const { PLYLoader } = await import(`${CDN}/loaders/PLYLoader.js`) + return new Promise((res, rej) => + new PLYLoader().load( + file_url, + geometry => { + geometry.computeVertexNormals() + const mat = new THREE.MeshStandardMaterial({ + color: 0x888888, + vertexColors: geometry.hasAttribute('color'), + }) + res(new THREE.Mesh(geometry, mat)) + }, + undefined, + rej + ) + ) + } + case 'fbx': { + const { FBXLoader } = await import(`${CDN}/loaders/FBXLoader.js`) + return new Promise((res, rej) => new FBXLoader().load(file_url, res, undefined, rej)) + } + case 'dae': { + const { ColladaLoader } = await import(`${CDN}/loaders/ColladaLoader.js`) + return new Promise((res, rej) => new ColladaLoader().load(file_url, c => res(c.scene), undefined, rej)) + } + default: + throw new Error(`Unsupported format: .${ext}`) + } +} diff --git a/cloud_storage/public/js/overrides.js b/cloud_storage/public/js/overrides.js index 5016c0c..d99ea30 100644 --- a/cloud_storage/public/js/overrides.js +++ b/cloud_storage/public/js/overrides.js @@ -31,139 +31,134 @@ function disallow_attachment_delete(frm) { } } -// TODO: full class override from Frappe's file_uploader.bundle.js file; keep in sync -frappe.provide('frappe.ui') -frappe.ui.FileUploader = class CloudStorageFileUploader { - constructor({ - wrapper, - method, - on_success, - doctype, - docname, - fieldname, - files, - folder, - restrictions = {}, - upload_notes, - allow_multiple, - as_dataurl, - disable_file_browser, - dialog_title, - attach_doc_image, - frm, - make_attachments_public, - } = {}) { - frm && frm.attachments.max_reached(true) - - if (!wrapper) { - this.make_dialog(dialog_title) - } else { - this.wrapper = wrapper.get ? wrapper.get(0) : wrapper - } - - if (restrictions && !restrictions.allowed_file_types) { - // apply global allow list if present - let allowed_extensions = frappe.sys_defaults?.allowed_file_extensions - if (allowed_extensions) { - restrictions.allowed_file_types = allowed_extensions.split('\n').map(ext => `.${ext}`) - } - } - - let app = createApp(FileUploaderComponent, { - show_upload_button: !Boolean(this.dialog), - doctype, - docname, - fieldname, - method, - folder, - on_success, - restrictions, - upload_notes, - allow_multiple, - as_dataurl, - disable_file_browser, - attach_doc_image, - make_attachments_public, - }) - SetVueGlobals(app) - this.uploader = app.mount(this.wrapper) - - if (!this.dialog) { - this.uploader.wrapper_ready = true - } +// Wait for ALL Frappe bundles to finish loading, then override +document.addEventListener('DOMContentLoaded', () => { + setTimeout(() => { + frappe.ui.FileUploader = class CloudStorageFileUploader { + constructor({ + wrapper, + method, + on_success, + doctype, + docname, + fieldname, + files, + folder, + restrictions = {}, + upload_notes, + allow_multiple, + as_dataurl, + disable_file_browser, + dialog_title, + attach_doc_image, + frm, + make_attachments_public, + } = {}) { + frm && frm.attachments.max_reached(true) + + if (!wrapper) { + this.make_dialog(dialog_title) + } else { + this.wrapper = wrapper.get ? wrapper.get(0) : wrapper + } - watch( - () => this.uploader.files, - files => { - let all_private = files.every(file => file.private) - if (this.dialog) { - this.dialog.set_secondary_action_label(all_private ? __('Set all public') : __('Set all private')) + if (restrictions && !restrictions.allowed_file_types) { + let allowed_extensions = frappe.sys_defaults?.allowed_file_extensions + if (allowed_extensions) { + restrictions.allowed_file_types = allowed_extensions.split('\n').map(ext => `.${ext}`) + } } - }, - { deep: true } - ) - - watch( - () => this.uploader.trigger_upload, - trigger_upload => { - if (trigger_upload) { - this.upload_files() + + let app = createApp(FileUploaderComponent, { + show_upload_button: !Boolean(this.dialog), + doctype, + docname, + fieldname, + method, + folder, + on_success, + restrictions, + upload_notes, + allow_multiple, + as_dataurl, + disable_file_browser, + attach_doc_image, + make_attachments_public, + }) + SetVueGlobals(app) + this.uploader = app.mount(this.wrapper) + + if (!this.dialog) { + this.uploader.wrapper_ready = true } - } - ) - watch( - () => this.uploader.close_dialog, - close_dialog => { - if (close_dialog) { - this.dialog && this.dialog.hide() + watch( + () => this.uploader.files, + files => { + let all_private = files.every(file => file.private) + if (this.dialog) { + this.dialog.set_secondary_action_label(all_private ? __('Set all public') : __('Set all private')) + } + }, + { deep: true } + ) + + watch( + () => this.uploader.trigger_upload, + trigger_upload => { + if (trigger_upload) this.upload_files() + } + ) + + watch( + () => this.uploader.close_dialog, + close_dialog => { + if (close_dialog) this.dialog && this.dialog.hide() + } + ) + + watch( + () => this.uploader.hide_dialog_footer, + hide_dialog_footer => { + if (hide_dialog_footer) { + this.dialog && this.dialog.footer.addClass('hide') + this.dialog.$wrapper.data('bs.modal')._config.backdrop = 'static' + } else { + this.dialog && this.dialog.footer.removeClass('hide') + this.dialog.$wrapper.data('bs.modal')._config.backdrop = true + } + } + ) + + if (files && files.length) { + this.uploader.add_files(files) } } - ) - - watch( - () => this.uploader.hide_dialog_footer, - hide_dialog_footer => { - if (hide_dialog_footer) { - this.dialog && this.dialog.footer.addClass('hide') - this.dialog.$wrapper.data('bs.modal')._config.backdrop = 'static' - } else { - this.dialog && this.dialog.footer.removeClass('hide') - this.dialog.$wrapper.data('bs.modal')._config.backdrop = true - } + + upload_files() { + this.dialog && this.dialog.get_primary_btn().prop('disabled', true) + this.dialog && this.dialog.get_secondary_btn().prop('disabled', true) + return this.uploader.upload_files() } - ) - if (files && files.length) { - this.uploader.add_files(files) + make_dialog(title) { + this.dialog = new frappe.ui.Dialog({ + title: title || __('Upload'), + primary_action_label: __('Upload'), + primary_action: () => this.upload_files(), + secondary_action_label: __('Set all private'), + secondary_action: () => this.uploader.toggle_all_private(), + on_page_show: () => { + this.uploader.wrapper_ready = true + }, + }) + this.wrapper = this.dialog.body + this.dialog.show() + this.dialog.$wrapper.on('hidden.bs.modal', function () { + $(this).data('bs.modal', null) + $(this).remove() + }) + } } - } - - upload_files() { - this.dialog && this.dialog.get_primary_btn().prop('disabled', true) - this.dialog && this.dialog.get_secondary_btn().prop('disabled', true) - return this.uploader.upload_files() - } - - make_dialog(title) { - this.dialog = new frappe.ui.Dialog({ - title: title || __('Upload'), - primary_action_label: __('Upload'), - primary_action: () => this.upload_files(), - secondary_action_label: __('Set all private'), - secondary_action: () => { - this.uploader.toggle_all_private() - }, - on_page_show: () => { - this.uploader.wrapper_ready = true - }, - }) - - this.wrapper = this.dialog.body - this.dialog.show() - this.dialog.$wrapper.on('hidden.bs.modal', function () { - $(this).data('bs.modal', null) - $(this).remove() - }) - } -} + }, 500) +}) From e36f3b04576ddb7ee9e1cb662fcb7c2b7bb34805 Mon Sep 17 00:00:00 2001 From: Ishwarya Date: Wed, 18 Mar 2026 14:42:28 +0530 Subject: [PATCH 2/4] Removed preview emoji --- cloud_storage/public/js/file.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud_storage/public/js/file.js b/cloud_storage/public/js/file.js index 52ddbab..0ccd095 100644 --- a/cloud_storage/public/js/file.js +++ b/cloud_storage/public/js/file.js @@ -32,7 +32,7 @@ frappe.ui.form.on('File', { preview_3d: function (frm) { // Add "Preview 3D" button in the form toolbar - frm.add_custom_button(__('🧊 Preview 3D'), () => { + frm.add_custom_button(__('Preview 3D'), () => { launch_3d_modal(frm.doc.file_url, frm.doc.file_name) }) }, From 754ff99228901a5544e102554ef99f5ab8dce13c Mon Sep 17 00:00:00 2001 From: Ishwarya Date: Wed, 18 Mar 2026 14:45:49 +0530 Subject: [PATCH 3/4] Fixed mypy linter issue --- cloud_storage/cloud_storage/overrides/file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud_storage/cloud_storage/overrides/file.py b/cloud_storage/cloud_storage/overrides/file.py index 9892b87..d7d1da1 100644 --- a/cloud_storage/cloud_storage/overrides/file.py +++ b/cloud_storage/cloud_storage/overrides/file.py @@ -759,7 +759,7 @@ def proxy_file(key: str): Fetch file from S3 server-side and stream back to browser. Used for 3D preview to avoid CORS issues with direct S3 URLs. """ - import requests + import requests # type: ignore[import-untyped] if not key: frappe.throw(_("Key not found")) From 1ab7dee054a9a4e5a621b197dbd9bae3bbee7c4c Mon Sep 17 00:00:00 2001 From: Ishwarya Date: Mon, 23 Mar 2026 18:14:23 +0530 Subject: [PATCH 4/4] Pushing the 3D model to view in preview section --- cloud_storage/public/js/file.js | 44 ++++++--------------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/cloud_storage/public/js/file.js b/cloud_storage/public/js/file.js index 0ccd095..1e12c1c 100644 --- a/cloud_storage/public/js/file.js +++ b/cloud_storage/public/js/file.js @@ -30,11 +30,13 @@ frappe.ui.form.on('File', { } }, - preview_3d: function (frm) { - // Add "Preview 3D" button in the form toolbar - frm.add_custom_button(__('Preview 3D'), () => { - launch_3d_modal(frm.doc.file_url, frm.doc.file_name) - }) + preview_3d: async function (frm) { + const field = frm.get_field('preview_html') + const container = document.createElement('div') + container.style.cssText = 'width:100%;height:500px;background:#1a1a2e;border-radius:6px;position:relative;' + field.$wrapper.html(container) + frm.toggle_display('preview', true) + await render_3d_in_container(container, frm.doc.file_url, frm.doc.file_name) }, preview_file_as_pdf: async function (frm) { @@ -129,12 +131,9 @@ function get_sharing_link(frm, reset) { }) } -// ─── 3D Modal ──────────────────────────────────────────────────────────────── - const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.module.js' const CDN = 'https://cdn.jsdelivr.net/npm/three@0.158.0/examples/jsm' -// Inject importmap so loaders can resolve bare "three" specifier function inject_importmap() { if (document.querySelector('script[type="importmap"]')) return const map = document.createElement('script') @@ -148,34 +147,17 @@ function inject_importmap() { document.head.prepend(map) } -async function launch_3d_modal(file_url, filename) { +async function render_3d_in_container(container, file_url, filename) { inject_importmap() - const dialog = new frappe.ui.Dialog({ - title: `🧊 ${filename}`, - size: 'extra-large', - }) - dialog.show() - - // Style the dialog body as a 3D viewport - const $body = dialog.$wrapper.find('.modal-body') - $body.css({ padding: 0, background: '#1a1a2e', 'min-height': '70vh' }) - - const container = document.createElement('div') - container.style.cssText = 'width:100%;height:70vh;position:relative;' - $body[0].appendChild(container) - - // Loading indicator const $loading = $( '
Loading 3D model...
' ) $(container).append($loading) try { - // Extract S3 key from the retrieve URL e.g. /api/method/retrieve?key=Item/Kiwi/cube.obj const key = new URLSearchParams(file_url.split('?')[1]).get('key') const proxy_url = `/api/method/cloud_storage.cloud_storage.overrides.file.proxy_file?key=${encodeURIComponent(key)}` - // Fetch file server-side through Frappe proxy (avoids S3 CORS) const response = await fetch(proxy_url, { credentials: 'same-origin', headers: { 'X-Frappe-CSRF-Token': frappe.csrf_token }, @@ -190,28 +172,23 @@ async function launch_3d_modal(file_url, filename) { const w = container.clientWidth const h = container.clientHeight - // Scene const scene = new THREE.Scene() scene.background = new THREE.Color(0x1a1a2e) scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x333333)) - // Camera const camera = new THREE.PerspectiveCamera(60, w / h, 0.01, 10000) camera.position.set(3, 3, 3) - // Renderer const renderer = new THREE.WebGLRenderer({ antialias: true }) renderer.setSize(w, h) renderer.setPixelRatio(window.devicePixelRatio) renderer.shadowMap.enabled = true container.appendChild(renderer.domElement) - // Controls const controls = new OrbitControls(camera, renderer.domElement) controls.enableDamping = true controls.dampingFactor = 0.05 - // Lights scene.add(new THREE.AmbientLight(0xffffff, 0.6)) const dir = new THREE.DirectionalLight(0xffffff, 1) dir.position.set(5, 10, 5) @@ -219,11 +196,9 @@ async function launch_3d_modal(file_url, filename) { scene.add(dir) scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.4)) - // Load model using blob URL (avoids CORS/auth issues with S3) const ext = filename.split('.').pop().toLowerCase() const object = await load_3d_model(THREE, ext, blob_url) - // Center & fit camera const box = new THREE.Box3().setFromObject(object) const size = box.getSize(new THREE.Vector3()).length() const center = box.getCenter(new THREE.Vector3()) @@ -239,7 +214,6 @@ async function launch_3d_modal(file_url, filename) { scene.add(object) $loading.remove() - // Animate let running = true function animate() { if (!running) return @@ -249,7 +223,6 @@ async function launch_3d_modal(file_url, filename) { } animate() - // Resize handler const on_resize = () => { const w = container.clientWidth const h = container.clientHeight @@ -259,7 +232,6 @@ async function launch_3d_modal(file_url, filename) { } window.addEventListener('resize', on_resize) - // Cleanup on dialog close dialog.$wrapper.on('hidden.bs.modal', () => { running = false renderer.dispose()