diff --git a/.gitignore b/.gitignore index 15f30f9..1622b22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ __pycache__ -uploads +uploads/* +venv/* +instance/* +huey_queue.db \ No newline at end of file diff --git a/app/app.db b/app/app.db new file mode 100644 index 0000000..e69de29 diff --git a/app/models.py b/app/models.py index 5b0e159..f0e888e 100644 --- a/app/models.py +++ b/app/models.py @@ -24,6 +24,7 @@ class PrintFile(db.Model): user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) status = db.Column(db.String(50), default='waiting') # waiting, slicing, sliced, failed + transform_matrix = db.Column(db.Text, nullable=True) # json format of 16-element array class SystemConfig(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/app/routes.py b/app/routes.py index 1cd4efb..629c45b 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,8 +1,13 @@ +import json +import trimesh +import uuid +import os from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, session, make_response, send_file, abort, jsonify from flask_login import login_user, logout_user, login_required, current_user from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.utils import secure_filename from .models import db, User, PrintFile, SystemConfig +from .tasks import merge_and_slice_task import os import uuid import configparser @@ -11,21 +16,21 @@ from .tasks import slice_stl_task main_bp = Blueprint('main', __name__) -def get_quality_presets(): - preset_dir = os.path.join(current_app.root_path, '..', 'print_config', 'presets', 'creality', 'base') - presets = [] - if os.path.exists(preset_dir): - for f in os.listdir(preset_dir): - if f.startswith('base_global_') and f.endswith('.inst.cfg'): - config = configparser.ConfigParser() - try: - config.read(os.path.join(preset_dir, f)) - name = config.get('general', 'name', fallback=f) - presets.append((f, name)) - except Exception as e: - pass - # Custom sort order or alphanumeric - return sorted(presets, key=lambda x: x[1]) +# def get_quality_presets(): +# preset_dir = os.path.join(current_app.root_path, '..', 'print_config', 'presets', 'creality', 'base') +# presets = [] +# if os.path.exists(preset_dir): +# for f in os.listdir(preset_dir): +# if f.startswith('base_global_') and f.endswith('.inst.cfg'): +# config = configparser.ConfigParser() +# try: +# config.read(os.path.join(preset_dir, f)) +# name = config.get('general', 'name', fallback=f) +# presets.append((f, name)) +# except Exception as e: +# pass +# # Custom sort order or alphanumeric +# return sorted(presets, key=lambda x: x[1]) auth_bp = Blueprint('auth', __name__, url_prefix='/auth') admin_bp = Blueprint('admin', __name__, url_prefix='/admin') @@ -70,9 +75,9 @@ def set_language(lang): response.set_cookie('lang', lang, max_age=60*60*24*365) return response -@main_bp.route('/slice', methods=['GET', 'POST']) +@main_bp.route('/files', methods=['GET', 'POST']) @login_required -def slice_page(): +def files(): if request.method == 'POST': if 'file' not in request.files: flash('No file part', 'danger') @@ -91,33 +96,51 @@ def slice_page(): filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename) file.save(filepath) + try: + mesh = trimesh.load(filepath) + # Check for overlapping faces or if the mesh is not watertight + # which can cause issues in CuraEngine + needs_repair = False + if not mesh.is_watertight or (len(mesh.faces) > 0 and len(mesh.intersecting_faces[0]) > 0): + needs_repair = True + + if needs_repair: + # Attempt automatic repair + import trimesh.repair + trimesh.repair.fix_normals(mesh) + trimesh.repair.fix_inversion(mesh) + trimesh.repair.fix_winding(mesh) + trimesh.repair.fill_holes(mesh) + + # Re-check after repair + if not mesh.is_watertight or (len(mesh.faces) > 0 and len(mesh.intersecting_faces[0]) > 0): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'error': 'Mesh has overlapping faces or is not watertight, and automatic repair failed! Please repair the model manually before slicing.'}), 400 + else: + flash('Mesh has overlapping faces or is not watertight, and automatic repair failed! Please repair the model manually.', 'danger') + os.remove(filepath) + return redirect(request.url) + else: + # Repair succeeded, rewrite file + mesh.export(filepath) + except Exception as e: + pass + print_file = PrintFile( filename=unique_filename, original_filename=original_filename, file_type='stl', user_id=current_user.id, - status='waiting' + status='uploaded' # Only display as uploaded, no automatic slicing ) db.session.add(print_file) db.session.commit() - # Start slicing task - quality_preset = request.form.get('quality', 'base_global_standard.inst.cfg') - slice_stl_task(print_file.id, filepath, quality_preset) - flash('File uploaded and slicing started!', 'success') - response = make_response(redirect(url_for('main.files'))) - response.set_cookie('last_quality_preset', quality_preset, max_age=60*60*24*365) - return response + flash('File uploaded successfully!', 'success') + return redirect(url_for('main.files')) - presets = get_quality_presets() - last_quality = request.cookies.get('last_quality_preset', 'base_global_standard.inst.cfg') - return render_template('slice.html', presets=presets, last_quality=last_quality) - -@main_bp.route('/files') -@login_required -def files(): # Order by newest first - user_files = PrintFile.query.filter_by(user_id=current_user.id).order_by(PrintFile.created_at.desc()).all() + user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all() return render_template('files.html', files=user_files) @main_bp.route('/api/files_status') @@ -218,12 +241,176 @@ def require_admin(): flash('Admin access required', 'danger') return redirect(url_for('main.index')) -@admin_bp.route('/settings') +@admin_bp.route('/settings', methods=['GET', 'POST']) def settings(): - configs = SystemConfig.query.all() + if request.method == 'POST': + # concurrent_slices = request.form.get('concurrent_slices') + offset_x = request.form.get('offset_x', '0') + offset_y = request.form.get('offset_y', '0') + + # update or create config entries + for key, val in [('offset_x', offset_x), ('offset_y', offset_y)]: + conf = SystemConfig.query.filter_by(key=key).first() + if not conf: + conf = SystemConfig(key=key) + db.session.add(conf) + conf.value = val + db.session.commit() + flash('Settings updated successfully', 'success') + return redirect(url_for('admin.settings')) + + configs = {c.key: c.value for c in SystemConfig.query.all()} return render_template('admin_settings.html', configs=configs) @admin_bp.route('/users') def users(): all_users = User.query.order_by(User.created_at.desc()).all() return render_template('admin_users.html', users=all_users) + + +def get_bed_dimensions(): + try: + from flask import current_app + path = os.path.join(current_app.root_path, '..', 'print_config', 'printers', 'creality_ender3v3se.def.json') + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + w = data['overrides']['machine_width']['default_value'] + h = data['overrides']['machine_depth']['default_value'] + hd = data['overrides']['machine_height']['default_value'] + return w, h, hd + except: + return 200, 200, 200 + +def get_quality_presets(): + try: + from flask import current_app + path = os.path.join(current_app.root_path, '..', 'print_config', 'presets', 'creality', 'base') + files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')] + presets = [] + for f in files: + name = f.replace('.inst.cfg', '').replace('base_', '').replace('_', ' ') + presets.append((f, name)) + presets.sort(key=lambda x: x[1]) + return presets + except: + return [] + +@main_bp.route('/plater') +@login_required +def plater(): + w, h, hd = get_bed_dimensions() + presets = get_quality_presets() + + # get offset configs + conf_x = SystemConfig.query.filter_by(key='offset_x').first() + conf_y = SystemConfig.query.filter_by(key='offset_y').first() + offset_x = float(conf_x.value) if conf_x else 0.0 + offset_y = float(conf_y.value) if conf_y else 0.0 + + last_quality = request.cookies.get('last_quality_preset', 'base_global_standard.inst.cfg') + user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all() + models = [{'id': f.id, 'name': f.original_filename, 'status': f.status, 'url': url_for('main.serve_proxy_file', file_id=f.id), 'transform_matrix': f.transform_matrix} for f in user_files] + return render_template('plater.html', w=w, h=h, hd=hd, presets=presets, last_quality=last_quality, models=models, offset_x=offset_x, offset_y=offset_y) + +@main_bp.route('/file/') +@login_required +def serve_file(file_id): + f = PrintFile.query.get_or_404(file_id) + if f.user_id != current_user.id and not current_user.is_admin: + abort(403) + path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) + return send_file(path) + +@main_bp.route('/proxy/') +@login_required +def serve_proxy_file(file_id): + f = PrintFile.query.get_or_404(file_id) + if f.user_id != current_user.id and not current_user.is_admin: + abort(403) + path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) + proxy_path = path + '.proxy.stl' + if not os.path.exists(proxy_path): + from stl_simplifier import simplify_stl + try: + simplify_stl(path, proxy_path, keep_ratio=0.1) # compress to 10% + except: + return send_file(path) # fallback to original if error + if os.path.exists(proxy_path): + return send_file(proxy_path) + return send_file(path) + +@main_bp.route('/api/merge_and_slice', methods=['POST']) +@login_required +def merge_and_slice(): + data = request.json + pieces = data.get('pieces', []) + quality = data.get('quality', 'base_global_standard.inst.cfg') + infill_density = data.get('infill', '20') + support_enable = data.get('support', 'false') + support_pattern = data.get('support_pattern', 'lines') + + if not pieces: + return jsonify({'error': 'No pieces provided'}), 400 + + inputs = [] + # Build a combined name + names = [] + for p in pieces[:3]: # Cap names at 3 to avoid super long string + f = PrintFile.query.get(p['file_id']) + if f and (f.user_id == current_user.id or current_user.is_admin): + names.append(f.original_filename.replace('.stl', '')) + + combined_name = ", ".join(names) + if len(pieces) > 3: + combined_name += "等合并切片" + else: + combined_name += "合并切片" + + for p in pieces: + f = PrintFile.query.get(p['file_id']) + if f and (f.user_id == current_user.id or current_user.is_admin): + path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) + inputs.append((path, p['matrix'])) + if 'raw_matrix' in p: + f.transform_matrix = json.dumps(p['raw_matrix']) + db.session.add(f) + + db.session.commit() + + if len(inputs) == 0: + return jsonify({'error': 'Invalid files'}), 400 + + if len(inputs) == 1: + # User is just generating gcode for a single original model, do NOT pollute list with new STL + target_file_id = pieces[0]['file_id'] + print_file = PrintFile.query.get(target_file_id) + if not print_file: + return jsonify({'error': 'Original file not found'}), 404 + print_file.status = 'merging' + db.session.commit() + + # We still need to apply transforms to a temporary STL to generate correct GCode + temp_filename = f"temp_{uuid.uuid4().hex}.stl" + temp_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], temp_filename) + + merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=True) + else: + # Multiple models, create a new "Merged Slice" PrintFile entry to keep track of combination + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + unique_filename = f"{timestamp}_{uuid.uuid4().hex}.stl" + merged_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename) + + print_file = PrintFile( + filename=unique_filename, + original_filename=f"{combined_name}.stl", + file_type='stl', + user_id=current_user.id, + status='merging' + ) + db.session.add(print_file) + db.session.commit() + + merge_and_slice_task(print_file.id, inputs, merged_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=False) + + return jsonify({'success': True, 'message': 'Plater slice queued!'}) + diff --git a/app/tasks.py b/app/tasks.py index f7a354d..d735ce9 100644 --- a/app/tasks.py +++ b/app/tasks.py @@ -8,7 +8,7 @@ huey = SqliteHuey(filename='huey_queue.db') import configparser @huey.task() -def slice_stl_task(file_id, stl_filepath, quality_preset=None): +def slice_stl_task(file_id, stl_filepath, quality_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False): # This is run by the Huey worker # We need to create an app context to interact with the database from app import create_app @@ -53,6 +53,20 @@ def slice_stl_task(file_id, stl_filepath, quality_preset=None): for key, val in config.items('values'): command.extend(['-s', f"{key}={val}"]) + if infill_density is not None: + command.extend(['-s', f"infill_sparse_density={infill_density}"]) + command.extend(['-s', f"infill_line_distance={100 / int(infill_density) if int(infill_density) > 0 else 9999}"]) + + if support_enable is not None: + command.extend(['-s', f"support_enable={'true' if support_enable == 'true' or support_enable == 'buildplate' else 'false'}"]) + command.extend(['-s', f"support_type={'buildplate' if support_enable == 'buildplate' else 'everywhere'}"]) + if support_pattern == 'tree': + command.extend(['-s', 'support_structure=tree']) + command.extend(['-s', 'support_tree_enable=true']) + elif support_pattern and support_pattern != 'false': + command.extend(['-s', 'support_structure=normal']) + command.extend(['-s', f'support_pattern={support_pattern}']) + command.extend([ "-l", stl_filepath, "-o", gcode_filepath @@ -80,6 +94,40 @@ def slice_stl_task(file_id, stl_filepath, quality_preset=None): print_file.status = 'failed' app.logger.error(f"Subprocess Exception: {e}") + if delete_stl and os.path.exists(stl_filepath): + try: + os.remove(stl_filepath) + except Exception as e: + app.logger.error(f"Failed to delete temp STL {stl_filepath}: {e}") + db.session.commit() db.session.remove() + +@huey.task() +def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False): + from app import create_app + app = create_app() + with app.app_context(): + from .models import PrintFile, db + print_file = PrintFile.query.get(file_id) + if not print_file: + return + + db.session.remove() + + try: + from stl_merger import merge_stls + merge_stls(inputs, merged_filepath) + + # Now trigger the regular slicing task + # We can just call the slicing logic or enqueue it + slice_stl_task(file_id, merged_filepath, quality_preset, infill_density, support_enable, support_pattern, delete_stl=delete_stl) + except Exception as e: + print_file = PrintFile.query.get(file_id) + if print_file: + print_file.status = 'failed' + db.session.commit() + app.logger.error(f"Merge Exception: {e}") + finally: + db.session.remove() diff --git a/app/templates/admin_settings.html b/app/templates/admin_settings.html index 10a79d7..2183117 100644 --- a/app/templates/admin_settings.html +++ b/app/templates/admin_settings.html @@ -9,12 +9,20 @@
CuraEngine Configurations

-
+
- - + + +
Adjust the X-axis compilation offset for combined files on the build plate.
- + +
+ + +
Adjust the Y-axis compilation offset for combined files on the build plate.
+
+ +
diff --git a/app/templates/base.html b/app/templates/base.html index 199b1f6..ea4d5bf 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -63,16 +63,16 @@ {{ _('Home') }} - + {% if current_user.is_authenticated and current_user.is_admin %} diff --git a/app/templates/files.html b/app/templates/files.html index 8723f8b..465d5a3 100644 --- a/app/templates/files.html +++ b/app/templates/files.html @@ -1,8 +1,28 @@ -{% extends 'base.html' %} +{% extends "base.html" %} {% block content %} -
-

{{ _('My Files') }}

+
+

{{ _('My Files') }}

+
+ +
+
+
+ +
{{ _('Drag & Drop STL file here or Click to Select') }}
+ +
+ +
+
+ {{ _('Uploading...') }} + 0% +
+
+
+
+
+
@@ -23,8 +43,12 @@ {{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') }} {{ file.original_filename }} - {% if file.status == 'waiting' or file.status == 'uploaded' %} + {% if file.status == 'waiting' %} {{ _('Waiting') }}... + {% elif file.status == 'uploaded' %} + {{ _('Uploaded') }} + {% elif file.status == 'merging' %} + {{ _('Merging') }}... {% elif file.status == 'slicing' %} {{ _('Slicing') }}... {% elif file.status == 'sliced' %} @@ -35,6 +59,7 @@
+ {{ _('Slice') }} {% if file.status == 'sliced' %} @@ -75,19 +100,21 @@ document.addEventListener('DOMContentLoaded', function() { const currentStatus = tr.getAttribute('data-status'); if (currentStatus !== status) { - // Change DOM state tr.setAttribute('data-status', status); const statusTd = document.getElementById('status-' + id); const actionsTd = document.getElementById('actions-container-' + id); - // Update Status Badge HTML correctly preserving translations - if (status === 'waiting' || status === 'uploaded') statusTd.innerHTML = '{{ _("Waiting") }}...'; + if (status === 'waiting') statusTd.innerHTML = '{{ _("Waiting") }}...'; + else if (status === 'uploaded') statusTd.innerHTML = '{{ _("Uploaded") }}'; + else if (status === 'merging') statusTd.innerHTML = '{{ _("Merging") }}...'; else if (status === 'slicing') statusTd.innerHTML = '{{ _("Slicing") }}...'; else if (status === 'sliced') statusTd.innerHTML = '{{ _("Sliced") }}'; else if (status === 'failed') statusTd.innerHTML = '{{ _("Failed") }}'; - // Update Actions HTML let actionsHtml = ''; + const platerUrl = `{{ url_for('main.plater') }}?add=${id}`; + actionsHtml += ` {{ _('Slice') }}\n`; + if (status === 'sliced') { const downloadUrl = `{{ url_for('main.download_gcode', file_id=999999999) }}`.replace('999999999', id); const previewUrl = `{{ url_for('main.preview_gcode', file_id=999999999) }}`.replace('999999999', id); @@ -101,31 +128,118 @@ document.addEventListener('DOMContentLoaded', function() { actionsTd.innerHTML = actionsHtml; } - if (status === 'waiting' || status === 'uploaded' || status === 'slicing') { + if (status === 'waiting' || status === 'slicing' || status === 'merging') { hasPending = true; } } - // Stop polling if there are no more pending files in the user's scope - if (!hasPending && pollTimer) { + if (!hasPending) { clearInterval(pollTimer); pollTimer = null; } }) - .catch(error => console.error('Error fetching file statuses:', error)); + .catch(error => console.error('Error fetching status:', error)); } + + pollTimer = setInterval(fetchStatus, checkInterval); - // Check initially if we have any pending slices - let needsPolling = false; - document.querySelectorAll('tr[id^="file-row-"]').forEach(row => { - const st = row.getAttribute('data-status'); - if (st === 'waiting' || st === 'uploaded' || st === 'slicing') { - needsPolling = true; + // Drag & Drop File Upload Logic + const dropZone = document.getElementById('drop-zone'); + const fileInput = document.getElementById('file'); + const progressContainer = document.getElementById('upload-progress-container'); + const progressBar = document.getElementById('upload-progress-bar'); + const progressText = document.getElementById('upload-progress-text'); + const statusText = document.getElementById('upload-status-text'); + + dropZone.addEventListener('click', () => fileInput.click()); + + ['dragover', 'dragenter'].forEach(evt => { + dropZone.addEventListener(evt, e => { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.add('bg-light'); + dropZone.classList.replace('border-primary', 'border-success'); + }); + }); + + ['dragleave', 'dragend', 'drop'].forEach(evt => { + dropZone.addEventListener(evt, e => { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.remove('bg-light'); + dropZone.classList.replace('border-success', 'border-primary'); + }); + }); + + dropZone.addEventListener('drop', e => { + const files = e.dataTransfer.files; + if (files.length) { + handleFileUpload(files[0]); } }); - - if (needsPolling) { - pollTimer = setInterval(fetchStatus, checkInterval); + + fileInput.addEventListener('change', () => { + if (fileInput.files.length) { + handleFileUpload(fileInput.files[0]); + } + }); + + function handleFileUpload(file) { + if (!file.name.toLowerCase().endsWith('.stl')) { + alert('{{ _("Please upload a valid .stl file!") }}'); + return; + } + + const formData = new FormData(); + formData.append('file', file); + + progressContainer.classList.remove('d-none'); + dropZone.classList.add('d-none'); + progressBar.style.width = '0%'; + progressText.innerText = '0%'; + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '{{ url_for("main.files") }}', true); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + + xhr.upload.onprogress = function(e) { + if (e.lengthComputable) { + const percentComplete = Math.floor((e.loaded / e.total) * 100); + progressBar.style.width = percentComplete + '%'; + progressText.innerText = percentComplete + '%'; + } + }; + + xhr.onload = function() { + if (xhr.status === 200) { + statusText.innerHTML = '{{ _("Upload Complete!") }}'; + progressBar.classList.replace('progress-bar-animated', 'bg-success'); + setTimeout(() => window.location.reload(), 500); + } else { + try { + let response = JSON.parse(xhr.responseText); + if (response.error) { + alert('{{ _("Validation Failed") }}:\n' + response.error); + progressContainer.classList.add('d-none'); + dropZone.classList.remove('d-none'); + return; + } + } catch(e) { + console.log('No JSON error response'); + } + alert('{{ _("Upload failed.") }}'); + progressContainer.classList.add('d-none'); + dropZone.classList.remove('d-none'); + } + }; + + xhr.onerror = function() { + alert('{{ _("Upload error.") }}'); + progressContainer.classList.add('d-none'); + dropZone.classList.remove('d-none'); + }; + + xhr.send(formData); } }); diff --git a/app/templates/plater.html b/app/templates/plater.html new file mode 100644 index 0000000..31c7b5c --- /dev/null +++ b/app/templates/plater.html @@ -0,0 +1,586 @@ +{% extends 'base.html' %} + +{% block content %} +
+

{{ _('Plater / Build Plate') }}

+
+ +
+ + + +
+
+ +
+ + + +
+ + +
+
+ + +
+
+
+ {{ _('Available Models') }} + +
+
+
+ {% for model in models %} + + {% else %} +
{{ _("No STL models uploaded yet. Go upload some first.") }}
+ {% endfor %} +
+
+
+ +
+
+ {{ _('Other Settings') }} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+ {{ _('Quality Profile') }} + +
+
+
+
+ +
+ +
+ +
+ + +
+
+
+
+
+ +
+ + + + + + + +{% endblock %} diff --git a/app/templates/slice.html b/app/templates/slice.html deleted file mode 100644 index c62c86c..0000000 --- a/app/templates/slice.html +++ /dev/null @@ -1,234 +0,0 @@ -{% extends 'base.html' %} - -{% block content %} -
-

{{ _('Upload & Slice STL') }}

-
- -
-
-
-
-
-
- -
- -

{{ _('Drag & Drop STL file here or Click to Select') }}

- -
-
- -
-
- {{ _('Uploading...') }} - 0% -
-
-
-
-
- - -
- - -
- - -
-
-
-
-
-
-
-
- -
- -
{{ _('3D Preview Area') }}
{{ _('Upload a file to display') }} -
-
-
-
-
-
- - - - - - - -{% endblock %} diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 1a3517b..3423413 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -27,6 +27,13 @@ "Actions": "Actions", "Uploaded": "Uploaded", "Waiting": "Waiting", + "Other Settings": "Other Settings", + "Infill Density": "Infill Density", + "Support": "Support", + "None": "None", + "Touching Buildplate": "Touching Buildplate", + "Everywhere": "Everywhere", + "Merging": "Merging", "Waiting in queue for slicing": "Waiting in queue for slicing", "Slicing": "Slicing", "Sliced": "Sliced", @@ -44,5 +51,6 @@ "Dynamic Quality": "Dynamic Quality", "Low Quality": "Low Quality", "Super Quality": "Super Quality", - "Ultra Quality": "Ultra Quality" + "Ultra Quality": "Ultra Quality", + "Plater": "Plater" } \ No newline at end of file diff --git a/assets/i18n/zh-cn.json b/assets/i18n/zh-cn.json index 8939242..d32d020 100644 --- a/assets/i18n/zh-cn.json +++ b/assets/i18n/zh-cn.json @@ -30,13 +30,21 @@ "Waiting in queue for slicing": "在队列中排队等待切片", "Slicing": "切片中", "Sliced": "已切片", + "Uploaded": "已上传", "Failed": "失败", + "This model has already been sliced. The existing GCode will be overwritten. Continue?": "该模型已经生成过切片,重新切片会覆盖原有GCode文件,是否继续?", + "Upload STL": "上传STL", "Download GCode": "下载 GCode", "GCode Preview": "GCode 预览", "Delete": "删除", "No files uploaded yet.": "还没有上传文件。", "Drag & Drop STL file here or Click to Select": "将 STL 文件拖放到此处或点击选择", "Uploading...": "上传中...", + "Upload Complete!": "上传完成!", + "Upload error.": "上传出错。", + "Upload failed.": "上传失败。", + "Please upload a valid .stl file!": "请上传有效的 .stl 文件!" +, "Slicing queued!": "切片已排队!", "Draft Quality": "草稿质量", "Standard Quality": "标准质量", @@ -44,5 +52,6 @@ "Dynamic Quality": "动态质量", "Low Quality": "低质量", "Super Quality": "超高质量", - "Ultra Quality": "极高质量" + "Ultra Quality": "极高质量", + "Plater": "构建板" } \ No newline at end of file diff --git a/assets/js/TransformControls.js b/assets/js/TransformControls.js new file mode 100644 index 0000000..8422db8 --- /dev/null +++ b/assets/js/TransformControls.js @@ -0,0 +1,1471 @@ +( function () { + + const _raycaster = new THREE.Raycaster(); + + const _tempVector = new THREE.Vector3(); + + const _tempVector2 = new THREE.Vector3(); + + const _tempQuaternion = new THREE.Quaternion(); + + const _unit = { + X: new THREE.Vector3( 1, 0, 0 ), + Y: new THREE.Vector3( 0, 1, 0 ), + Z: new THREE.Vector3( 0, 0, 1 ) + }; + const _changeEvent = { + type: 'change' + }; + const _mouseDownEvent = { + type: 'mouseDown' + }; + const _mouseUpEvent = { + type: 'mouseUp', + mode: null + }; + const _objectChangeEvent = { + type: 'objectChange' + }; + + class TransformControls extends THREE.Object3D { + + constructor( camera, domElement ) { + + super(); + + if ( domElement === undefined ) { + + console.warn( 'THREE.TransformControls: The second parameter "domElement" is now mandatory.' ); + domElement = document; + + } + + this.visible = false; + this.domElement = domElement; + + const _gizmo = new TransformControlsGizmo(); + + this._gizmo = _gizmo; + this.add( _gizmo ); + + const _plane = new TransformControlsPlane(); + + this._plane = _plane; + this.add( _plane ); + const scope = this; // Defined getter, setter and store for a property + + function defineProperty( propName, defaultValue ) { + + let propValue = defaultValue; + Object.defineProperty( scope, propName, { + get: function () { + + return propValue !== undefined ? propValue : defaultValue; + + }, + set: function ( value ) { + + if ( propValue !== value ) { + + propValue = value; + _plane[ propName ] = value; + _gizmo[ propName ] = value; + scope.dispatchEvent( { + type: propName + '-changed', + value: value + } ); + scope.dispatchEvent( _changeEvent ); + + } + + } + } ); + scope[ propName ] = defaultValue; + _plane[ propName ] = defaultValue; + _gizmo[ propName ] = defaultValue; + + } // Define properties with getters/setter + // Setting the defined property will automatically trigger change event + // Defined properties are passed down to gizmo and plane + + + defineProperty( 'camera', camera ); + defineProperty( 'object', undefined ); + defineProperty( 'enabled', true ); + defineProperty( 'axis', null ); + defineProperty( 'mode', 'translate' ); + defineProperty( 'translationSnap', null ); + defineProperty( 'rotationSnap', null ); + defineProperty( 'scaleSnap', null ); + defineProperty( 'space', 'world' ); + defineProperty( 'size', 1 ); + defineProperty( 'dragging', false ); + defineProperty( 'showX', true ); + defineProperty( 'showY', true ); + defineProperty( 'showZ', true ); // Reusable utility variables + + const worldPosition = new THREE.Vector3(); + const worldPositionStart = new THREE.Vector3(); + const worldQuaternion = new THREE.Quaternion(); + const worldQuaternionStart = new THREE.Quaternion(); + const cameraPosition = new THREE.Vector3(); + const cameraQuaternion = new THREE.Quaternion(); + const pointStart = new THREE.Vector3(); + const pointEnd = new THREE.Vector3(); + const rotationAxis = new THREE.Vector3(); + const rotationAngle = 0; + const eye = new THREE.Vector3(); // TODO: remove properties unused in plane and gizmo + + defineProperty( 'worldPosition', worldPosition ); + defineProperty( 'worldPositionStart', worldPositionStart ); + defineProperty( 'worldQuaternion', worldQuaternion ); + defineProperty( 'worldQuaternionStart', worldQuaternionStart ); + defineProperty( 'cameraPosition', cameraPosition ); + defineProperty( 'cameraQuaternion', cameraQuaternion ); + defineProperty( 'pointStart', pointStart ); + defineProperty( 'pointEnd', pointEnd ); + defineProperty( 'rotationAxis', rotationAxis ); + defineProperty( 'rotationAngle', rotationAngle ); + defineProperty( 'eye', eye ); + this._offset = new THREE.Vector3(); + this._startNorm = new THREE.Vector3(); + this._endNorm = new THREE.Vector3(); + this._cameraScale = new THREE.Vector3(); + this._parentPosition = new THREE.Vector3(); + this._parentQuaternion = new THREE.Quaternion(); + this._parentQuaternionInv = new THREE.Quaternion(); + this._parentScale = new THREE.Vector3(); + this._worldScaleStart = new THREE.Vector3(); + this._worldQuaternionInv = new THREE.Quaternion(); + this._worldScale = new THREE.Vector3(); + this._positionStart = new THREE.Vector3(); + this._quaternionStart = new THREE.Quaternion(); + this._scaleStart = new THREE.Vector3(); + this._getPointer = getPointer.bind( this ); + this._onPointerDown = onPointerDown.bind( this ); + this._onPointerHover = onPointerHover.bind( this ); + this._onPointerMove = onPointerMove.bind( this ); + this._onPointerUp = onPointerUp.bind( this ); + this.domElement.addEventListener( 'pointerdown', this._onPointerDown ); + this.domElement.addEventListener( 'pointermove', this._onPointerHover ); + this.domElement.ownerDocument.addEventListener( 'pointerup', this._onPointerUp ); + + } // updateMatrixWorld updates key transformation variables + + + updateMatrixWorld() { + + if ( this.object !== undefined ) { + + this.object.updateMatrixWorld(); + + if ( this.object.parent === null ) { + + console.error( 'TransformControls: The attached 3D object must be a part of the scene graph.' ); + + } else { + + this.object.parent.matrixWorld.decompose( this._parentPosition, this._parentQuaternion, this._parentScale ); + + } + + this.object.matrixWorld.decompose( this.worldPosition, this.worldQuaternion, this._worldScale ); + + this._parentQuaternionInv.copy( this._parentQuaternion ).invert(); + + this._worldQuaternionInv.copy( this.worldQuaternion ).invert(); + + } + + this.camera.updateMatrixWorld(); + this.camera.matrixWorld.decompose( this.cameraPosition, this.cameraQuaternion, this._cameraScale ); + this.eye.copy( this.cameraPosition ).sub( this.worldPosition ).normalize(); + super.updateMatrixWorld( this ); + + } + + pointerHover( pointer ) { + + if ( this.object === undefined || this.dragging === true ) return; + + _raycaster.setFromCamera( pointer, this.camera ); + + const intersect = intersectObjectWithRay( this._gizmo.picker[ this.mode ], _raycaster ); + + if ( intersect ) { + + this.axis = intersect.object.name; + + } else { + + this.axis = null; + + } + + } + + pointerDown( pointer ) { + + if ( this.object === undefined || this.dragging === true || pointer.button !== 0 ) return; + + if ( this.axis !== null ) { + + _raycaster.setFromCamera( pointer, this.camera ); + + const planeIntersect = intersectObjectWithRay( this._plane, _raycaster, true ); + + if ( planeIntersect ) { + + let space = this.space; + + if ( this.mode === 'scale' ) { + + space = 'local'; + + } else if ( this.axis === 'E' || this.axis === 'XYZE' || this.axis === 'XYZ' ) { + + space = 'world'; + + } + + if ( space === 'local' && this.mode === 'rotate' ) { + + const snap = this.rotationSnap; + if ( this.axis === 'X' && snap ) this.object.rotation.x = Math.round( this.object.rotation.x / snap ) * snap; + if ( this.axis === 'Y' && snap ) this.object.rotation.y = Math.round( this.object.rotation.y / snap ) * snap; + if ( this.axis === 'Z' && snap ) this.object.rotation.z = Math.round( this.object.rotation.z / snap ) * snap; + + } + + this.object.updateMatrixWorld(); + this.object.parent.updateMatrixWorld(); + + this._positionStart.copy( this.object.position ); + + this._quaternionStart.copy( this.object.quaternion ); + + this._scaleStart.copy( this.object.scale ); + + this.object.matrixWorld.decompose( this.worldPositionStart, this.worldQuaternionStart, this._worldScaleStart ); + this.pointStart.copy( planeIntersect.point ).sub( this.worldPositionStart ); + + } + + this.dragging = true; + _mouseDownEvent.mode = this.mode; + this.dispatchEvent( _mouseDownEvent ); + + } + + } + + pointerMove( pointer ) { + + const axis = this.axis; + const mode = this.mode; + const object = this.object; + let space = this.space; + + if ( mode === 'scale' ) { + + space = 'local'; + + } else if ( axis === 'E' || axis === 'XYZE' || axis === 'XYZ' ) { + + space = 'world'; + + } + + if ( object === undefined || axis === null || this.dragging === false || pointer.button !== - 1 ) return; + + _raycaster.setFromCamera( pointer, this.camera ); + + const planeIntersect = intersectObjectWithRay( this._plane, _raycaster, true ); + if ( ! planeIntersect ) return; + this.pointEnd.copy( planeIntersect.point ).sub( this.worldPositionStart ); + + if ( mode === 'translate' ) { + + // Apply translate + this._offset.copy( this.pointEnd ).sub( this.pointStart ); + + if ( space === 'local' && axis !== 'XYZ' ) { + + this._offset.applyQuaternion( this._worldQuaternionInv ); + + } + + if ( axis.indexOf( 'X' ) === - 1 ) this._offset.x = 0; + if ( axis.indexOf( 'Y' ) === - 1 ) this._offset.y = 0; + if ( axis.indexOf( 'Z' ) === - 1 ) this._offset.z = 0; + + if ( space === 'local' && axis !== 'XYZ' ) { + + this._offset.applyQuaternion( this._quaternionStart ).divide( this._parentScale ); + + } else { + + this._offset.applyQuaternion( this._parentQuaternionInv ).divide( this._parentScale ); + + } + + object.position.copy( this._offset ).add( this._positionStart ); // Apply translation snap + + if ( this.translationSnap ) { + + if ( space === 'local' ) { + + object.position.applyQuaternion( _tempQuaternion.copy( this._quaternionStart ).invert() ); + + if ( axis.search( 'X' ) !== - 1 ) { + + object.position.x = Math.round( object.position.x / this.translationSnap ) * this.translationSnap; + + } + + if ( axis.search( 'Y' ) !== - 1 ) { + + object.position.y = Math.round( object.position.y / this.translationSnap ) * this.translationSnap; + + } + + if ( axis.search( 'Z' ) !== - 1 ) { + + object.position.z = Math.round( object.position.z / this.translationSnap ) * this.translationSnap; + + } + + object.position.applyQuaternion( this._quaternionStart ); + + } + + if ( space === 'world' ) { + + if ( object.parent ) { + + object.position.add( _tempVector.setFromMatrixPosition( object.parent.matrixWorld ) ); + + } + + if ( axis.search( 'X' ) !== - 1 ) { + + object.position.x = Math.round( object.position.x / this.translationSnap ) * this.translationSnap; + + } + + if ( axis.search( 'Y' ) !== - 1 ) { + + object.position.y = Math.round( object.position.y / this.translationSnap ) * this.translationSnap; + + } + + if ( axis.search( 'Z' ) !== - 1 ) { + + object.position.z = Math.round( object.position.z / this.translationSnap ) * this.translationSnap; + + } + + if ( object.parent ) { + + object.position.sub( _tempVector.setFromMatrixPosition( object.parent.matrixWorld ) ); + + } + + } + + } + + } else if ( mode === 'scale' ) { + + if ( axis.search( 'XYZ' ) !== - 1 ) { + + let d = this.pointEnd.length() / this.pointStart.length(); + if ( this.pointEnd.dot( this.pointStart ) < 0 ) d *= - 1; + + _tempVector2.set( d, d, d ); + + } else { + + _tempVector.copy( this.pointStart ); + + _tempVector2.copy( this.pointEnd ); + + _tempVector.applyQuaternion( this._worldQuaternionInv ); + + _tempVector2.applyQuaternion( this._worldQuaternionInv ); + + _tempVector2.divide( _tempVector ); + + if ( axis.search( 'X' ) === - 1 ) { + + _tempVector2.x = 1; + + } + + if ( axis.search( 'Y' ) === - 1 ) { + + _tempVector2.y = 1; + + } + + if ( axis.search( 'Z' ) === - 1 ) { + + _tempVector2.z = 1; + + } + + } // Apply scale + + + object.scale.copy( this._scaleStart ).multiply( _tempVector2 ); + + if ( this.scaleSnap ) { + + if ( axis.search( 'X' ) !== - 1 ) { + + object.scale.x = Math.round( object.scale.x / this.scaleSnap ) * this.scaleSnap || this.scaleSnap; + + } + + if ( axis.search( 'Y' ) !== - 1 ) { + + object.scale.y = Math.round( object.scale.y / this.scaleSnap ) * this.scaleSnap || this.scaleSnap; + + } + + if ( axis.search( 'Z' ) !== - 1 ) { + + object.scale.z = Math.round( object.scale.z / this.scaleSnap ) * this.scaleSnap || this.scaleSnap; + + } + + } + + } else if ( mode === 'rotate' ) { + + this._offset.copy( this.pointEnd ).sub( this.pointStart ); + + const ROTATION_SPEED = 20 / this.worldPosition.distanceTo( _tempVector.setFromMatrixPosition( this.camera.matrixWorld ) ); + + if ( axis === 'E' ) { + + this.rotationAxis.copy( this.eye ); + this.rotationAngle = this.pointEnd.angleTo( this.pointStart ); + + this._startNorm.copy( this.pointStart ).normalize(); + + this._endNorm.copy( this.pointEnd ).normalize(); + + this.rotationAngle *= this._endNorm.cross( this._startNorm ).dot( this.eye ) < 0 ? 1 : - 1; + + } else if ( axis === 'XYZE' ) { + + this.rotationAxis.copy( this._offset ).cross( this.eye ).normalize(); + this.rotationAngle = this._offset.dot( _tempVector.copy( this.rotationAxis ).cross( this.eye ) ) * ROTATION_SPEED; + + } else if ( axis === 'X' || axis === 'Y' || axis === 'Z' ) { + + this.rotationAxis.copy( _unit[ axis ] ); + + _tempVector.copy( _unit[ axis ] ); + + if ( space === 'local' ) { + + _tempVector.applyQuaternion( this.worldQuaternion ); + + } + + this.rotationAngle = this._offset.dot( _tempVector.cross( this.eye ).normalize() ) * ROTATION_SPEED; + + } // Apply rotation snap + + + if ( this.rotationSnap ) this.rotationAngle = Math.round( this.rotationAngle / this.rotationSnap ) * this.rotationSnap; // Apply rotate + + if ( space === 'local' && axis !== 'E' && axis !== 'XYZE' ) { + + object.quaternion.copy( this._quaternionStart ); + object.quaternion.multiply( _tempQuaternion.setFromAxisAngle( this.rotationAxis, this.rotationAngle ) ).normalize(); + + } else { + + this.rotationAxis.applyQuaternion( this._parentQuaternionInv ); + object.quaternion.copy( _tempQuaternion.setFromAxisAngle( this.rotationAxis, this.rotationAngle ) ); + object.quaternion.multiply( this._quaternionStart ).normalize(); + + } + + } + + this.dispatchEvent( _changeEvent ); + this.dispatchEvent( _objectChangeEvent ); + + } + + pointerUp( pointer ) { + + if ( pointer.button !== 0 ) return; + + if ( this.dragging && this.axis !== null ) { + + _mouseUpEvent.mode = this.mode; + this.dispatchEvent( _mouseUpEvent ); + + } + + this.dragging = false; + this.axis = null; + + } + + dispose() { + + this.domElement.removeEventListener( 'pointerdown', this._onPointerDown ); + this.domElement.removeEventListener( 'pointermove', this._onPointerHover ); + this.domElement.ownerDocument.removeEventListener( 'pointermove', this._onPointerMove ); + this.domElement.ownerDocument.removeEventListener( 'pointerup', this._onPointerUp ); + this.traverse( function ( child ) { + + if ( child.geometry ) child.geometry.dispose(); + if ( child.material ) child.material.dispose(); + + } ); + + } // Set current object + + + attach( object ) { + + this.object = object; + this.visible = true; + return this; + + } // Detatch from object + + + detach() { + + this.object = undefined; + this.visible = false; + this.axis = null; + return this; + + } // TODO: deprecate + + + getMode() { + + return this.mode; + + } + + setMode( mode ) { + + this.mode = mode; + + } + + setTranslationSnap( translationSnap ) { + + this.translationSnap = translationSnap; + + } + + setRotationSnap( rotationSnap ) { + + this.rotationSnap = rotationSnap; + + } + + setScaleSnap( scaleSnap ) { + + this.scaleSnap = scaleSnap; + + } + + setSize( size ) { + + this.size = size; + + } + + setSpace( space ) { + + this.space = space; + + } + + update() { + + console.warn( 'THREE.TransformControls: update function has no more functionality and therefore has been deprecated.' ); + + } + + } + + TransformControls.prototype.isTransformControls = true; // mouse / touch event handlers + + function getPointer( event ) { + + if ( this.domElement.ownerDocument.pointerLockElement ) { + + return { + x: 0, + y: 0, + button: event.button + }; + + } else { + + const pointer = event.changedTouches ? event.changedTouches[ 0 ] : event; + const rect = this.domElement.getBoundingClientRect(); + return { + x: ( pointer.clientX - rect.left ) / rect.width * 2 - 1, + y: - ( pointer.clientY - rect.top ) / rect.height * 2 + 1, + button: event.button + }; + + } + + } + + function onPointerHover( event ) { + + if ( ! this.enabled ) return; + + switch ( event.pointerType ) { + + case 'mouse': + case 'pen': + this.pointerHover( this._getPointer( event ) ); + break; + + } + + } + + function onPointerDown( event ) { + + if ( ! this.enabled ) return; + this.domElement.style.touchAction = 'none'; // disable touch scroll + + this.domElement.ownerDocument.addEventListener( 'pointermove', this._onPointerMove ); + this.pointerHover( this._getPointer( event ) ); + this.pointerDown( this._getPointer( event ) ); + + } + + function onPointerMove( event ) { + + if ( ! this.enabled ) return; + this.pointerMove( this._getPointer( event ) ); + + } + + function onPointerUp( event ) { + + if ( ! this.enabled ) return; + this.domElement.style.touchAction = ''; + this.domElement.ownerDocument.removeEventListener( 'pointermove', this._onPointerMove ); + this.pointerUp( this._getPointer( event ) ); + + } + + function intersectObjectWithRay( object, raycaster, includeInvisible ) { + + const allIntersections = raycaster.intersectObject( object, true ); + + for ( let i = 0; i < allIntersections.length; i ++ ) { + + if ( allIntersections[ i ].object.visible || includeInvisible ) { + + return allIntersections[ i ]; + + } + + } + + return false; + + } // + // Reusable utility variables + + + const _tempEuler = new THREE.Euler(); + + const _alignVector = new THREE.Vector3( 0, 1, 0 ); + + const _zeroVector = new THREE.Vector3( 0, 0, 0 ); + + const _lookAtMatrix = new THREE.Matrix4(); + + const _tempQuaternion2 = new THREE.Quaternion(); + + const _identityQuaternion = new THREE.Quaternion(); + + const _dirVector = new THREE.Vector3(); + + const _tempMatrix = new THREE.Matrix4(); + + const _unitX = new THREE.Vector3( 1, 0, 0 ); + + const _unitY = new THREE.Vector3( 0, 1, 0 ); + + const _unitZ = new THREE.Vector3( 0, 0, 1 ); + + const _v1 = new THREE.Vector3(); + + const _v2 = new THREE.Vector3(); + + const _v3 = new THREE.Vector3(); + + class TransformControlsGizmo extends THREE.Object3D { + + constructor() { + + super(); + this.type = 'TransformControlsGizmo'; // shared materials + + const gizmoMaterial = new THREE.MeshBasicMaterial( { + depthTest: false, + depthWrite: false, + transparent: true, + side: THREE.DoubleSide, + fog: false, + toneMapped: false + } ); + const gizmoLineMaterial = new THREE.LineBasicMaterial( { + depthTest: false, + depthWrite: false, + transparent: true, + linewidth: 1, + fog: false, + toneMapped: false + } ); // Make unique material for each axis/color + + const matInvisible = gizmoMaterial.clone(); + matInvisible.opacity = 0.15; + const matHelper = gizmoMaterial.clone(); + matHelper.opacity = 0.33; + const matRed = gizmoMaterial.clone(); + matRed.color.set( 0xff0000 ); + const matGreen = gizmoMaterial.clone(); + matGreen.color.set( 0x00ff00 ); + const matBlue = gizmoMaterial.clone(); + matBlue.color.set( 0x0000ff ); + const matWhiteTransparent = gizmoMaterial.clone(); + matWhiteTransparent.opacity = 0.25; + const matYellowTransparent = matWhiteTransparent.clone(); + matYellowTransparent.color.set( 0xffff00 ); + const matCyanTransparent = matWhiteTransparent.clone(); + matCyanTransparent.color.set( 0x00ffff ); + const matMagentaTransparent = matWhiteTransparent.clone(); + matMagentaTransparent.color.set( 0xff00ff ); + const matYellow = gizmoMaterial.clone(); + matYellow.color.set( 0xffff00 ); + const matLineRed = gizmoLineMaterial.clone(); + matLineRed.color.set( 0xff0000 ); + const matLineGreen = gizmoLineMaterial.clone(); + matLineGreen.color.set( 0x00ff00 ); + const matLineBlue = gizmoLineMaterial.clone(); + matLineBlue.color.set( 0x0000ff ); + const matLineCyan = gizmoLineMaterial.clone(); + matLineCyan.color.set( 0x00ffff ); + const matLineMagenta = gizmoLineMaterial.clone(); + matLineMagenta.color.set( 0xff00ff ); + const matLineYellow = gizmoLineMaterial.clone(); + matLineYellow.color.set( 0xffff00 ); + const matLineGray = gizmoLineMaterial.clone(); + matLineGray.color.set( 0x787878 ); + const matLineYellowTransparent = matLineYellow.clone(); + matLineYellowTransparent.opacity = 0.25; // reusable geometry + + const arrowGeometry = new THREE.CylinderGeometry( 0, 0.05, 0.2, 12, 1, false ); + const scaleHandleGeometry = new THREE.BoxGeometry( 0.125, 0.125, 0.125 ); + const lineGeometry = new THREE.BufferGeometry(); + lineGeometry.setAttribute( 'position', new THREE.Float32BufferAttribute( [ 0, 0, 0, 1, 0, 0 ], 3 ) ); + + function CircleGeometry( radius, arc ) { + + const geometry = new THREE.BufferGeometry(); + const vertices = []; + + for ( let i = 0; i <= 64 * arc; ++ i ) { + + vertices.push( 0, Math.cos( i / 32 * Math.PI ) * radius, Math.sin( i / 32 * Math.PI ) * radius ); + + } + + geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( vertices, 3 ) ); + return geometry; + + } // Special geometry for transform helper. If scaled with position vector it spans from [0,0,0] to position + + + function TranslateHelperGeometry() { + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( [ 0, 0, 0, 1, 1, 1 ], 3 ) ); + return geometry; + + } // Gizmo definitions - custom hierarchy definitions for setupGizmo() function + + + const gizmoTranslate = { + X: [[ new THREE.Mesh( arrowGeometry, matRed ), [ 1, 0, 0 ], [ 0, 0, - Math.PI / 2 ], null, 'fwd' ], [ new THREE.Mesh( arrowGeometry, matRed ), [ 1, 0, 0 ], [ 0, 0, Math.PI / 2 ], null, 'bwd' ], [ new THREE.Line( lineGeometry, matLineRed ) ]], + Y: [[ new THREE.Mesh( arrowGeometry, matGreen ), [ 0, 1, 0 ], null, null, 'fwd' ], [ new THREE.Mesh( arrowGeometry, matGreen ), [ 0, 1, 0 ], [ Math.PI, 0, 0 ], null, 'bwd' ], [ new THREE.Line( lineGeometry, matLineGreen ), null, [ 0, 0, Math.PI / 2 ]]], + Z: [[ new THREE.Mesh( arrowGeometry, matBlue ), [ 0, 0, 1 ], [ Math.PI / 2, 0, 0 ], null, 'fwd' ], [ new THREE.Mesh( arrowGeometry, matBlue ), [ 0, 0, 1 ], [ - Math.PI / 2, 0, 0 ], null, 'bwd' ], [ new THREE.Line( lineGeometry, matLineBlue ), null, [ 0, - Math.PI / 2, 0 ]]], + XYZ: [[ new THREE.Mesh( new THREE.OctahedronGeometry( 0.1, 0 ), matWhiteTransparent.clone() ), [ 0, 0, 0 ], [ 0, 0, 0 ]]], + XY: [[ new THREE.Mesh( new THREE.PlaneGeometry( 0.295, 0.295 ), matYellowTransparent.clone() ), [ 0.15, 0.15, 0 ]], [ new THREE.Line( lineGeometry, matLineYellow ), [ 0.18, 0.3, 0 ], null, [ 0.125, 1, 1 ]], [ new THREE.Line( lineGeometry, matLineYellow ), [ 0.3, 0.18, 0 ], [ 0, 0, Math.PI / 2 ], [ 0.125, 1, 1 ]]], + YZ: [[ new THREE.Mesh( new THREE.PlaneGeometry( 0.295, 0.295 ), matCyanTransparent.clone() ), [ 0, 0.15, 0.15 ], [ 0, Math.PI / 2, 0 ]], [ new THREE.Line( lineGeometry, matLineCyan ), [ 0, 0.18, 0.3 ], [ 0, 0, Math.PI / 2 ], [ 0.125, 1, 1 ]], [ new THREE.Line( lineGeometry, matLineCyan ), [ 0, 0.3, 0.18 ], [ 0, - Math.PI / 2, 0 ], [ 0.125, 1, 1 ]]], + XZ: [[ new THREE.Mesh( new THREE.PlaneGeometry( 0.295, 0.295 ), matMagentaTransparent.clone() ), [ 0.15, 0, 0.15 ], [ - Math.PI / 2, 0, 0 ]], [ new THREE.Line( lineGeometry, matLineMagenta ), [ 0.18, 0, 0.3 ], null, [ 0.125, 1, 1 ]], [ new THREE.Line( lineGeometry, matLineMagenta ), [ 0.3, 0, 0.18 ], [ 0, - Math.PI / 2, 0 ], [ 0.125, 1, 1 ]]] + }; + const pickerTranslate = { + X: [[ new THREE.Mesh( new THREE.CylinderGeometry( 0.2, 0, 1, 4, 1, false ), matInvisible ), [ 0.6, 0, 0 ], [ 0, 0, - Math.PI / 2 ]]], + Y: [[ new THREE.Mesh( new THREE.CylinderGeometry( 0.2, 0, 1, 4, 1, false ), matInvisible ), [ 0, 0.6, 0 ]]], + Z: [[ new THREE.Mesh( new THREE.CylinderGeometry( 0.2, 0, 1, 4, 1, false ), matInvisible ), [ 0, 0, 0.6 ], [ Math.PI / 2, 0, 0 ]]], + XYZ: [[ new THREE.Mesh( new THREE.OctahedronGeometry( 0.2, 0 ), matInvisible ) ]], + XY: [[ new THREE.Mesh( new THREE.PlaneGeometry( 0.4, 0.4 ), matInvisible ), [ 0.2, 0.2, 0 ]]], + YZ: [[ new THREE.Mesh( new THREE.PlaneGeometry( 0.4, 0.4 ), matInvisible ), [ 0, 0.2, 0.2 ], [ 0, Math.PI / 2, 0 ]]], + XZ: [[ new THREE.Mesh( new THREE.PlaneGeometry( 0.4, 0.4 ), matInvisible ), [ 0.2, 0, 0.2 ], [ - Math.PI / 2, 0, 0 ]]] + }; + const helperTranslate = { + START: [[ new THREE.Mesh( new THREE.OctahedronGeometry( 0.01, 2 ), matHelper ), null, null, null, 'helper' ]], + END: [[ new THREE.Mesh( new THREE.OctahedronGeometry( 0.01, 2 ), matHelper ), null, null, null, 'helper' ]], + DELTA: [[ new THREE.Line( TranslateHelperGeometry(), matHelper ), null, null, null, 'helper' ]], + X: [[ new THREE.Line( lineGeometry, matHelper.clone() ), [ - 1e3, 0, 0 ], null, [ 1e6, 1, 1 ], 'helper' ]], + Y: [[ new THREE.Line( lineGeometry, matHelper.clone() ), [ 0, - 1e3, 0 ], [ 0, 0, Math.PI / 2 ], [ 1e6, 1, 1 ], 'helper' ]], + Z: [[ new THREE.Line( lineGeometry, matHelper.clone() ), [ 0, 0, - 1e3 ], [ 0, - Math.PI / 2, 0 ], [ 1e6, 1, 1 ], 'helper' ]] + }; + const gizmoRotate = { + X: [[ new THREE.Line( CircleGeometry( 1, 0.5 ), matLineRed ) ], [ new THREE.Mesh( new THREE.OctahedronGeometry( 0.04, 0 ), matRed ), [ 0, 0, 0.99 ], null, [ 1, 3, 1 ]]], + Y: [[ new THREE.Line( CircleGeometry( 1, 0.5 ), matLineGreen ), null, [ 0, 0, - Math.PI / 2 ]], [ new THREE.Mesh( new THREE.OctahedronGeometry( 0.04, 0 ), matGreen ), [ 0, 0, 0.99 ], null, [ 3, 1, 1 ]]], + Z: [[ new THREE.Line( CircleGeometry( 1, 0.5 ), matLineBlue ), null, [ 0, Math.PI / 2, 0 ]], [ new THREE.Mesh( new THREE.OctahedronGeometry( 0.04, 0 ), matBlue ), [ 0.99, 0, 0 ], null, [ 1, 3, 1 ]]], + E: [[ new THREE.Line( CircleGeometry( 1.25, 1 ), matLineYellowTransparent ), null, [ 0, Math.PI / 2, 0 ]], [ new THREE.Mesh( new THREE.CylinderGeometry( 0.03, 0, 0.15, 4, 1, false ), matLineYellowTransparent ), [ 1.17, 0, 0 ], [ 0, 0, - Math.PI / 2 ], [ 1, 1, 0.001 ]], [ new THREE.Mesh( new THREE.CylinderGeometry( 0.03, 0, 0.15, 4, 1, false ), matLineYellowTransparent ), [ - 1.17, 0, 0 ], [ 0, 0, Math.PI / 2 ], [ 1, 1, 0.001 ]], [ new THREE.Mesh( new THREE.CylinderGeometry( 0.03, 0, 0.15, 4, 1, false ), matLineYellowTransparent ), [ 0, - 1.17, 0 ], [ Math.PI, 0, 0 ], [ 1, 1, 0.001 ]], [ new THREE.Mesh( new THREE.CylinderGeometry( 0.03, 0, 0.15, 4, 1, false ), matLineYellowTransparent ), [ 0, 1.17, 0 ], [ 0, 0, 0 ], [ 1, 1, 0.001 ]]], + XYZE: [[ new THREE.Line( CircleGeometry( 1, 1 ), matLineGray ), null, [ 0, Math.PI / 2, 0 ]]] + }; + const helperRotate = { + AXIS: [[ new THREE.Line( lineGeometry, matHelper.clone() ), [ - 1e3, 0, 0 ], null, [ 1e6, 1, 1 ], 'helper' ]] + }; + const pickerRotate = { + X: [[ new THREE.Mesh( new THREE.TorusGeometry( 1, 0.1, 4, 24 ), matInvisible ), [ 0, 0, 0 ], [ 0, - Math.PI / 2, - Math.PI / 2 ]]], + Y: [[ new THREE.Mesh( new THREE.TorusGeometry( 1, 0.1, 4, 24 ), matInvisible ), [ 0, 0, 0 ], [ Math.PI / 2, 0, 0 ]]], + Z: [[ new THREE.Mesh( new THREE.TorusGeometry( 1, 0.1, 4, 24 ), matInvisible ), [ 0, 0, 0 ], [ 0, 0, - Math.PI / 2 ]]], + E: [[ new THREE.Mesh( new THREE.TorusGeometry( 1.25, 0.1, 2, 24 ), matInvisible ) ]], + XYZE: [[ new THREE.Mesh( new THREE.SphereGeometry( 0.7, 10, 8 ), matInvisible ) ]] + }; + const gizmoScale = { + X: [[ new THREE.Mesh( scaleHandleGeometry, matRed ), [ 0.8, 0, 0 ], [ 0, 0, - Math.PI / 2 ]], [ new THREE.Line( lineGeometry, matLineRed ), null, null, [ 0.8, 1, 1 ]]], + Y: [[ new THREE.Mesh( scaleHandleGeometry, matGreen ), [ 0, 0.8, 0 ]], [ new THREE.Line( lineGeometry, matLineGreen ), null, [ 0, 0, Math.PI / 2 ], [ 0.8, 1, 1 ]]], + Z: [[ new THREE.Mesh( scaleHandleGeometry, matBlue ), [ 0, 0, 0.8 ], [ Math.PI / 2, 0, 0 ]], [ new THREE.Line( lineGeometry, matLineBlue ), null, [ 0, - Math.PI / 2, 0 ], [ 0.8, 1, 1 ]]], + XY: [[ new THREE.Mesh( scaleHandleGeometry, matYellowTransparent ), [ 0.85, 0.85, 0 ], null, [ 2, 2, 0.2 ]], [ new THREE.Line( lineGeometry, matLineYellow ), [ 0.855, 0.98, 0 ], null, [ 0.125, 1, 1 ]], [ new THREE.Line( lineGeometry, matLineYellow ), [ 0.98, 0.855, 0 ], [ 0, 0, Math.PI / 2 ], [ 0.125, 1, 1 ]]], + YZ: [[ new THREE.Mesh( scaleHandleGeometry, matCyanTransparent ), [ 0, 0.85, 0.85 ], null, [ 0.2, 2, 2 ]], [ new THREE.Line( lineGeometry, matLineCyan ), [ 0, 0.855, 0.98 ], [ 0, 0, Math.PI / 2 ], [ 0.125, 1, 1 ]], [ new THREE.Line( lineGeometry, matLineCyan ), [ 0, 0.98, 0.855 ], [ 0, - Math.PI / 2, 0 ], [ 0.125, 1, 1 ]]], + XZ: [[ new THREE.Mesh( scaleHandleGeometry, matMagentaTransparent ), [ 0.85, 0, 0.85 ], null, [ 2, 0.2, 2 ]], [ new THREE.Line( lineGeometry, matLineMagenta ), [ 0.855, 0, 0.98 ], null, [ 0.125, 1, 1 ]], [ new THREE.Line( lineGeometry, matLineMagenta ), [ 0.98, 0, 0.855 ], [ 0, - Math.PI / 2, 0 ], [ 0.125, 1, 1 ]]], + XYZX: [[ new THREE.Mesh( new THREE.BoxGeometry( 0.125, 0.125, 0.125 ), matWhiteTransparent.clone() ), [ 1.1, 0, 0 ]]], + XYZY: [[ new THREE.Mesh( new THREE.BoxGeometry( 0.125, 0.125, 0.125 ), matWhiteTransparent.clone() ), [ 0, 1.1, 0 ]]], + XYZZ: [[ new THREE.Mesh( new THREE.BoxGeometry( 0.125, 0.125, 0.125 ), matWhiteTransparent.clone() ), [ 0, 0, 1.1 ]]] + }; + const pickerScale = { + X: [[ new THREE.Mesh( new THREE.CylinderGeometry( 0.2, 0, 0.8, 4, 1, false ), matInvisible ), [ 0.5, 0, 0 ], [ 0, 0, - Math.PI / 2 ]]], + Y: [[ new THREE.Mesh( new THREE.CylinderGeometry( 0.2, 0, 0.8, 4, 1, false ), matInvisible ), [ 0, 0.5, 0 ]]], + Z: [[ new THREE.Mesh( new THREE.CylinderGeometry( 0.2, 0, 0.8, 4, 1, false ), matInvisible ), [ 0, 0, 0.5 ], [ Math.PI / 2, 0, 0 ]]], + XY: [[ new THREE.Mesh( scaleHandleGeometry, matInvisible ), [ 0.85, 0.85, 0 ], null, [ 3, 3, 0.2 ]]], + YZ: [[ new THREE.Mesh( scaleHandleGeometry, matInvisible ), [ 0, 0.85, 0.85 ], null, [ 0.2, 3, 3 ]]], + XZ: [[ new THREE.Mesh( scaleHandleGeometry, matInvisible ), [ 0.85, 0, 0.85 ], null, [ 3, 0.2, 3 ]]], + XYZX: [[ new THREE.Mesh( new THREE.BoxGeometry( 0.2, 0.2, 0.2 ), matInvisible ), [ 1.1, 0, 0 ]]], + XYZY: [[ new THREE.Mesh( new THREE.BoxGeometry( 0.2, 0.2, 0.2 ), matInvisible ), [ 0, 1.1, 0 ]]], + XYZZ: [[ new THREE.Mesh( new THREE.BoxGeometry( 0.2, 0.2, 0.2 ), matInvisible ), [ 0, 0, 1.1 ]]] + }; + const helperScale = { + X: [[ new THREE.Line( lineGeometry, matHelper.clone() ), [ - 1e3, 0, 0 ], null, [ 1e6, 1, 1 ], 'helper' ]], + Y: [[ new THREE.Line( lineGeometry, matHelper.clone() ), [ 0, - 1e3, 0 ], [ 0, 0, Math.PI / 2 ], [ 1e6, 1, 1 ], 'helper' ]], + Z: [[ new THREE.Line( lineGeometry, matHelper.clone() ), [ 0, 0, - 1e3 ], [ 0, - Math.PI / 2, 0 ], [ 1e6, 1, 1 ], 'helper' ]] + }; // Creates an THREE.Object3D with gizmos described in custom hierarchy definition. + + function setupGizmo( gizmoMap ) { + + const gizmo = new THREE.Object3D(); + + for ( const name in gizmoMap ) { + + for ( let i = gizmoMap[ name ].length; i --; ) { + + const object = gizmoMap[ name ][ i ][ 0 ].clone(); + const position = gizmoMap[ name ][ i ][ 1 ]; + const rotation = gizmoMap[ name ][ i ][ 2 ]; + const scale = gizmoMap[ name ][ i ][ 3 ]; + const tag = gizmoMap[ name ][ i ][ 4 ]; // name and tag properties are essential for picking and updating logic. + + object.name = name; + object.tag = tag; + + if ( position ) { + + object.position.set( position[ 0 ], position[ 1 ], position[ 2 ] ); + + } + + if ( rotation ) { + + object.rotation.set( rotation[ 0 ], rotation[ 1 ], rotation[ 2 ] ); + + } + + if ( scale ) { + + object.scale.set( scale[ 0 ], scale[ 1 ], scale[ 2 ] ); + + } + + object.updateMatrix(); + const tempGeometry = object.geometry.clone(); + tempGeometry.applyMatrix4( object.matrix ); + object.geometry = tempGeometry; + object.renderOrder = Infinity; + object.position.set( 0, 0, 0 ); + object.rotation.set( 0, 0, 0 ); + object.scale.set( 1, 1, 1 ); + gizmo.add( object ); + + } + + } + + return gizmo; + + } // Gizmo creation + + + this.gizmo = {}; + this.picker = {}; + this.helper = {}; + this.add( this.gizmo[ 'translate' ] = setupGizmo( gizmoTranslate ) ); + this.add( this.gizmo[ 'rotate' ] = setupGizmo( gizmoRotate ) ); + this.add( this.gizmo[ 'scale' ] = setupGizmo( gizmoScale ) ); + this.add( this.picker[ 'translate' ] = setupGizmo( pickerTranslate ) ); + this.add( this.picker[ 'rotate' ] = setupGizmo( pickerRotate ) ); + this.add( this.picker[ 'scale' ] = setupGizmo( pickerScale ) ); + this.add( this.helper[ 'translate' ] = setupGizmo( helperTranslate ) ); + this.add( this.helper[ 'rotate' ] = setupGizmo( helperRotate ) ); + this.add( this.helper[ 'scale' ] = setupGizmo( helperScale ) ); // Pickers should be hidden always + + this.picker[ 'translate' ].visible = false; + this.picker[ 'rotate' ].visible = false; + this.picker[ 'scale' ].visible = false; + + } // updateMatrixWorld will update transformations and appearance of individual handles + + + updateMatrixWorld( force ) { + + const space = this.mode === 'scale' ? this.space : 'local'; // scale always oriented to local rotation + + const quaternion = space === 'local' ? this.worldQuaternion : _identityQuaternion; // Show only gizmos for current transform mode + + this.gizmo[ 'translate' ].visible = this.mode === 'translate'; + this.gizmo[ 'rotate' ].visible = this.mode === 'rotate'; + this.gizmo[ 'scale' ].visible = this.mode === 'scale'; + this.helper[ 'translate' ].visible = this.mode === 'translate'; + this.helper[ 'rotate' ].visible = this.mode === 'rotate'; + this.helper[ 'scale' ].visible = this.mode === 'scale'; + let handles = []; + handles = handles.concat( this.picker[ this.mode ].children ); + handles = handles.concat( this.gizmo[ this.mode ].children ); + handles = handles.concat( this.helper[ this.mode ].children ); + + for ( let i = 0; i < handles.length; i ++ ) { + + const handle = handles[ i ]; // hide aligned to camera + + handle.visible = true; + handle.rotation.set( 0, 0, 0 ); + handle.position.copy( this.worldPosition ); + let factor; + + if ( this.camera.isOrthographicCamera ) { + + factor = ( this.camera.top - this.camera.bottom ) / this.camera.zoom; + + } else { + + factor = this.worldPosition.distanceTo( this.cameraPosition ) * Math.min( 1.9 * Math.tan( Math.PI * this.camera.fov / 360 ) / this.camera.zoom, 7 ); + + } + + handle.scale.set( 1, 1, 1 ).multiplyScalar( factor * this.size / 7 ); // TODO: simplify helpers and consider decoupling from gizmo + + if ( handle.tag === 'helper' ) { + + handle.visible = false; + + if ( handle.name === 'AXIS' ) { + + handle.position.copy( this.worldPositionStart ); + handle.visible = !! this.axis; + + if ( this.axis === 'X' ) { + + _tempQuaternion.setFromEuler( _tempEuler.set( 0, 0, 0 ) ); + + handle.quaternion.copy( quaternion ).multiply( _tempQuaternion ); + + if ( Math.abs( _alignVector.copy( _unitX ).applyQuaternion( quaternion ).dot( this.eye ) ) > 0.9 ) { + + handle.visible = false; + + } + + } + + if ( this.axis === 'Y' ) { + + _tempQuaternion.setFromEuler( _tempEuler.set( 0, 0, Math.PI / 2 ) ); + + handle.quaternion.copy( quaternion ).multiply( _tempQuaternion ); + + if ( Math.abs( _alignVector.copy( _unitY ).applyQuaternion( quaternion ).dot( this.eye ) ) > 0.9 ) { + + handle.visible = false; + + } + + } + + if ( this.axis === 'Z' ) { + + _tempQuaternion.setFromEuler( _tempEuler.set( 0, Math.PI / 2, 0 ) ); + + handle.quaternion.copy( quaternion ).multiply( _tempQuaternion ); + + if ( Math.abs( _alignVector.copy( _unitZ ).applyQuaternion( quaternion ).dot( this.eye ) ) > 0.9 ) { + + handle.visible = false; + + } + + } + + if ( this.axis === 'XYZE' ) { + + _tempQuaternion.setFromEuler( _tempEuler.set( 0, Math.PI / 2, 0 ) ); + + _alignVector.copy( this.rotationAxis ); + + handle.quaternion.setFromRotationMatrix( _lookAtMatrix.lookAt( _zeroVector, _alignVector, _unitY ) ); + handle.quaternion.multiply( _tempQuaternion ); + handle.visible = this.dragging; + + } + + if ( this.axis === 'E' ) { + + handle.visible = false; + + } + + } else if ( handle.name === 'START' ) { + + handle.position.copy( this.worldPositionStart ); + handle.visible = this.dragging; + + } else if ( handle.name === 'END' ) { + + handle.position.copy( this.worldPosition ); + handle.visible = this.dragging; + + } else if ( handle.name === 'DELTA' ) { + + handle.position.copy( this.worldPositionStart ); + handle.quaternion.copy( this.worldQuaternionStart ); + + _tempVector.set( 1e-10, 1e-10, 1e-10 ).add( this.worldPositionStart ).sub( this.worldPosition ).multiplyScalar( - 1 ); + + _tempVector.applyQuaternion( this.worldQuaternionStart.clone().invert() ); + + handle.scale.copy( _tempVector ); + handle.visible = this.dragging; + + } else { + + handle.quaternion.copy( quaternion ); + + if ( this.dragging ) { + + handle.position.copy( this.worldPositionStart ); + + } else { + + handle.position.copy( this.worldPosition ); + + } + + if ( this.axis ) { + + handle.visible = this.axis.search( handle.name ) !== - 1; + + } + + } // If updating helper, skip rest of the loop + + + continue; + + } // Align handles to current local or world rotation + + + handle.quaternion.copy( quaternion ); + + if ( this.mode === 'translate' || this.mode === 'scale' ) { + + // Hide translate and scale axis facing the camera + const AXIS_HIDE_TRESHOLD = 0.99; + const PLANE_HIDE_TRESHOLD = 0.2; + const AXIS_FLIP_TRESHOLD = 0.0; + + if ( handle.name === 'X' || handle.name === 'XYZX' ) { + + if ( Math.abs( _alignVector.copy( _unitX ).applyQuaternion( quaternion ).dot( this.eye ) ) > AXIS_HIDE_TRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } + + if ( handle.name === 'Y' || handle.name === 'XYZY' ) { + + if ( Math.abs( _alignVector.copy( _unitY ).applyQuaternion( quaternion ).dot( this.eye ) ) > AXIS_HIDE_TRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } + + if ( handle.name === 'Z' || handle.name === 'XYZZ' ) { + + if ( Math.abs( _alignVector.copy( _unitZ ).applyQuaternion( quaternion ).dot( this.eye ) ) > AXIS_HIDE_TRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } + + if ( handle.name === 'XY' ) { + + if ( Math.abs( _alignVector.copy( _unitZ ).applyQuaternion( quaternion ).dot( this.eye ) ) < PLANE_HIDE_TRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } + + if ( handle.name === 'YZ' ) { + + if ( Math.abs( _alignVector.copy( _unitX ).applyQuaternion( quaternion ).dot( this.eye ) ) < PLANE_HIDE_TRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } + + if ( handle.name === 'XZ' ) { + + if ( Math.abs( _alignVector.copy( _unitY ).applyQuaternion( quaternion ).dot( this.eye ) ) < PLANE_HIDE_TRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } // Flip translate and scale axis ocluded behind another axis + + + if ( handle.name.search( 'X' ) !== - 1 ) { + + if ( _alignVector.copy( _unitX ).applyQuaternion( quaternion ).dot( this.eye ) < AXIS_FLIP_TRESHOLD ) { + + if ( handle.tag === 'fwd' ) { + + handle.visible = false; + + } else { + + handle.scale.x *= - 1; + + } + + } else if ( handle.tag === 'bwd' ) { + + handle.visible = false; + + } + + } + + if ( handle.name.search( 'Y' ) !== - 1 ) { + + if ( _alignVector.copy( _unitY ).applyQuaternion( quaternion ).dot( this.eye ) < AXIS_FLIP_TRESHOLD ) { + + if ( handle.tag === 'fwd' ) { + + handle.visible = false; + + } else { + + handle.scale.y *= - 1; + + } + + } else if ( handle.tag === 'bwd' ) { + + handle.visible = false; + + } + + } + + if ( handle.name.search( 'Z' ) !== - 1 ) { + + if ( _alignVector.copy( _unitZ ).applyQuaternion( quaternion ).dot( this.eye ) < AXIS_FLIP_TRESHOLD ) { + + if ( handle.tag === 'fwd' ) { + + handle.visible = false; + + } else { + + handle.scale.z *= - 1; + + } + + } else if ( handle.tag === 'bwd' ) { + + handle.visible = false; + + } + + } + + } else if ( this.mode === 'rotate' ) { + + // Align handles to current local or world rotation + _tempQuaternion2.copy( quaternion ); + + _alignVector.copy( this.eye ).applyQuaternion( _tempQuaternion.copy( quaternion ).invert() ); + + if ( handle.name.search( 'E' ) !== - 1 ) { + + handle.quaternion.setFromRotationMatrix( _lookAtMatrix.lookAt( this.eye, _zeroVector, _unitY ) ); + + } + + if ( handle.name === 'X' ) { + + _tempQuaternion.setFromAxisAngle( _unitX, Math.atan2( - _alignVector.y, _alignVector.z ) ); + + _tempQuaternion.multiplyQuaternions( _tempQuaternion2, _tempQuaternion ); + + handle.quaternion.copy( _tempQuaternion ); + + } + + if ( handle.name === 'Y' ) { + + _tempQuaternion.setFromAxisAngle( _unitY, Math.atan2( _alignVector.x, _alignVector.z ) ); + + _tempQuaternion.multiplyQuaternions( _tempQuaternion2, _tempQuaternion ); + + handle.quaternion.copy( _tempQuaternion ); + + } + + if ( handle.name === 'Z' ) { + + _tempQuaternion.setFromAxisAngle( _unitZ, Math.atan2( _alignVector.y, _alignVector.x ) ); + + _tempQuaternion.multiplyQuaternions( _tempQuaternion2, _tempQuaternion ); + + handle.quaternion.copy( _tempQuaternion ); + + } + + } // Hide disabled axes + + + handle.visible = handle.visible && ( handle.name.indexOf( 'X' ) === - 1 || this.showX ); + handle.visible = handle.visible && ( handle.name.indexOf( 'Y' ) === - 1 || this.showY ); + handle.visible = handle.visible && ( handle.name.indexOf( 'Z' ) === - 1 || this.showZ ); + handle.visible = handle.visible && ( handle.name.indexOf( 'E' ) === - 1 || this.showX && this.showY && this.showZ ); // highlight selected axis + + handle.material._opacity = handle.material._opacity || handle.material.opacity; + handle.material._color = handle.material._color || handle.material.color.clone(); + handle.material.color.copy( handle.material._color ); + handle.material.opacity = handle.material._opacity; + + if ( ! this.enabled ) { + + handle.material.opacity *= 0.5; + handle.material.color.lerp( new THREE.Color( 1, 1, 1 ), 0.5 ); + + } else if ( this.axis ) { + + if ( handle.name === this.axis ) { + + handle.material.opacity = 1.0; + handle.material.color.lerp( new THREE.Color( 1, 1, 1 ), 0.5 ); + + } else if ( this.axis.split( '' ).some( function ( a ) { + + return handle.name === a; + + } ) ) { + + handle.material.opacity = 1.0; + handle.material.color.lerp( new THREE.Color( 1, 1, 1 ), 0.5 ); + + } else { + + handle.material.opacity *= 0.25; + handle.material.color.lerp( new THREE.Color( 1, 1, 1 ), 0.5 ); + + } + + } + + } + + super.updateMatrixWorld( force ); + + } + + } + + TransformControlsGizmo.prototype.isTransformControlsGizmo = true; // + + class TransformControlsPlane extends THREE.Mesh { + + constructor() { + + super( new THREE.PlaneGeometry( 100000, 100000, 2, 2 ), new THREE.MeshBasicMaterial( { + visible: false, + wireframe: true, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.1, + toneMapped: false + } ) ); + this.type = 'TransformControlsPlane'; + + } + + updateMatrixWorld( force ) { + + let space = this.space; + this.position.copy( this.worldPosition ); + if ( this.mode === 'scale' ) space = 'local'; // scale always oriented to local rotation + + _v1.copy( _unitX ).applyQuaternion( space === 'local' ? this.worldQuaternion : _identityQuaternion ); + + _v2.copy( _unitY ).applyQuaternion( space === 'local' ? this.worldQuaternion : _identityQuaternion ); + + _v3.copy( _unitZ ).applyQuaternion( space === 'local' ? this.worldQuaternion : _identityQuaternion ); // Align the plane for current transform mode, axis and space. + + + _alignVector.copy( _v2 ); + + switch ( this.mode ) { + + case 'translate': + case 'scale': + switch ( this.axis ) { + + case 'X': + _alignVector.copy( this.eye ).cross( _v1 ); + + _dirVector.copy( _v1 ).cross( _alignVector ); + + break; + + case 'Y': + _alignVector.copy( this.eye ).cross( _v2 ); + + _dirVector.copy( _v2 ).cross( _alignVector ); + + break; + + case 'Z': + _alignVector.copy( this.eye ).cross( _v3 ); + + _dirVector.copy( _v3 ).cross( _alignVector ); + + break; + + case 'XY': + _dirVector.copy( _v3 ); + + break; + + case 'YZ': + _dirVector.copy( _v1 ); + + break; + + case 'XZ': + _alignVector.copy( _v3 ); + + _dirVector.copy( _v2 ); + + break; + + case 'XYZ': + case 'E': + _dirVector.set( 0, 0, 0 ); + + break; + + } + + break; + + case 'rotate': + default: + // special case for rotate + _dirVector.set( 0, 0, 0 ); + + } + + if ( _dirVector.length() === 0 ) { + + // If in rotate mode, make the plane parallel to camera + this.quaternion.copy( this.cameraQuaternion ); + + } else { + + _tempMatrix.lookAt( _tempVector.set( 0, 0, 0 ), _dirVector, _alignVector ); + + this.quaternion.setFromRotationMatrix( _tempMatrix ); + + } + + super.updateMatrixWorld( force ); + + } + + } + + TransformControlsPlane.prototype.isTransformControlsPlane = true; + + THREE.TransformControls = TransformControls; + THREE.TransformControlsGizmo = TransformControlsGizmo; + THREE.TransformControlsPlane = TransformControlsPlane; + +} )(); diff --git a/huey_queue.db b/huey_queue.db index f689c3c..6b9d8e2 100644 Binary files a/huey_queue.db and b/huey_queue.db differ diff --git a/huey_queue.db-shm b/huey_queue.db-shm deleted file mode 100644 index d1917ad..0000000 Binary files a/huey_queue.db-shm and /dev/null differ diff --git a/huey_queue.db-wal b/huey_queue.db-wal deleted file mode 100644 index 370ee33..0000000 Binary files a/huey_queue.db-wal and /dev/null differ diff --git a/instance/aio_3d.db b/instance/aio_3d.db index 1b3ee85..88446b9 100644 Binary files a/instance/aio_3d.db and b/instance/aio_3d.db differ diff --git a/instance/aio_3d.db-shm b/instance/aio_3d.db-shm deleted file mode 100644 index 796a2ac..0000000 Binary files a/instance/aio_3d.db-shm and /dev/null differ diff --git a/instance/aio_3d.db-wal b/instance/aio_3d.db-wal deleted file mode 100644 index 4e89aa1..0000000 Binary files a/instance/aio_3d.db-wal and /dev/null differ diff --git a/stl_merger.py b/stl_merger.py new file mode 100644 index 0000000..bd61405 --- /dev/null +++ b/stl_merger.py @@ -0,0 +1,117 @@ +import struct +import math +import os + +def transform_stl(input_path, output_path, matrix): + # matrix is a flat 16 float array from Three.js (column-major format) + # [m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, m11, m12, m13, m14, m15] + + with open(input_path, 'rb') as f: + header = f.read(80) + num_faces = struct.unpack(' 0.000001: + return x/length, y/length, z/length + return 0.0, 0.0, 0.0 + + out = bytearray(80 + 4 + num_faces * 50) + out[0:80] = header + out[80:84] = struct.pack(' 0.000001: + return x/length, y/length, z/length + return 0.0, 0.0, 0.0 + + new_data = bytearray(faces * 50) + src_offset = 0 + dst_offset = 0 + for _ in range(faces): + n_x, n_y, n_z, v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z, attr = struct.unpack_from('<12fH', data, src_offset) + + nnx, nny, nnz = apply_rot(n_x, n_y, n_z) + nv1x, nv1y, nv1z = apply_m(v1x, v1y, v1z) + nv2x, nv2y, nv2z = apply_m(v2x, v2y, v2z) + nv3x, nv3y, nv3z = apply_m(v3x, v3y, v3z) + + struct.pack_into('<12fH', new_data, dst_offset, nnx, nny, nnz, nv1x, nv1y, nv1z, nv2x, nv2y, nv2z, nv3x, nv3y, nv3z, attr) + src_offset += 50 + dst_offset += 50 + + meshes_data.append(new_data) + total_faces += faces + + # write merged + with open(output_path, 'wb') as f: + f.write(b'\0' * 80) + f.write(struct.pack(' 2: + simplify_stl(sys.argv[1], sys.argv[2]) diff --git a/test.STL b/test.STL new file mode 100644 index 0000000..fff343f Binary files /dev/null and b/test.STL differ diff --git a/tmp/parse_bed.py b/tmp/parse_bed.py new file mode 100644 index 0000000..955547b --- /dev/null +++ b/tmp/parse_bed.py @@ -0,0 +1,8 @@ +import json +import os + +with open('print_config/printers/creality_ender3v3se.def.json') as f: + d = json.load(f) + print(d['overrides']['machine_width']['default_value']) + print(d['overrides']['machine_depth']['default_value']) + print(d['overrides']['machine_height']['default_value']) diff --git a/parse_presets.py b/tmp/parse_presets.py similarity index 100% rename from parse_presets.py rename to tmp/parse_presets.py diff --git a/tmp/patch_proxy.py b/tmp/patch_proxy.py new file mode 100644 index 0000000..47c42ae --- /dev/null +++ b/tmp/patch_proxy.py @@ -0,0 +1,53 @@ +import re + +with open('app/routes.py', 'r', encoding='utf-8') as f: + text = f.read() + +# Add serve_proxy_file after serve_file +serve_file_code = """@main_bp.route('/file/') +@login_required +def serve_file(file_id): + f = PrintFile.query.get_or_404(file_id) + if f.user_id != current_user.id and not current_user.is_admin: + abort(403) + path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) + return send_file(path)""" + +proxy_code = """@main_bp.route('/file/') +@login_required +def serve_file(file_id): + f = PrintFile.query.get_or_404(file_id) + if f.user_id != current_user.id and not current_user.is_admin: + abort(403) + path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) + return send_file(path) + +@main_bp.route('/proxy/') +@login_required +def serve_proxy_file(file_id): + f = PrintFile.query.get_or_404(file_id) + if f.user_id != current_user.id and not current_user.is_admin: + abort(403) + path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) + proxy_path = path + '.proxy.stl' + if not os.path.exists(proxy_path): + from stl_simplifier import simplify_stl + try: + simplify_stl(path, proxy_path, keep_ratio=0.1) # compress to 10% + except: + return send_file(path) # fallback to original if error + if os.path.exists(proxy_path): + return send_file(proxy_path) + return send_file(path)""" + +text = text.replace(serve_file_code, proxy_code) + +# Change plater models to use serve_proxy_file +old_str = "url_for('main.serve_file', file_id=f.id)" +new_str = "url_for('main.serve_proxy_file', file_id=f.id)" + +text = text.replace(old_str, new_str) + +with open('app/routes.py', 'w', encoding='utf-8') as f: + f.write(text) +print("Patched successfully") diff --git a/tmp/patch_routes.py b/tmp/patch_routes.py new file mode 100644 index 0000000..68607b3 --- /dev/null +++ b/tmp/patch_routes.py @@ -0,0 +1,36 @@ +import re + +with open('app/routes.py', 'r', encoding='utf-8') as f: + text = f.read() + +old_str = """ print_file = PrintFile( + filename=unique_filename, + original_filename=f"{combined_name}.stl", + file_type='stl', + user_id=current_user.id, + status='waiting' + ) + db.session.add(print_file) + db.session.commit() + + slice_stl_task(print_file.id, merged_filepath, quality)""" + +new_str = """ print_file = PrintFile( + filename=unique_filename, + original_filename=f"{combined_name}.stl", + file_type='stl', + user_id=current_user.id, + status='merging' + ) + db.session.add(print_file) + db.session.commit() + + from .tasks import merge_and_slice_task + merge_and_slice_task(print_file.id, inputs, merged_filepath, quality)""" + +if old_str in text: + with open('app/routes.py', 'w', encoding='utf-8') as f: + f.write(text.replace(old_str, new_str)) + print("Patched successfully") +else: + print("Old string not found") diff --git a/tmp/repatch_routes.py b/tmp/repatch_routes.py new file mode 100644 index 0000000..47a1012 --- /dev/null +++ b/tmp/repatch_routes.py @@ -0,0 +1,141 @@ +import json, os, uuid +from datetime import datetime + +with open('app/routes.py', 'r', encoding='utf-8') as f: + content = f.read() + +prefix = """import json +import uuid +import os +""" + +functions = """ +def get_bed_dimensions(): + try: + from flask import current_app + path = os.path.join(current_app.root_path, '..', 'print_config', 'printers', 'creality_ender3v3se.def.json') + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + w = data['overrides']['machine_width']['default_value'] + h = data['overrides']['machine_depth']['default_value'] + hd = data['overrides']['machine_height']['default_value'] + return w, h, hd + except: + return 200, 200, 200 + +def get_quality_presets(): + try: + from flask import current_app + path = os.path.join(current_app.root_path, '..', 'print_config', 'presets', 'creality', 'base') + files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')] + presets = [] + for f in files: + name = f.replace('.inst.cfg', '').replace('base_', '').replace('_', ' ') + presets.append((f, name)) + return presets + except: + return [] +""" + +routes = """ +@main_bp.route('/plater') +@login_required +def plater(): + w, h, hd = get_bed_dimensions() + presets = get_quality_presets() + last_quality = request.cookies.get('last_quality_preset', 'base_global_standard.inst.cfg') + user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all() + models = [{'id': f.id, 'name': f.original_filename, 'url': url_for('main.serve_proxy_file', file_id=f.id)} for f in user_files] + return render_template('plater.html', w=w, h=h, hd=hd, presets=presets, last_quality=last_quality, models=models) + +@main_bp.route('/file/') +@login_required +def serve_file(file_id): + f = PrintFile.query.get_or_404(file_id) + if f.user_id != current_user.id and not current_user.is_admin: + abort(403) + path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) + return send_file(path) + +@main_bp.route('/proxy/') +@login_required +def serve_proxy_file(file_id): + f = PrintFile.query.get_or_404(file_id) + if f.user_id != current_user.id and not current_user.is_admin: + abort(403) + path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) + proxy_path = path + '.proxy.stl' + if not os.path.exists(proxy_path): + from stl_simplifier import simplify_stl + try: + simplify_stl(path, proxy_path, keep_ratio=0.1) # compress to 10% + except: + return send_file(path) # fallback to original if error + if os.path.exists(proxy_path): + return send_file(proxy_path) + return send_file(path) + +@main_bp.route('/api/merge_and_slice', methods=['POST']) +@login_required +def merge_and_slice(): + data = request.json + pieces = data.get('pieces', []) + quality = data.get('quality', 'base_global_standard.inst.cfg') + + if not pieces: + return jsonify({'error': 'No pieces provided'}), 400 + + inputs = [] + # Build a combined name + names = [] + for p in pieces[:3]: # Cap names at 3 to avoid super long string + f = PrintFile.query.get(p['file_id']) + if f and (f.user_id == current_user.id or current_user.is_admin): + names.append(f.original_filename.replace('.stl', '')) + + combined_name = ", ".join(names) + if len(pieces) > 3: + combined_name += "等合并切片" + else: + combined_name += "合并切片" + + for p in pieces: + f = PrintFile.query.get(p['file_id']) + if f and (f.user_id == current_user.id or current_user.is_admin): + path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) + inputs.append((path, p['matrix'])) + + if not inputs: + return jsonify({'error': 'Invalid files'}), 400 + + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + unique_filename = f"{timestamp}_{uuid.uuid4().hex}.stl" + merged_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename) + + print_file = PrintFile( + filename=unique_filename, + original_filename=f"{combined_name}.stl", + file_type='stl', + user_id=current_user.id, + status='merging' + ) + db.session.add(print_file) + db.session.commit() + + from .tasks import merge_and_slice_task + merge_and_slice_task(print_file.id, inputs, merged_filepath, quality) + + return jsonify({'success': True, 'message': 'Plater slice queued!'}) +""" + +# Only patch if not already present +if 'def plater()' not in content: + with open('app/routes.py', 'w', encoding='utf-8') as f: + # Prepend imports if needed + lines = content.split('\n') + # Insert mostly at the end + new_content = "\n".join(lines[:-1]) + functions + routes + "\n" + f.write(prefix + new_content) + print("Repatched successfully.") +else: + print("Already present.") diff --git a/tmp/test_pq.py b/tmp/test_pq.py new file mode 100644 index 0000000..2044be2 --- /dev/null +++ b/tmp/test_pq.py @@ -0,0 +1,14 @@ +from app import create_app +from app.routes import get_quality_presets + +import logging +logging.getLogger("werkzeug").setLevel(logging.ERROR) + +app = create_app() +with app.app_context(): + presets = get_quality_presets() + print("Presets length:", len(presets)) + if len(presets) > 0: + print("First 3 presets:") + for p in presets[:3]: + print(p) diff --git a/tmp/test_pq2.py b/tmp/test_pq2.py new file mode 100644 index 0000000..cf5344a --- /dev/null +++ b/tmp/test_pq2.py @@ -0,0 +1,19 @@ +import os, configparser + +preset_dir = 'print_config/presets/creality/base' +presets = [] +for f in os.listdir(preset_dir): + if f.endswith('.inst.cfg'): + config = configparser.ConfigParser() + config.read(os.path.join(preset_dir, f)) + try: + name = config.get('general', 'name', fallback=f) + except: + name = f.replace('.inst.cfg', '').replace('base_', '').replace('_', ' ') + presets.append((f, name)) + +presets = sorted(presets, key=lambda x: str(x[1]).lower()) +print("Presets length:", len(presets)) +if len(presets) > 0: + for p in presets[:5]: + print(p) diff --git a/tmp/test_pq3.py b/tmp/test_pq3.py new file mode 100644 index 0000000..1f9b45c --- /dev/null +++ b/tmp/test_pq3.py @@ -0,0 +1,9 @@ +from app import create_app +from app.routes import get_quality_presets + +app = create_app() +with app.app_context(): + presets = get_quality_presets() + print("Presets length:", len(presets)) + for p in presets[:5]: + print(p) diff --git a/tmp/test_simplifier.py b/tmp/test_simplifier.py new file mode 100644 index 0000000..359256a --- /dev/null +++ b/tmp/test_simplifier.py @@ -0,0 +1,35 @@ +import numpy as np +from stl import mesh + +def simplify_stl_grid(input_path, output_path, target_ratio=0.1): + m = mesh.Mesh.from_file(input_path) + vertices = m.vectors.reshape(-1, 3) + + min_v = vertices.min(axis=0) + max_v = vertices.max(axis=0) + bbox_size = max_v - min_v + max_dim = np.max(bbox_size) + + # Adjust resolution to rough target_ratio by guessing. + # The number of vertices drops roughly by (resolution_factor)^2. + # So if we want 10% faces, resolution_factor can be heuristically set. + # Let's try 0.05 + grid_size = max_dim * 0.05 + + v_idx = np.round((vertices - min_v) / grid_size).astype(np.int32) + _, unique_idx, inv_idx = np.unique(v_idx, axis=0, return_index=True, return_inverse=True) + + new_vertices = vertices[unique_idx] + faces = inv_idx.reshape(-1, 3) + + valid = (faces[:,0] != faces[:,1]) & (faces[:,1] != faces[:,2]) & (faces[:,0] != faces[:,2]) + valid_faces = faces[valid] + + new_m = mesh.Mesh(np.zeros(valid_faces.shape[0], dtype=mesh.Mesh.dtype)) + for i, f in enumerate(valid_faces): + for j in range(3): + new_m.vectors[i][j] = new_vertices[f[j]] + + new_m.update_normals() + new_m.save(output_path) +