有构建板,支持多模型构建,但生成支撑的切片还有bug
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,2 +1,5 @@
|
||||
__pycache__
|
||||
uploads
|
||||
uploads/*
|
||||
venv/*
|
||||
instance/*
|
||||
huey_queue.db
|
||||
0
app/app.db
Normal file
0
app/app.db
Normal file
@@ -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)
|
||||
|
||||
257
app/routes.py
257
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/<int:file_id>')
|
||||
@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/<int:file_id>')
|
||||
@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!'})
|
||||
|
||||
|
||||
50
app/tasks.py
50
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()
|
||||
|
||||
@@ -9,12 +9,20 @@
|
||||
<div class="card-body">
|
||||
<h5>CuraEngine Configurations</h5>
|
||||
<hr>
|
||||
<form method="POST">
|
||||
<form method="POST" action="{{ url_for('admin.settings') }}">
|
||||
<div class="mb-3">
|
||||
<label for="concurrent_slices" class="form-label">Concurrent Slices (Queue Worker limit)</label>
|
||||
<input type="number" class="form-control" id="concurrent_slices" value="2" min="1" max="10">
|
||||
<label for="offset_x" class="form-label">Plater Origin Offset X (mm)</label>
|
||||
<input type="number" class="form-control" name="offset_x" id="offset_x" value="{{ configs.get('offset_x', '0') }}">
|
||||
<div class="form-text">Adjust the X-axis compilation offset for combined files on the build plate.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" onclick="alert('Settings saved (demo)')">Save Settings</button>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="offset_y" class="form-label">Plater Origin Offset Y (mm)</label>
|
||||
<input type="number" class="form-control" name="offset_y" id="offset_y" value="{{ configs.get('offset_y', '0') }}">
|
||||
<div class="form-text">Adjust the Y-axis compilation offset for combined files on the build plate.</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,16 +63,16 @@
|
||||
<i class="bi bi-house-door me-2"></i>{{ _('Home') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'main.slice_page' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.slice_page') }}">
|
||||
<i class="bi bi-box me-2"></i>{{ _('New Slice') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'main.files' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.files') }}">
|
||||
<i class="bi bi-folder2-open me-2"></i>{{ _('My Files') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mb-1">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'main.plater' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.plater') }}">
|
||||
<i class="bi bi-grid-3x3 me-2"></i>{{ _('Plater') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
{% extends 'base.html' %}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-4 border-bottom">
|
||||
<h1 class="h2"><i class="bi bi-files me-2 text-warning"></i>{{ _('My Files') }}</h1>
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">{{ _('My Files') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0 mb-4 bg-light">
|
||||
<div class="card-body">
|
||||
<div id="drop-zone" class="border border-2 border-primary rounded p-4 text-center bg-white" style="border-style: dashed !important; cursor: pointer; transition: all 0.3s ease;">
|
||||
<i class="bi bi-cloud-arrow-up display-4 text-primary mb-2"></i>
|
||||
<h5 class="text-secondary fw-normal">{{ _('Drag & Drop STL file here or Click to Select') }}</h5>
|
||||
<input type="file" id="file" name="file" accept=".stl" class="d-none">
|
||||
</div>
|
||||
|
||||
<div id="upload-progress-container" class="mt-3 d-none">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span class="text-muted small" id="upload-status-text"><i class="bi bi-cloud-arrow-up-fill me-1"></i>{{ _('Uploading...') }}</span>
|
||||
<span class="text-primary small fw-bold" id="upload-progress-text">0%</span>
|
||||
</div>
|
||||
<div class="progress shadow-sm" style="height: 10px;">
|
||||
<div id="upload-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-primary" role="progressbar" style="width: 0%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
@@ -23,8 +43,12 @@
|
||||
<td class="ps-4 text-muted"><i class="bi bi-clock me-1"></i>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td class="fw-medium">{{ file.original_filename }}</td>
|
||||
<td id="status-{{ file.id }}">
|
||||
{% if file.status == 'waiting' or file.status == 'uploaded' %}
|
||||
{% if file.status == 'waiting' %}
|
||||
<span class="badge bg-info text-dark rounded-pill fw-normal px-2" title="{{ _('Waiting in queue for slicing') }}"><i class="bi bi-hourglass-split me-1"></i>{{ _('Waiting') }}...</span>
|
||||
{% elif file.status == 'uploaded' %}
|
||||
<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _('Uploaded') }}</span>
|
||||
{% elif file.status == 'merging' %}
|
||||
<span class="badge bg-primary text-light rounded-pill fw-normal px-2"><i class="bi bi-intersect me-1"></i>{{ _('Merging') }}...</span>
|
||||
{% elif file.status == 'slicing' %}
|
||||
<span class="badge bg-warning text-dark rounded-pill fw-normal px-2"><i class="bi bi-gear-wide-connected bi-spin me-1"></i>{{ _('Slicing') }}...</span>
|
||||
{% elif file.status == 'sliced' %}
|
||||
@@ -35,6 +59,7 @@
|
||||
</td>
|
||||
<td class="pe-4">
|
||||
<div class="d-flex gap-2" id="actions-container-{{ file.id }}">
|
||||
<a href="{{ url_for('main.plater') }}?add={{ file.id }}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>
|
||||
{% if file.status == 'sliced' %}
|
||||
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-primary shadow-sm" title="{{ _('Download GCode') }}"><i class="bi bi-download"></i></a>
|
||||
<a href="{{ url_for('main.preview_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-info shadow-sm" title="{{ _('GCode Preview') }}"><i class="bi bi-eye"></i></a>
|
||||
@@ -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 = '<span class="badge bg-info text-dark rounded-pill fw-normal px-2" title="{{ _("Waiting in queue for slicing") }}"><i class="bi bi-hourglass-split me-1"></i>{{ _("Waiting") }}...</span>';
|
||||
if (status === 'waiting') statusTd.innerHTML = '<span class="badge bg-info text-dark rounded-pill fw-normal px-2"><i class="bi bi-hourglass-split me-1"></i>{{ _("Waiting") }}...</span>';
|
||||
else if (status === 'uploaded') statusTd.innerHTML = '<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _("Uploaded") }}</span>';
|
||||
else if (status === 'merging') statusTd.innerHTML = '<span class="badge bg-primary text-light rounded-pill fw-normal px-2"><i class="bi bi-intersect me-1"></i>{{ _("Merging") }}...</span>';
|
||||
else if (status === 'slicing') statusTd.innerHTML = '<span class="badge bg-warning text-dark rounded-pill fw-normal px-2"><i class="bi bi-gear-wide-connected bi-spin me-1"></i>{{ _("Slicing") }}...</span>';
|
||||
else if (status === 'sliced') statusTd.innerHTML = '<span class="badge bg-success rounded-pill fw-normal px-2"><i class="bi bi-check-circle me-1"></i>{{ _("Sliced") }}</span>';
|
||||
else if (status === 'failed') statusTd.innerHTML = '<span class="badge bg-danger rounded-pill fw-normal px-2"><i class="bi bi-x-circle me-1"></i>{{ _("Failed") }}</span>';
|
||||
|
||||
// Update Actions HTML
|
||||
let actionsHtml = '';
|
||||
const platerUrl = `{{ url_for('main.plater') }}?add=${id}`;
|
||||
actionsHtml += `<a href="${platerUrl}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>\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 = '<i class="bi bi-check-circle-fill text-success me-1"></i>{{ _("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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
586
app/templates/plater.html
Normal file
586
app/templates/plater.html
Normal file
@@ -0,0 +1,586 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2"><i class="bi bi-grid-3x3 me-2 text-primary"></i>{{ _('Plater / Build Plate') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row" style="height: 70vh;">
|
||||
|
||||
|
||||
<!-- 3D Area -->
|
||||
<div class="col-md-9 h-100 position-relative">
|
||||
<div id="plater-container" class="w-100 h-100 rounded shadow-sm border border-secondary" style="overflow: hidden; background: #f8f9fa;"></div>
|
||||
|
||||
<div class="position-absolute top-50 start-0 translate-middle-y ms-3 p-2 bg-white rounded shadow-sm d-flex flex-column gap-2 opacity-75" style="z-index: 10;">
|
||||
<button class="btn btn-primary btn-sm rounded" id="btn-translate" title="{{ _('Translate (W)') }}" onclick="setTransformMode('translate')"><i class="bi bi-arrows-move"></i></button>
|
||||
<button class="btn btn-outline-secondary btn-sm rounded" id="btn-rotate" title="{{ _('Rotate (E)') }}" onclick="setTransformMode('rotate')"><i class="bi bi-arrow-clockwise"></i></button>
|
||||
<button class="btn btn-outline-secondary btn-sm rounded" id="btn-scale" title="{{ _('Scale (R)') }}" onclick="setTransformMode('scale')"><i class="bi bi-arrows-angle-expand"></i></button>
|
||||
<hr class="m-0 border-secondary">
|
||||
<button class="btn btn-outline-info btn-sm rounded" id="btn-layflat" title="{{ _('Lay Flat') }}" onclick="setTransformMode('layflat')"><i class="bi bi-symmetry-horizontal"></i></button>
|
||||
<button class="btn btn-outline-danger btn-sm rounded mt-2" id="btn-remove" title="{{ _('Remove Selected (Del)') }}" onclick="removeActiveModel()"><i class="bi bi-trash3"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="col-md-3 h-100 d-flex flex-column pb-3" style="overflow-y: auto; overflow-x: hidden;">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center" style="cursor: pointer; z-index: 10;" data-bs-toggle="collapse" data-bs-target="#collapseModels" aria-expanded="true">
|
||||
<span><i class="bi bi-layers-fill me-2"></i>{{ _('Available Models') }}</span>
|
||||
<i class="bi bi-chevron-bar-contract"></i>
|
||||
</div>
|
||||
<div id="collapseModels" class="collapse show">
|
||||
<div class="list-group list-group-flush" id="model-list" style="max-height: 250px; overflow-y: auto;">
|
||||
{% for model in models %}
|
||||
<button id="add-model-btn-{{ model.id }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" data-matrix="{{ model.transform_matrix or '' }}" onclick="addModelToPlate(this, {{ model.id }}, '{{ model.url }}', '{{ model.name }}', '{{ model.status }}')">
|
||||
<span class="text-truncate">{{ model.name }}</span>
|
||||
<i class="bi bi-plus-circle text-success"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<div class="p-3 text-center text-muted">{{ _("No STL models uploaded yet. Go upload some first.") }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3 flex-shrink-0">
|
||||
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapseSettings" aria-expanded="true">
|
||||
<span><i class="bi bi-sliders me-2"></i>{{ _('Other Settings') }}</span>
|
||||
<i class="bi bi-chevron-bar-contract"></i>
|
||||
</div>
|
||||
<div id="collapseSettings" class="collapse show">
|
||||
<div class="card-body py-2">
|
||||
<div class="mb-2">
|
||||
<label for="infill-density" class="form-label text-secondary small mb-1">{{ _('Infill Density') }} (%)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="infill-density" value="20" min="0" max="100">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label for="support-type" class="form-label text-secondary small mb-1">{{ _('Support') }}</label>
|
||||
<select class="form-select form-select-sm" id="support-type">
|
||||
<option value="false">{{ _('None') }}</option>
|
||||
<option value="buildplate">{{ _('Touching Buildplate') }}</option>
|
||||
<option value="true">{{ _('Everywhere') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label for="support-pattern" class="form-label text-secondary small mb-1">{{ _('Support Type') }}</label>
|
||||
<select class="form-select form-select-sm" id="support-pattern" disabled>
|
||||
<option value="lines">{{ _('Lines') }} ({{ _('默认线状') }})</option>
|
||||
<option value="grid">{{ _('Grid') }} ({{ _('网格状') }})</option>
|
||||
<option value="triangles">{{ _('Triangles') }} ({{ _('三角网') }})</option>
|
||||
<option value="zigzag">{{ _('ZigZag') }} ({{ _('之字形') }})</option>
|
||||
<option value="tree">{{ _('Tree') }} ({{ _('树状') }})</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm flex-shrink-0">
|
||||
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapseQuality" aria-expanded="true">
|
||||
<span><i class="bi bi-gear-wide-connected me-2"></i>{{ _('Quality Profile') }}</span>
|
||||
<i class="bi bi-chevron-bar-contract"></i>
|
||||
</div>
|
||||
<div id="collapseQuality" class="collapse show">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<select class="form-select bg-light" id="quality">
|
||||
{% for key, name in presets %}
|
||||
<option value="{{ key }}" {% if key == last_quality %}selected{% endif %}>{{ _(name) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="clearPlate()"><i class="bi bi-trash me-1"></i>{{ _('Clear Board') }}</button>
|
||||
<button class="btn btn-primary" onclick="mergeAndSlice()" id="btn-merge"><i class="bi bi-gear-fill me-2" id="merge-icon"></i><span id="merge-text">{{ _('Merge & Slice') }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/three.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/OrbitControls.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/TransformControls.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/STLLoader.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// Toggle icons on collapse
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const cards = document.querySelectorAll('.collapse');
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('show.bs.collapse', function () {
|
||||
const icon = this.previousElementSibling.querySelector('i.bi-chevron-bar-expand');
|
||||
if(icon) {
|
||||
icon.classList.remove('bi-chevron-bar-expand');
|
||||
icon.classList.add('bi-chevron-bar-contract');
|
||||
}
|
||||
});
|
||||
card.addEventListener('hide.bs.collapse', function () {
|
||||
const icon = this.previousElementSibling.querySelector('i.bi-chevron-bar-contract');
|
||||
if(icon) {
|
||||
icon.classList.remove('bi-chevron-bar-contract');
|
||||
icon.classList.add('bi-chevron-bar-expand');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let scene, camera, renderer, orbit, transformControl, transformProxy, gridHelper, bedBoxOutline;
|
||||
let boundPlanes = {};
|
||||
let bedWidth = {{ w }};
|
||||
let bedDepth = {{ h }};
|
||||
let bedHeight = {{ hd }};
|
||||
let offsetX = {{ offset_x|default(0) }};
|
||||
let offsetY = {{ offset_y|default(0) }};
|
||||
let loadedModels = [];
|
||||
let activeModel = null;
|
||||
|
||||
initPlater();
|
||||
animate();
|
||||
|
||||
function initPlater() {
|
||||
const container = document.getElementById('plater-container');
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0xe9ecef);
|
||||
|
||||
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 1, 3000);
|
||||
camera.position.set(0, -bedDepth * 1.2, bedHeight);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Lights
|
||||
scene.add(new THREE.AmbientLight(0x888888));
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
dirLight.position.set(100, 100, 200);
|
||||
scene.add(dirLight);
|
||||
|
||||
// Bed Grid
|
||||
const gridSizeX = bedWidth;
|
||||
const gridSizeY = bedDepth;
|
||||
const maxGridSize = Math.max(gridSizeX, gridSizeY);
|
||||
// Divisions needed to make each square exactly 10mm wide
|
||||
const gridDivisions = Math.ceil(maxGridSize / 10);
|
||||
gridHelper = new THREE.GridHelper(gridDivisions * 10, gridDivisions, 0xbbbbbb, 0xdddddd);
|
||||
gridHelper.rotation.x = Math.PI / 2;
|
||||
scene.add(gridHelper);
|
||||
|
||||
// Bed Origin Axes (Bottom-Left Corner)
|
||||
const axesHelper = new THREE.AxesHelper(maxGridSize / 4);
|
||||
axesHelper.position.set(-bedWidth / 2, -bedDepth / 2, 0.2);
|
||||
scene.add(axesHelper);
|
||||
|
||||
// Show Bed Box outline
|
||||
const boxGeo = new THREE.BoxGeometry(bedWidth, bedDepth, bedHeight);
|
||||
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||
bedBoxOutline = new THREE.LineSegments(edges, new THREE.LineBasicMaterial( { color: 0xcccccc } ));
|
||||
bedBoxOutline.position.z = bedHeight / 2;
|
||||
scene.add(bedBoxOutline);
|
||||
|
||||
// Warning planes
|
||||
const planeMat = new THREE.MeshBasicMaterial({ color: 0xff0000, transparent: true, opacity: 0.4, side: THREE.DoubleSide, depthWrite: false });
|
||||
boundPlanes = {
|
||||
minX: new THREE.Mesh(new THREE.PlaneGeometry(bedDepth, bedHeight), planeMat),
|
||||
maxX: new THREE.Mesh(new THREE.PlaneGeometry(bedDepth, bedHeight), planeMat),
|
||||
minY: new THREE.Mesh(new THREE.PlaneGeometry(bedWidth, bedHeight), planeMat),
|
||||
maxY: new THREE.Mesh(new THREE.PlaneGeometry(bedWidth, bedHeight), planeMat),
|
||||
minZ: new THREE.Mesh(new THREE.PlaneGeometry(bedWidth, bedDepth), planeMat),
|
||||
maxZ: new THREE.Mesh(new THREE.PlaneGeometry(bedWidth, bedDepth), planeMat)
|
||||
};
|
||||
|
||||
boundPlanes.minX.rotation.y = Math.PI / 2;
|
||||
boundPlanes.minX.rotation.z = Math.PI / 2;
|
||||
boundPlanes.minX.position.set(-bedWidth/2, 0, bedHeight/2);
|
||||
|
||||
boundPlanes.maxX.rotation.y = Math.PI / 2;
|
||||
boundPlanes.maxX.rotation.z = Math.PI / 2;
|
||||
boundPlanes.maxX.position.set(bedWidth/2, 0, bedHeight/2);
|
||||
|
||||
boundPlanes.minY.rotation.x = Math.PI / 2;
|
||||
boundPlanes.minY.position.set(0, -bedDepth/2, bedHeight/2);
|
||||
|
||||
boundPlanes.maxY.rotation.x = Math.PI / 2;
|
||||
boundPlanes.maxY.position.set(0, bedDepth/2, bedHeight/2);
|
||||
|
||||
boundPlanes.minZ.position.set(0, 0, 0); // bottom
|
||||
|
||||
boundPlanes.maxZ.position.set(0, 0, bedHeight); // top
|
||||
|
||||
for (let key in boundPlanes) {
|
||||
boundPlanes[key].visible = false;
|
||||
scene.add(boundPlanes[key]);
|
||||
}
|
||||
|
||||
// Controls
|
||||
orbit = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
orbit.enableDamping = true;
|
||||
orbit.target.set(0, 0, 0);
|
||||
|
||||
transformProxy = new THREE.Object3D();
|
||||
scene.add(transformProxy);
|
||||
|
||||
transformControl = new THREE.TransformControls(camera, renderer.domElement);
|
||||
transformControl.setSpace('world');
|
||||
transformControl.addEventListener('dragging-changed', function (event) {
|
||||
orbit.enabled = !event.value;
|
||||
if (!event.value && activeModel) {
|
||||
scene.attach(activeModel);
|
||||
transformProxy.position.copy(activeModel.getWorldPosition(new THREE.Vector3()));
|
||||
transformProxy.rotation.set(0, 0, 0);
|
||||
transformProxy.scale.set(1, 1, 1);
|
||||
transformProxy.attach(activeModel);
|
||||
}
|
||||
});
|
||||
scene.add(transformControl);
|
||||
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
renderer.domElement.addEventListener('pointerdown', onPointerDown);
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
const container = document.getElementById('plater-container');
|
||||
camera.aspect = container.clientWidth / container.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
}
|
||||
|
||||
let layFlatMode = false;
|
||||
|
||||
function setTransformMode(mode) {
|
||||
if (mode !== 'layflat') {
|
||||
transformControl.setMode(mode);
|
||||
transformControl.setSpace(mode === 'scale' ? 'local' : 'world');
|
||||
layFlatMode = false;
|
||||
|
||||
document.getElementById('btn-translate').className = mode === 'translate' ? 'btn btn-primary btn-sm rounded' : 'btn btn-outline-secondary btn-sm rounded';
|
||||
document.getElementById('btn-rotate').className = mode === 'rotate' ? 'btn btn-primary btn-sm rounded' : 'btn btn-outline-secondary btn-sm rounded';
|
||||
document.getElementById('btn-scale').className = mode === 'scale' ? 'btn btn-primary btn-sm rounded' : 'btn btn-outline-secondary btn-sm rounded';
|
||||
document.getElementById('btn-layflat').className = 'btn btn-outline-info btn-sm rounded';
|
||||
} else {
|
||||
layFlatMode = true;
|
||||
document.getElementById('btn-translate').className = 'btn btn-outline-secondary btn-sm rounded';
|
||||
document.getElementById('btn-rotate').className = 'btn btn-outline-secondary btn-sm rounded';
|
||||
document.getElementById('btn-scale').className = 'btn btn-outline-secondary btn-sm rounded';
|
||||
document.getElementById('btn-layflat').className = 'btn btn-info btn-sm rounded text-white';
|
||||
transformControl.detach();
|
||||
}
|
||||
}
|
||||
|
||||
function removeActiveModel() {
|
||||
if (activeModel) {
|
||||
removeModel(activeModel);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'w': setTransformMode('translate'); break;
|
||||
case 'e': setTransformMode('rotate'); break;
|
||||
case 'r': setTransformMode('scale'); break;
|
||||
case 'backspace':
|
||||
case 'delete':
|
||||
removeActiveModel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if(transformControl.dragging) return;
|
||||
const container = document.getElementById('plater-container');
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const pointer = new THREE.Vector2();
|
||||
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const intersects = raycaster.intersectObjects(loadedModels, true);
|
||||
|
||||
if (layFlatMode) {
|
||||
if (intersects.length > 0) {
|
||||
const hit = intersects[0];
|
||||
const obj = hit.object;
|
||||
const face = hit.face;
|
||||
if (face) {
|
||||
// Ensure model is in world space before applying transformations
|
||||
scene.attach(obj);
|
||||
|
||||
// The target normal to align with (pointing downward to the bed, Z = -1)
|
||||
const targetNormal = new THREE.Vector3(0, 0, -1);
|
||||
|
||||
// Get the face's normal in world space
|
||||
let localNormal = face.normal.clone();
|
||||
let currentWorldNormal = localNormal.transformDirection(obj.matrixWorld).normalize();
|
||||
|
||||
// Compute quaternion to rotate current normal to point straight down
|
||||
let quaternion = new THREE.Quaternion();
|
||||
quaternion.setFromUnitVectors(currentWorldNormal, targetNormal);
|
||||
|
||||
// Apply global rotation
|
||||
obj.quaternion.premultiply(quaternion);
|
||||
obj.updateMatrixWorld(true);
|
||||
|
||||
// Snap to bed (Z=0)
|
||||
obj.geometry.computeBoundingBox();
|
||||
const box = obj.geometry.boundingBox.clone();
|
||||
box.applyMatrix4(obj.matrixWorld);
|
||||
const minZ = box.min.z;
|
||||
obj.position.z -= minZ;
|
||||
obj.updateMatrixWorld(true);
|
||||
|
||||
// Exit lay flat mode and reset to translate
|
||||
setTransformMode('translate');
|
||||
selectModel(obj);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (intersects.length > 0) {
|
||||
selectModel(intersects[0].object);
|
||||
} else {
|
||||
selectModel(null);
|
||||
}
|
||||
}
|
||||
|
||||
function selectModel(model) {
|
||||
if (activeModel && activeModel !== model) {
|
||||
scene.attach(activeModel);
|
||||
}
|
||||
activeModel = model;
|
||||
if (model) {
|
||||
scene.attach(model);
|
||||
transformProxy.position.copy(model.getWorldPosition(new THREE.Vector3()));
|
||||
transformProxy.rotation.set(0, 0, 0);
|
||||
transformProxy.scale.set(1, 1, 1);
|
||||
transformProxy.attach(model);
|
||||
transformControl.attach(transformProxy);
|
||||
} else {
|
||||
transformControl.detach();
|
||||
}
|
||||
}
|
||||
|
||||
function removeModel(model) {
|
||||
if (activeModel === model) {
|
||||
transformControl.detach();
|
||||
scene.attach(model);
|
||||
activeModel = null;
|
||||
}
|
||||
scene.remove(model);
|
||||
loadedModels = loadedModels.filter(m => m !== model);
|
||||
}
|
||||
|
||||
function clearPlate() {
|
||||
transformControl.detach();
|
||||
loadedModels.forEach(m => {
|
||||
scene.attach(m);
|
||||
scene.remove(m);
|
||||
});
|
||||
loadedModels = [];
|
||||
activeModel = null;
|
||||
}
|
||||
|
||||
function addModelToPlate(btnElement, fileId, url, name, status) {
|
||||
const iconSpan = btnElement.querySelector('i');
|
||||
const originalClass = iconSpan.className;
|
||||
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
|
||||
btnElement.disabled = true;
|
||||
|
||||
const loader = new THREE.STLLoader();
|
||||
loader.load(url, function (geometry) {
|
||||
// By default STLs center or are offset, let's normalize slightly to be on top of the plate
|
||||
geometry.computeBoundingBox();
|
||||
const center = geometry.boundingBox.getCenter(new THREE.Vector3());
|
||||
const minZ = geometry.boundingBox.min.z;
|
||||
geometry.translate(-center.x, -center.y, -minZ);
|
||||
|
||||
const material = new THREE.MeshPhongMaterial({ color: 0x0d6efd, specular: 0x111111, shininess: 200 });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.userData = {
|
||||
fileId: fileId,
|
||||
name: name,
|
||||
status: status,
|
||||
geomTrans: new THREE.Matrix4().makeTranslation(-center.x, -center.y, -minZ)
|
||||
};
|
||||
|
||||
let matrixData = btnElement.getAttribute('data-matrix');
|
||||
if (matrixData && matrixData.trim() !== '' && matrixData !== 'None') {
|
||||
try {
|
||||
let mArray = JSON.parse(matrixData);
|
||||
let savedMatrix = new THREE.Matrix4().fromArray(mArray);
|
||||
savedMatrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved matrix:', e);
|
||||
mesh.position.x = (Math.random() - 0.5) * 50;
|
||||
mesh.position.y = (Math.random() - 0.5) * 50;
|
||||
}
|
||||
} else {
|
||||
// Random slight offset so they don't exactly stack
|
||||
mesh.position.x = (Math.random() - 0.5) * 50;
|
||||
mesh.position.y = (Math.random() - 0.5) * 50;
|
||||
}
|
||||
|
||||
scene.add(mesh);
|
||||
loadedModels.push(mesh);
|
||||
selectModel(mesh);
|
||||
|
||||
iconSpan.className = originalClass;
|
||||
btnElement.disabled = false;
|
||||
}, undefined, function (error) {
|
||||
console.error(error);
|
||||
iconSpan.className = originalClass;
|
||||
btnElement.disabled = false;
|
||||
alert("{{ _('Error loading STL model file.') }}");
|
||||
});
|
||||
}
|
||||
|
||||
function checkBounds() {
|
||||
if (!bedBoxOutline) return false;
|
||||
let boundsViolation = {
|
||||
minX: false, maxX: false,
|
||||
minY: false, maxY: false,
|
||||
minZ: false, maxZ: false
|
||||
};
|
||||
|
||||
let outOfBounds = false;
|
||||
|
||||
for (let i = 0; i < loadedModels.length; i++) {
|
||||
let m = loadedModels[i];
|
||||
let box = new THREE.Box3().setFromObject(m);
|
||||
|
||||
if (box.min.x < -bedWidth / 2 - 0.05) boundsViolation.minX = true;
|
||||
if (box.max.x > bedWidth / 2 + 0.05) boundsViolation.maxX = true;
|
||||
if (box.min.y < -bedDepth / 2 - 0.05) boundsViolation.minY = true;
|
||||
if (box.max.y > bedDepth / 2 + 0.05) boundsViolation.maxY = true;
|
||||
if (box.min.z < -0.05) boundsViolation.minZ = true;
|
||||
if (box.max.z > bedHeight + 0.05) boundsViolation.maxZ = true;
|
||||
}
|
||||
|
||||
outOfBounds = boundsViolation.minX || boundsViolation.maxX ||
|
||||
boundsViolation.minY || boundsViolation.maxY ||
|
||||
boundsViolation.minZ || boundsViolation.maxZ;
|
||||
|
||||
for (let key in boundsViolation) {
|
||||
if (boundPlanes && boundPlanes[key]) {
|
||||
boundPlanes[key].visible = boundsViolation[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (outOfBounds) {
|
||||
bedBoxOutline.material.color.setHex(0xffaaaa);
|
||||
} else {
|
||||
bedBoxOutline.material.color.setHex(0xcccccc);
|
||||
}
|
||||
return outOfBounds;
|
||||
}
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
checkBounds();
|
||||
orbit.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
function mergeAndSlice() {
|
||||
if (loadedModels.length === 0) {
|
||||
alert("{{ _('Please add at least one model to the build plate.') }}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkBounds()) {
|
||||
alert("{{ _('One or more models are outside the print area. Please adjust them before slicing.') }}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedModels.length === 1) {
|
||||
const singleModel = loadedModels[0];
|
||||
if (singleModel.userData.status === 'sliced') {
|
||||
if (!confirm("{{ _('This model has already been sliced. The existing GCode will be overwritten. Continue?') }}")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pieces = loadedModels.map(m => {
|
||||
m.updateMatrixWorld(true);
|
||||
const mat = m.matrixWorld.clone();
|
||||
if (m.userData.geomTrans) {
|
||||
mat.multiply(m.userData.geomTrans);
|
||||
}
|
||||
const translation = new THREE.Matrix4().makeTranslation((bedWidth / 2) + offsetX, (bedDepth / 2) + offsetY, 0);
|
||||
mat.premultiply(translation);
|
||||
return {
|
||||
file_id: m.userData.fileId,
|
||||
matrix: mat.elements, // Array of 16 numbers used for slicing
|
||||
raw_matrix: m.matrix.elements // Local visual properties
|
||||
};
|
||||
});
|
||||
|
||||
const quality = document.getElementById('quality').value;
|
||||
const infill = document.getElementById('infill-density').value;
|
||||
const support = document.getElementById('support-type').value;
|
||||
const supportPattern = document.getElementById('support-pattern').value;
|
||||
|
||||
const btn = document.getElementById('btn-merge');
|
||||
const icon = document.getElementById('merge-icon');
|
||||
const text = document.getElementById('merge-text');
|
||||
|
||||
btn.disabled = true;
|
||||
icon.className = 'spinner-border spinner-border-sm me-2';
|
||||
text.innerText = '{{ _("Slicing queued!") }}';
|
||||
|
||||
// Ajax request
|
||||
fetch('{{ url_for("main.merge_and_slice") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ pieces: pieces, quality: quality, infill: infill, support: support, support_pattern: supportPattern })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
window.location.href = "{{ url_for('main.files') }}";
|
||||
} else {
|
||||
alert("Error: " + data.error);
|
||||
btn.disabled = false;
|
||||
icon.className = 'bi bi-gear-fill me-2';
|
||||
text.innerText = '{{ _("Merge & Slice") }}';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
alert("Error: " + String(err));
|
||||
btn.disabled = false;
|
||||
icon.className = 'bi bi-gear-fill me-2';
|
||||
text.innerText = '{{ _("Merge & Slice") }}';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const supportType = document.getElementById('support-type');
|
||||
const supportPattern = document.getElementById('support-pattern');
|
||||
if (supportType && supportPattern) {
|
||||
supportType.addEventListener('change', function() {
|
||||
supportPattern.disabled = (this.value === 'false');
|
||||
});
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const addId = params.get('add');
|
||||
if (addId) {
|
||||
const btn = document.getElementById('add-model-btn-' + addId);
|
||||
if (btn) {
|
||||
btn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,234 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2"><i class="bi bi-cloud-arrow-up me-2 text-primary"></i>{{ _('Upload & Slice STL') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4 mb-md-0">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body p-4">
|
||||
<form id="upload-form" method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold text-secondary">{{ _('Select STL File') }}</label>
|
||||
<div id="drop-zone" class="border rounded p-4 text-center position-relative" style="border: 2px dashed #0d6efd !important; cursor: pointer; transition: all 0.3s ease; background-color: #f8f9fa;">
|
||||
<i class="bi bi-cloud-arrow-up display-4 text-primary mb-2"></i>
|
||||
<p class="mt-2 text-secondary fw-bold mb-0" id="drop-text">{{ _('Drag & Drop STL file here or Click to Select') }}</p>
|
||||
<input class="form-control position-absolute w-100 h-100 top-0 start-0 opacity-0" type="file" id="file" name="file" accept=".stl" style="cursor: pointer;" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="progress-container" class="mb-4 d-none">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span class="text-secondary fw-bold small" id="progress-text">{{ _('Uploading...') }}</span>
|
||||
<span class="text-primary fw-bold small" id="progress-percent">0%</span>
|
||||
</div>
|
||||
<div class="progress rounded-pill" style="height: 10px;">
|
||||
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Here you can add slice configurations -->
|
||||
<div class="mb-4">
|
||||
<label for="quality" class="form-label fw-bold text-secondary">{{ _('Quality Profile') }}</label>
|
||||
<select class="form-select bg-light" id="quality" name="quality">
|
||||
{% for key, name in presets %}
|
||||
<option value="{{ key }}" {% if key == last_quality %}selected{% endif %}>{{ _(name) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submit-btn" class="btn btn-success fw-bold px-4 py-2 w-100 shadow-sm"><i class="bi bi-gear-fill me-2" id="submit-icon"></i><span id="submit-text">{{ _('Upload & Slice') }}</span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body p-0 position-relative">
|
||||
<div id="stl_viewer_container" style="height: 400px; width: 100%; border-radius: 0.375rem; overflow: hidden; background: #f8f9fa;">
|
||||
<!-- STL Viewer Integration Point -->
|
||||
<div id="viewer_placeholder" class="text-muted text-center position-absolute top-50 start-50 translate-middle">
|
||||
<i class="bi bi-box display-1 text-secondary opacity-50 mb-3 d-block"></i>
|
||||
<h5>{{ _('3D Preview Area') }}</h5><small>{{ _('Upload a file to display') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Three.js + STLLoader + OrbitControls -->
|
||||
<script src="{{ url_for('static', filename='js/three.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/OrbitControls.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/STLLoader.js') }}"></script>
|
||||
|
||||
<script>
|
||||
const fileInput = document.getElementById('file');
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const dropText = document.getElementById('drop-text');
|
||||
const uploadForm = document.getElementById('upload-form');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressPercent = document.getElementById('progress-percent');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const submitIcon = document.getElementById('submit-icon');
|
||||
const submitText = document.getElementById('submit-text');
|
||||
|
||||
function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); }
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropZone.style.backgroundColor = '#e9ecef';
|
||||
dropZone.style.borderColor = '#0b5ed7';
|
||||
}, false);
|
||||
});
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, () => {
|
||||
dropZone.style.backgroundColor = '#f8f9fa';
|
||||
dropZone.style.borderColor = '#0d6efd';
|
||||
}, false);
|
||||
});
|
||||
dropZone.addEventListener('drop', e => {
|
||||
if(e.dataTransfer.files.length) {
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if(!file) return;
|
||||
|
||||
dropText.innerText = file.name;
|
||||
document.getElementById('viewer_placeholder').style.display = 'none';
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(event) {
|
||||
initViewer(event.target.result);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
uploadForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const file = fileInput.files[0];
|
||||
if(!file) return;
|
||||
|
||||
const formData = new FormData(uploadForm);
|
||||
progressContainer.classList.remove('d-none');
|
||||
submitBtn.disabled = true;
|
||||
submitIcon.className = 'spinner-border spinner-border-sm me-2';
|
||||
submitText.innerText = '{{ _("Uploading...") }}';
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', window.location.href, true);
|
||||
|
||||
xhr.upload.onprogress = function(e) {
|
||||
if(e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
progressBar.style.width = percent + '%';
|
||||
progressBar.setAttribute('aria-valuenow', percent);
|
||||
progressPercent.innerText = percent + '%';
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function() {
|
||||
if(xhr.status >= 200 && xhr.status < 300) {
|
||||
progressBar.classList.remove('progress-bar-animated');
|
||||
progressBar.classList.remove('progress-bar-striped');
|
||||
submitText.innerText = '{{ _("Slicing queued!") }}';
|
||||
window.location.href = "{{ url_for('main.files') }}";
|
||||
} else {
|
||||
alert('Error: ' + xhr.statusText);
|
||||
resetUploadState();
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() {
|
||||
alert('Upload failed');
|
||||
resetUploadState();
|
||||
};
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
function resetUploadState() {
|
||||
progressContainer.classList.add('d-none');
|
||||
submitBtn.disabled = false;
|
||||
submitIcon.className = 'bi bi-gear-fill me-2';
|
||||
submitText.innerText = '{{ _("Upload & Slice") }}';
|
||||
progressBar.style.width = '0%';
|
||||
progressPercent.innerText = '0%';
|
||||
}
|
||||
|
||||
let scene, camera, renderer, controls;
|
||||
|
||||
function initViewer(data) {
|
||||
const container = document.getElementById('stl_viewer_container');
|
||||
// Clear previous if any
|
||||
container.innerHTML = '';
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color( 0xf8f9fa );
|
||||
|
||||
// Setup camera
|
||||
camera = new THREE.PerspectiveCamera( 45, container.clientWidth / container.clientHeight, 1, 1000 );
|
||||
camera.position.set( 0, -150, 150 );
|
||||
|
||||
// Setup renderer
|
||||
renderer = new THREE.WebGLRenderer( { antialias: true } );
|
||||
renderer.setSize( container.clientWidth, container.clientHeight );
|
||||
container.appendChild( renderer.domElement );
|
||||
|
||||
// Add lighting
|
||||
scene.add( new THREE.AmbientLight( 0x777777 ) );
|
||||
const directionalLight = new THREE.DirectionalLight( 0xffffff, 1 );
|
||||
directionalLight.position.set( 1, 1, 2 );
|
||||
scene.add( directionalLight );
|
||||
|
||||
// Load STL
|
||||
const loader = new THREE.STLLoader();
|
||||
const geometry = loader.parse( data );
|
||||
|
||||
geometry.computeBoundingBox();
|
||||
const center = geometry.boundingBox.getCenter(new THREE.Vector3());
|
||||
geometry.center();
|
||||
|
||||
const material = new THREE.MeshPhongMaterial( { color: 0x0d6efd, specular: 0x111111, shininess: 200 } );
|
||||
const mesh = new THREE.Mesh( geometry, material );
|
||||
|
||||
// Optional: scale model to fit view automatically
|
||||
const boundingSphere = geometry.boundingBox.getBoundingSphere(new THREE.Sphere());
|
||||
const radius = boundingSphere.radius;
|
||||
camera.position.set(0, -radius * 2, radius * 2);
|
||||
|
||||
scene.add( mesh );
|
||||
|
||||
// Add controls
|
||||
controls = new THREE.OrbitControls( camera, renderer.domElement );
|
||||
controls.enableDamping = true;
|
||||
controls.target.set(0,0,0);
|
||||
controls.update();
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame( animate );
|
||||
controls.update();
|
||||
renderer.render( scene, camera );
|
||||
}
|
||||
|
||||
// Handle window resize dynamically inside container context
|
||||
window.addEventListener('resize', function() {
|
||||
if(camera && renderer) {
|
||||
const container = document.getElementById('stl_viewer_container');
|
||||
camera.aspect = container.clientWidth / container.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize( container.clientWidth, container.clientHeight );
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "构建板"
|
||||
}
|
||||
1471
assets/js/TransformControls.js
Normal file
1471
assets/js/TransformControls.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
huey_queue.db
BIN
huey_queue.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
117
stl_merger.py
Normal file
117
stl_merger.py
Normal file
@@ -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('<I', f.read(4))[0]
|
||||
|
||||
faces = f.read()
|
||||
|
||||
# Precompute for speed
|
||||
def apply_m(_x, _y, _z):
|
||||
w = _x * matrix[3] + _y * matrix[7] + _z * matrix[11] + matrix[15]
|
||||
nx = (_x * matrix[0] + _y * matrix[4] + _z * matrix[8] + matrix[12]) / w
|
||||
ny = (_x * matrix[1] + _y * matrix[5] + _z * matrix[9] + matrix[13]) / w
|
||||
nz = (_x * matrix[2] + _y * matrix[6] + _z * matrix[10] + matrix[14]) / w
|
||||
return nx, ny, nz
|
||||
|
||||
def apply_rot(_nx, _ny, _nz):
|
||||
# Normals don't need translation, only upper 3x3
|
||||
# Assuming isotropic scaling for normals, otherwise needs inverse transpose
|
||||
x = _nx * matrix[0] + _ny * matrix[4] + _nz * matrix[8]
|
||||
y = _nx * matrix[1] + _ny * matrix[5] + _nz * matrix[9]
|
||||
z = _nx * matrix[2] + _ny * matrix[6] + _nz * matrix[10]
|
||||
length = math.sqrt(x*x + y*y + z*z)
|
||||
if length > 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('<I', num_faces)
|
||||
|
||||
offset = 84
|
||||
src_offset = 0
|
||||
for _ in range(num_faces):
|
||||
face_data = faces[src_offset:src_offset+50]
|
||||
if len(face_data) < 50:
|
||||
break
|
||||
|
||||
nx, ny, nz, v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z, attr = struct.unpack('<12fH', face_data)
|
||||
|
||||
# Transform normal
|
||||
nnx, nny, nnz = apply_rot(nx, ny, nz)
|
||||
# Transform vertices
|
||||
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', out, offset, nnx, nny, nnz, nv1x, nv1y, nv1z, nv2x, nv2y, nv2z, nv3x, nv3y, nv3z, attr)
|
||||
offset += 50
|
||||
src_offset += 50
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(out)
|
||||
|
||||
return num_faces
|
||||
|
||||
def merge_stls(input_files, output_path):
|
||||
total_faces = 0
|
||||
meshes_data = []
|
||||
|
||||
for path, matrix in input_files:
|
||||
with open(path, 'rb') as f:
|
||||
f.read(80)
|
||||
faces = struct.unpack('<I', f.read(4))[0]
|
||||
data = f.read(faces * 50)
|
||||
|
||||
# transform data
|
||||
# To speed things up, we could just do the transform in python like above
|
||||
# For this simple prototype, let's process each file into memory:
|
||||
|
||||
def apply_m(_x, _y, _z):
|
||||
w = _x * matrix[3] + _y * matrix[7] + _z * matrix[11] + matrix[15]
|
||||
nx = (_x * matrix[0] + _y * matrix[4] + _z * matrix[8] + matrix[12]) / w
|
||||
ny = (_x * matrix[1] + _y * matrix[5] + _z * matrix[9] + matrix[13]) / w
|
||||
nz = (_x * matrix[2] + _y * matrix[6] + _z * matrix[10] + matrix[14]) / w
|
||||
return nx, ny, nz
|
||||
|
||||
def apply_rot(_nx, _ny, _nz):
|
||||
x = _nx * matrix[0] + _ny * matrix[4] + _nz * matrix[8]
|
||||
y = _nx * matrix[1] + _ny * matrix[5] + _nz * matrix[9]
|
||||
z = _nx * matrix[2] + _ny * matrix[6] + _nz * matrix[10]
|
||||
length = math.sqrt(x*x + y*y + z*z)
|
||||
if length > 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('<I', total_faces))
|
||||
for d in meshes_data:
|
||||
f.write(d)
|
||||
|
||||
65
stl_simplifier.py
Normal file
65
stl_simplifier.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import numpy as np
|
||||
from stl import mesh
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
|
||||
def simplify_stl(input_path, output_path, keep_ratio=0.1):
|
||||
try:
|
||||
# Load mesh using numpy-stl
|
||||
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)
|
||||
|
||||
if max_dim == 0:
|
||||
m.save(output_path)
|
||||
return True
|
||||
|
||||
# Target roughly a resolution that gives us keep_ratio faces.
|
||||
# This is a heuristic approach to grid-based vertex clustering.
|
||||
# We start with a baseline resolution (e.g. 2% of max dimension)
|
||||
# and adjust if needed.
|
||||
grid_resolution = max_dim * 0.02
|
||||
|
||||
# Function to simplify given a grid size
|
||||
def do_simplify(g_size):
|
||||
v_idx = np.round((vertices - min_v) / g_size).astype(np.int32)
|
||||
# Find unique grid cells and map old vertices to them
|
||||
_, unique_idx, inv_idx = np.unique(v_idx, axis=0, return_index=True, return_inverse=True)
|
||||
new_verts = vertices[unique_idx]
|
||||
|
||||
# Map faces to new vertices
|
||||
faces = inv_idx.reshape(-1, 3)
|
||||
|
||||
# Remove degenerate faces (faces where at least two vertices resolve to the same cell)
|
||||
valid = (faces[:,0] != faces[:,1]) & (faces[:,1] != faces[:,2]) & (faces[:,0] != faces[:,2])
|
||||
valid_faces = faces[valid]
|
||||
|
||||
return new_verts, valid_faces
|
||||
|
||||
new_vertices, valid_faces = do_simplify(grid_resolution)
|
||||
|
||||
# Build the simplified mesh
|
||||
new_m = mesh.Mesh(np.zeros(valid_faces.shape[0], dtype=mesh.Mesh.dtype))
|
||||
|
||||
# Vectorized assignment
|
||||
new_m.vectors[:, 0, :] = new_vertices[valid_faces[:, 0]]
|
||||
new_m.vectors[:, 1, :] = new_vertices[valid_faces[:, 1]]
|
||||
new_m.vectors[:, 2, :] = new_vertices[valid_faces[:, 2]]
|
||||
|
||||
# Calculate normals correctly
|
||||
new_m.update_normals()
|
||||
new_m.save(output_path)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error simplifying STL: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 2:
|
||||
simplify_stl(sys.argv[1], sys.argv[2])
|
||||
8
tmp/parse_bed.py
Normal file
8
tmp/parse_bed.py
Normal file
@@ -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'])
|
||||
53
tmp/patch_proxy.py
Normal file
53
tmp/patch_proxy.py
Normal file
@@ -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/<int:file_id>')
|
||||
@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/<int:file_id>')
|
||||
@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/<int:file_id>')
|
||||
@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")
|
||||
36
tmp/patch_routes.py
Normal file
36
tmp/patch_routes.py
Normal file
@@ -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")
|
||||
141
tmp/repatch_routes.py
Normal file
141
tmp/repatch_routes.py
Normal file
@@ -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/<int:file_id>')
|
||||
@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/<int:file_id>')
|
||||
@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.")
|
||||
14
tmp/test_pq.py
Normal file
14
tmp/test_pq.py
Normal file
@@ -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)
|
||||
19
tmp/test_pq2.py
Normal file
19
tmp/test_pq2.py
Normal file
@@ -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)
|
||||
9
tmp/test_pq3.py
Normal file
9
tmp/test_pq3.py
Normal file
@@ -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)
|
||||
35
tmp/test_simplifier.py
Normal file
35
tmp/test_simplifier.py
Normal file
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user