mirror of
https://github.com/manyfold3d/manyfold.git
synced 2026-10-19 01:59:48 -05:00
Merge pull request #6325 from manyfold3d/wasm
New interactive renderer using F3D; displays STEP and a whole load more
This commit is contained in:
14 files changed
+206
-7
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user