diff --git a/app/components/renderers/f3d.rb b/app/components/renderers/f3d.rb new file mode 100644 index 000000000..bdcee2a92 --- /dev/null +++ b/app/components/renderers/f3d.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Components::Renderers::F3d < Components::Renderers::Base + include Phlex::Rails::Helpers::NumberToHumanSize + + def self.supports?(file) + FileHandlers::F3dWasm.can_load? file&.mime_type + end + + def before_template + @settings = current_user&.renderer_settings || SiteSettings::UserDefaults::RENDERER + @loading_bar_position_classes = @file.has_render? ? + "btn-sm mt-3 translate-middle-x translate-top" : + "top-50 translate-middle" + end + + def view_template + div class: "position-relative", data: {turbo_permanent: true} do + img src: model_model_file_path(@file.model, @file, format: @file.extension, derivative: :render), class: "card-img-top image-preview", alt: @file.name if @file.has_render? + canvas id: "file-#{@file.to_param}", + class: "object-preview position-relative", + tabindex: "0", + data: { + controller: "f3d-renderer", + preview_url: model_model_file_raw_path(@file.model, @file.filename), + format: @file.extension, + y_up: @file.y_up.to_s, + grid_size_x: @settings["grid_width"], + grid_size_z: @settings["grid_depth"], + show_grid: @settings["show_grid"].to_s, + enable_pan_zoom: @settings["enable_pan_zoom"].to_s, + background_colour: @settings["background_colour"], + object_colour: @settings["object_colour"], + render_style: @settings["render_style"], + auto_load: ((@file.size || 9_999_999.megabytes) < (@settings["auto_load_max_size"] || 9_999_999).megabytes) ? "true" : "false" + } + div class: "p-0 btn btn-secondary load-progress object-preview-progress position-absolute start-50 #{@loading_bar_position_classes}", role: "presentation" do + div class: "progress-bar bg-info progress-bar-animated progress-bar-striped", role: "progressbar", style: "width: 0%; height: 100%", + aria_label: "Loading progress", aria_valuenow: "0", aria_valuemin: "0", aria_valuemax: "100" + span class: "progress-label position-absolute top-50 start-50 translate-middle", role: "button" do + Icon icon: "box" + whitespace + span { t("renderer.load") } + whitespace + span { "(#{number_to_human_size @file.size, precision: 2})" } + end + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index dfd9e16af..6dcb86ec7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -109,7 +109,7 @@ class ApplicationController < ActionController::Base content_security_policy.img_src(*img_src) content_security_policy.worker_src :self, :blob content_security_policy.object_src :none - content_security_policy.script_src :self + content_security_policy.script_src :self, :unsafe_eval content_security_policy.style_src :self content_security_policy.style_src_attr :unsafe_inline content_security_policy.style_src_elem :self, "https://fonts.googleapis.com" diff --git a/app/frontend/javascript/controllers/f3d_renderer_controller.ts b/app/frontend/javascript/controllers/f3d_renderer_controller.ts new file mode 100644 index 000000000..33ff78e9c --- /dev/null +++ b/app/frontend/javascript/controllers/f3d_renderer_controller.ts @@ -0,0 +1,118 @@ +import { Controller } from '@hotwired/stimulus' +import f3d from 'f3d' + +// Connects to data-controller="f3d-renderer" +export default class extends Controller { + engine: any + progressBar: HTMLDivElement | null + progressLabel: HTMLSpanElement | null + + connect (): void { + this.progressBar = this.element.parentElement?.getElementsByClassName('progress-bar')[0] as HTMLDivElement + this.progressLabel = this.element.parentElement?.getElementsByClassName('progress-label')[0] as HTMLSpanElement + const loadButton = this.element.parentElement?.getElementsByClassName('object-preview-progress')[0] as HTMLDivElement + loadButton.addEventListener('click', this.load.bind(this)) + } + + async init (data: Uint8Array): Promise { + const settings = { + canvas: this.element + } + await f3d(settings).then(async (F3D) => { + F3D.Engine.autoloadPlugins() + // Uncomment this to get an updated list of formats in dev + // console.log(`F3D supported types: ${F3D.Engine.getReadersInfo().map((reader) => (`${reader["extensions"][0]}: ${reader["mimeTypes"][0]}`)).flat()}`); + this.engine = F3D.Engine.create(`#${this.element.id}`) + const options = this.engine.getOptions() + + // Set renderer options the same as the CLI version + options.setAsString('render.effect.ambient_occlusion', 'true') + options.setAsString('render.effect.antialiasing.mode', 'fxaa') + options.setAsString('render.effect.blending.mode', 'ddp') + options.setAsString('render.background.color', '#000000') + options.setAsString('render.grid.enable', 'true') + options.setAsString('render.grid.color', '#00ffff') + options.setAsString('render.grid.subdivisions', '0') + options.setAsString('render.grid.unit', '10') + options.setAsString('render.effect.tone_mapping', 'true') + options.setAsString('scene.animation.autoplay', 'true') + + const canvas = this.element as HTMLCanvasElement + + options.setAsString('scene.up_direction', canvas.dataset.yUp === 'true' ? '+Y' : '+Z') + + const scale = window.devicePixelRatio + console.log(scale * canvas.clientWidth) + console.log(scale * canvas.clientHeight) + this.engine.getWindow().setSize(scale * canvas.clientWidth, scale * canvas.clientHeight) + + const scene = this.engine.getScene() + scene.clear() + try { + scene.addBuffer(data) + } catch (e) { + console.log(e) + if (this.progressBar && this.progressLabel) { + this.progressBar.classList.add('bg-danger') + this.progressBar.style.width = this.progressBar.ariaValueNow = '100%' + this.progressLabel.textContent = window.i18n.t('renderer.errors.load') // i18n-tasks-use t('renderer.errors.load') + } + return + } + + this.progressBar?.parentElement?.remove() + this.progressBar = null + this.progressLabel = null + canvas.style.backgroundColor = 'black' + + const camera = this.engine.getWindow().getCamera() + const foc = camera.focalPoint + + const dir = canvas.dataset.yUp === 'true' ? [-1, -0.5, -1] : [-1, 1, -0.5] + const pos = [0, 0, 0] + for (let i = 0; i < 3; i++) { + pos[i] = foc[i] - dir[i] + } + camera.position = pos + camera.resetToBounds(0.9) + this.engine.getWindow().render() + this.engine.getInteractor().start() + }) + } + + async load (): Promise { + const url = (this.element as HTMLCanvasElement).dataset.previewUrl + if (url != null) { + const xhr = new XMLHttpRequest() + xhr.open('GET', url) + xhr.responseType = 'arraybuffer' + xhr.addEventListener('progress', this.onLoadProgress.bind(this)) + xhr.addEventListener('error', this.onLoadError.bind(this)) + xhr.onreadystatechange = function () { + if (xhr.readyState === XMLHttpRequest.DONE) { + this.init(new Uint8Array(xhr.response)) + } + }.bind(this) + xhr.send() + } + } + + onLoadProgress (event): void { + const percentage = Math.round((event.loaded / event.total) * 100) + if ((this.progressBar == null) || (this.progressLabel == null)) { return } + if (percentage === 100) { + this.progressLabel.textContent = window.i18n.t('renderer.processing') // i18n-tasks-use t('renderer.processing') + } else { + this.progressLabel.textContent = `${percentage}%` + } + this.progressBar.style.width = `${percentage}%` + this.progressBar.ariaValueNow = percentage.toString() + } + + onLoadError (): void { + if ((this.progressBar == null) || (this.progressLabel == null)) { return } + this.progressBar.classList.add('bg-danger') + this.progressBar.style.width = this.progressBar.ariaValueNow = '100%' + this.progressLabel.textContent = window.i18n.t('renderer.errors.load') // i18n-tasks-use t('renderer.errors.load') + } +} diff --git a/app/frontend/javascript/controllers/index.ts b/app/frontend/javascript/controllers/index.ts index 07b58f469..8a48aa71c 100644 --- a/app/frontend/javascript/controllers/index.ts +++ b/app/frontend/javascript/controllers/index.ts @@ -11,6 +11,7 @@ import CocoonedController from './cocooned_controller' import CollectionInputController from './collection_input_controller' import CopyTextController from './copy_text_controller' import EditableController from './editable_controller' +import F3dRendererController from './f3d_renderer_controller' import I18nController from './i18n_controller' import ModelSelectController from './model_select_controller' import ObfuscatedTextController from './obfuscated_text_controller' @@ -30,6 +31,7 @@ application.register('cocooned', CocoonedController) application.register('collection-input', CollectionInputController) application.register('copy-text', CopyTextController) application.register('editable', EditableController) +application.register('f3d-renderer', F3dRendererController) application.register('i18n', I18nController) application.register('model-select', ModelSelectController) application.register('obfuscated-text', ObfuscatedTextController) diff --git a/app/jobs/upgrade/backfill_model_renders.rb b/app/jobs/upgrade/backfill_model_renders.rb index e28bdcdcb..2f2023f63 100644 --- a/app/jobs/upgrade/backfill_model_renders.rb +++ b/app/jobs/upgrade/backfill_model_renders.rb @@ -2,7 +2,7 @@ class Upgrade::BackfillModelRenders < Upgrade::BackfillDerivativesBase queue_as :performance def mime_types - FileHandlers::F3d.input_types.without(MediaType.image_types) + FileHandlers::F3dCli.input_types.without(MediaType.image_types) end def derivative diff --git a/app/lib/file_handlers/f3d.rb b/app/lib/file_handlers/f3d_cli.rb similarity index 83% rename from app/lib/file_handlers/f3d.rb rename to app/lib/file_handlers/f3d_cli.rb index 8f424a2ba..babdff974 100644 --- a/app/lib/file_handlers/f3d.rb +++ b/app/lib/file_handlers/f3d_cli.rb @@ -1,4 +1,4 @@ -class FileHandlers::F3d < FileHandlers::Base +class FileHandlers::F3dCli < FileHandlers::Base def self.readers `f3d --list-readers` end diff --git a/app/lib/file_handlers/f3d_wasm.rb b/app/lib/file_handlers/f3d_wasm.rb new file mode 100644 index 000000000..25a3b2bdf --- /dev/null +++ b/app/lib/file_handlers/f3d_wasm.rb @@ -0,0 +1,12 @@ +class FileHandlers::F3dWasm < FileHandlers::Base + ENVIRONMENTS = [:browser, :preview_frame].freeze + INPUT_TYPES = Mime::EXTENSION_LOOKUP.slice(*%w[gml gltf glb obj ply pts stl vtk vtp vtu vtkhdf 3ds wrl vrml fbx dae dxf off x 3mf ex2 exo e g stp step igs iges brep xbf drc mdl nc cdf ncdf ifc]).values.uniq.freeze + + def self.priority + 1 + end + + def self.component + Components::Renderers::F3d + end +end diff --git a/app/lib/file_handlers/three.rb b/app/lib/file_handlers/three.rb index 8d4b8f308..5d2c12e40 100644 --- a/app/lib/file_handlers/three.rb +++ b/app/lib/file_handlers/three.rb @@ -4,6 +4,10 @@ class FileHandlers::Three < FileHandlers::Base "stl", "obj", "3mf", "ply", "gltf", "glb", "drc", "fbx", "3ds", "gcode", "mpd", "ldr", "3dm" ).values.freeze + def self.priority + 100 + end + def self.component Components::Renderers::Three end diff --git a/app/uploaders/application_uploader.rb b/app/uploaders/application_uploader.rb index e50972199..78825772b 100644 --- a/app/uploaders/application_uploader.rb +++ b/app/uploaders/application_uploader.rb @@ -90,7 +90,7 @@ class ApplicationUploader < Shrine end add_metadata :object do |io, context| - if context[:record]&.try(:is_3d_model?) && FileHandlers::F3d.can_load?(context[:record].mime_type) + if context[:record]&.try(:is_3d_model?) && FileHandlers::F3dCli.can_load?(context[:record].mime_type) bounds = Shrine.with_file(io) do |file| if file.path options = { @@ -141,7 +141,7 @@ class ApplicationUploader < Shrine Shrine.with_file(original) do {render: FreecadThumbnailExtractorService.new(file: it).call}.compact end - elsif SiteSettings.generate_model_renders && FileHandlers::F3d.can_load?(context[:record].mime_type) && context[:record]&.is_3d_model? + elsif SiteSettings.generate_model_renders && FileHandlers::F3dCli.can_load?(context[:record].mime_type) && context[:record]&.is_3d_model? Shrine.with_file(original) do up = context[:record]&.up_direction options = F3D_OPTS.merge( diff --git a/config/initializers/cache_clearance.rb b/config/initializers/cache_clearance.rb new file mode 100644 index 000000000..2f0479ae7 --- /dev/null +++ b/config/initializers/cache_clearance.rb @@ -0,0 +1,4 @@ +Rails.application.config.after_initialize do + Rails.cache.delete_matched("FileHandlers_handlers_for_*") if ENV["RAILS_ASSETS_PRECOMPILE"].blank? +rescue RedisClient::CannotConnectError +end diff --git a/package.json b/package.json index 2748066a3..52736e2b5 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "bootswatch": "^5.3.8", "comlink": "^4.4.2", "driver.js": "^1.8.0", + "f3d": "3.5.0-nightly.20261003", "i18n-js": "^4.5.3", "masonry-layout": "^4.2.2", "three": "^0.186.1", diff --git a/spec/lib/file_handlers/f3d_spec.rb b/spec/lib/file_handlers/f3d_cli_spec.rb similarity index 72% rename from spec/lib/file_handlers/f3d_spec.rb rename to spec/lib/file_handlers/f3d_cli_spec.rb index 7b2925d50..3fad30955 100644 --- a/spec/lib/file_handlers/f3d_spec.rb +++ b/spec/lib/file_handlers/f3d_cli_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -RSpec.describe FileHandlers::F3d do +RSpec.describe FileHandlers::F3dCli do it "can execute f3d" do expect(`f3d --version`).to include "F3D" end diff --git a/spec/support/f3d.rb b/spec/support/f3d.rb index a57525b6e..e1119df47 100644 --- a/spec/support/f3d.rb +++ b/spec/support/f3d.rb @@ -1,6 +1,6 @@ RSpec.configure do |config| config.before do - allow(FileHandlers::F3d).to receive(:input_types).and_return([ + allow(FileHandlers::F3dCli).to receive(:input_types).and_return([ Mime[:threemf], Mime[:obj], Mime[:stl], Mime[:jpg] ]) end diff --git a/yarn.lock b/yarn.lock index 6717c5022..5fc3ab58d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2877,6 +2877,13 @@ __metadata: languageName: node linkType: hard +"f3d@npm:3.5.0-nightly.20261003": + version: 3.5.0-nightly.20261003 + resolution: "f3d@npm:3.5.0-nightly.20261003" + checksum: d3f3419feb1585953e33d9c8b423cdc3fe25f4cd5b1acc3cfa39ede28f411bbd5aa928f2f481bca067a1f58eb0b48c0eaad26ed955244a4003970763209f1e6b + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -4079,6 +4086,7 @@ __metadata: comlink: ^4.4.2 driver.js: ^1.8.0 eslint: ^9.0.0 + f3d: 3.5.0-nightly.20261003 husky: ^9.1.7 i18n-js: ^4.5.3 masonry-layout: ^4.2.2