Merge pull request #6325 from manyfold3d/wasm

New interactive renderer using F3D; displays STEP and a whole load more
This commit is contained in:
James Smith authored and GitHub committed 2026-10-06 08:29:28 +01:00
commit 3c7da648e3
14 files changed
+206 -7

No files matched your search

+50
View File
@@ -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
+1 -1
View File
@@ -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"
@@ -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<void> {
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<void> {
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')
}
}
@@ -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)
+1 -1
View File
@@ -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
@@ -1,4 +1,4 @@
class FileHandlers::F3d < FileHandlers::Base
class FileHandlers::F3dCli < FileHandlers::Base
def self.readers
`f3d --list-readers`
end
+12
View File
@@ -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
+4
View File
@@ -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
+2 -2
View File
@@ -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(
+4
View File
@@ -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
+1
View File
@@ -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",
@@ -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
+1 -1
View File
@@ -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
Generated
+8
View File
@@ -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