有构建板,支持多模型构建,但生成支撑的切片还有bug

This commit is contained in:
2026-04-11 01:50:30 +08:00
parent 975f06eb46
commit 3020957367
31 changed files with 3001 additions and 303 deletions

5
.gitignore vendored
View File

@@ -1,2 +1,5 @@
__pycache__
uploads
uploads/*
venv/*
instance/*
huey_queue.db

0
app/app.db Normal file
View File

View 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)

View File

@@ -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!'})

View File

@@ -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()

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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
View 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 %}

View File

@@ -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 %}

View File

@@ -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"
}

View File

@@ -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": "构建板"
}

File diff suppressed because it is too large Load Diff

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
View 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
View 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])

BIN
test.STL Normal file

Binary file not shown.

8
tmp/parse_bed.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)