Files
AIO_3D_Print_Web_Platform/app/routes.py
2026-04-13 16:32:30 +08:00

534 lines
22 KiB
Python

import json
import trimesh
import uuid
import os
import configparser
from datetime import datetime
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, slice_stl_task, simplify_stl_task
from app import i18n_dict
# import trimesh.repair
from stl_simplifier import simplify_stl
main_bp = Blueprint('main', __name__)
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
# Guest User Middleware
@main_bp.before_app_request
def assign_guest_cookie():
if not current_user.is_authenticated:
guest_id = request.cookies.get('guest_id')
if not guest_id:
guest_id = str(uuid.uuid4())
user = User(username=f'guest_{guest_id[:8]}', is_guest=True, guest_cookie_id=guest_id)
db.session.add(user)
db.session.commit()
login_user(user)
# We will set the cookie in the response after request, see below
request.guest_id_to_set = guest_id
else:
user = User.query.filter_by(guest_cookie_id=guest_id).first()
if user:
login_user(user)
@main_bp.after_app_request
def set_guest_cookie(response):
if hasattr(request, 'guest_id_to_set'):
response.set_cookie('guest_id', request.guest_id_to_set, max_age=60*60*24*365) # 1 year
return response
# --- Main Routes ---
@main_bp.route('/')
def index():
return render_template('index.html')
@main_bp.route('/set_language/<lang>')
def set_language(lang):
if lang not in i18n_dict:
lang = 'en'
# return to previous page
response = make_response(redirect(request.referrer or url_for('main.index')))
response.set_cookie('lang', lang, max_age=60*60*24*365)
return response
@main_bp.route('/files', methods=['GET', 'POST'])
@login_required
def files():
if request.method == 'POST':
if 'file' not in request.files:
flash('No file part', 'danger')
return redirect(request.url)
file = request.files['file']
if file.filename == '':
flash('No selected file', 'danger')
return redirect(request.url)
if file and file.filename.lower().endswith('.stl'):
original_filename = file.filename # Do not use secure_filename to keep Chinese characters
ext = os.path.splitext(original_filename)[1].lower()
if not ext:
ext = '.stl'
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
unique_filename = f"{timestamp}_{uuid.uuid4().hex}{ext}"
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
# pass
# if needs_repair:
# # Attempt automatic 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='simplifying' # Set to simplifying while proxy is generated
)
db.session.add(print_file)
db.session.commit()
# Start background simplification
simplify_stl_task(print_file.id, filepath)
flash('File uploaded successfully!', 'success')
return redirect(url_for('main.files'))
# Order by newest first
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')
@login_required
def files_status():
files = PrintFile.query.filter_by(user_id=current_user.id).all()
return jsonify({str(f.id): f.status for f in files})
@main_bp.route('/download/<int:file_id>')
@login_required
def download_gcode(file_id):
print_file = PrintFile.query.get_or_404(file_id)
if print_file.user_id != current_user.id and not current_user.is_admin:
abort(403)
if print_file.status != 'sliced':
flash('File is not ready yet.', 'warning')
return redirect(url_for('main.files'))
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
if os.path.exists(filepath):
safe_name = print_file.original_filename.rsplit('.', 1)[0] + '.gcode'
return send_file(filepath, as_attachment=True, download_name=safe_name)
flash('GCode file not found. It might have been deleted.', 'danger')
return redirect(url_for('main.files'))
@main_bp.route('/preview_gcode/<int:file_id>')
@login_required
def preview_gcode(file_id):
print_file = PrintFile.query.get_or_404(file_id)
if print_file.user_id != current_user.id and not current_user.is_admin:
abort(403)
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
content = "File not found or not ready."
line_count = 0
if os.path.exists(filepath):
with open(filepath, 'r') as f:
lines = f.readlines()
line_count = len(lines)
content = "".join(lines[:500]) # Preview first 500 lines
if line_count > 500:
content += f"\n... \n[Preview truncated. Total lines: {line_count}. Please download to view full file.]"
w, h, hd = get_bed_dimensions()
configs = {c.key: c.value for c in SystemConfig.query.all()}
offset_x = float(configs.get('offset_x', '0.0'))
offset_y = float(configs.get('offset_y', '0.0'))
return render_template('gcode_preview.html', file=print_file, content=content, line_count=line_count, machine_width=w, machine_depth=h, machine_height=hd, offset_x=offset_x, offset_y=offset_y)
@main_bp.route('/delete_file/<int:file_id>', methods=['POST'])
@login_required
def delete_file(file_id):
print_file = PrintFile.query.get_or_404(file_id)
if print_file.user_id != current_user.id and not current_user.is_admin:
abort(403)
stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename)
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
gcode_path = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
proxy_path = stl_path + '.proxy.stl'
if os.path.exists(stl_path):
os.remove(stl_path)
if os.path.exists(proxy_path):
os.remove(proxy_path)
if os.path.exists(gcode_path):
os.remove(gcode_path)
db.session.delete(print_file)
db.session.commit()
flash(f"Deleted {print_file.original_filename} successfully.", 'success')
return redirect(url_for('main.files'))
# --- Auth Routes ---
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter_by(username=username, is_guest=False).first()
if user and check_password_hash(user.password_hash, password):
login_user(user)
return redirect(url_for('main.index'))
flash('Invalid username or password', 'danger')
return render_template('login.html')
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
response = make_response(redirect(url_for('main.index')))
response.delete_cookie('guest_id') # Optionally clear guest cookie
return response
# --- Admin Routes ---
@admin_bp.before_request
def require_admin():
if not current_user.is_authenticated or not current_user.is_admin:
flash('Admin access required', 'danger')
return redirect(url_for('main.index'))
@admin_bp.route('/settings', methods=['GET', 'POST'])
def settings():
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')
proxy_skip_size_mb = request.form.get('proxy_skip_size_mb', '5.0')
default_infill = request.form.get('default_infill', '20')
default_support = request.form.get('default_support', 'false')
default_support_pattern = request.form.get('default_support_pattern', 'tree')
default_quality = request.form.get('default_quality', 'base_global_standard.inst.cfg')
# update or create config entries
config_items = [
('offset_x', offset_x),
('offset_y', offset_y),
('proxy_skip_size_mb', proxy_skip_size_mb),
('default_infill', default_infill),
('default_support', default_support),
('default_support_pattern', default_support_pattern),
('default_quality', default_quality)
]
for key, val in config_items:
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()}
presets = get_quality_presets()
return render_template('admin_settings.html', configs=configs, presets=presets)
@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)
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
def delete_user(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash('You cannot delete yourself.', 'danger')
return redirect(url_for('admin.users'))
print_files = PrintFile.query.filter_by(user_id=user.id).all()
for print_file in print_files:
stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename)
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
gcode_path = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
proxy_path = stl_path + '.proxy.stl'
if os.path.exists(stl_path):
try: os.remove(stl_path)
except: pass
if os.path.exists(proxy_path):
try: os.remove(proxy_path)
except: pass
if os.path.exists(gcode_path):
try: os.remove(gcode_path)
except: pass
db.session.delete(print_file)
db.session.delete(user)
db.session.commit()
flash(f'User {user.username} and all their files have been deleted.', 'success')
return redirect(url_for('admin.users'))
def get_bed_dimensions():
try:
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:
path = os.path.join(current_app.root_path, '..', 'print_config', 'quality', 'creality', 'presets')
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('_', ' ')
name = f.replace('.inst.cfg', '')
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()
configs = {c.key: c.value for c in SystemConfig.query.all()}
offset_x = float(configs.get('offset_x', '0.0'))
offset_y = float(configs.get('offset_y', '0.0'))
default_infill = configs.get('default_infill', '20')
default_support = configs.get('default_support', 'false')
default_support_pattern = configs.get('default_support_pattern', 'tree')
default_quality = configs.get('default_quality', '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=default_quality, models=models, offset_x=offset_x, offset_y=offset_y, default_infill=default_infill, default_support=default_support, default_support_pattern=default_support_pattern)
@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 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 += "等合并切片"
elif len(pieces) > 1:
combined_name += "合并切片"
else:
combined_name += " 单独切片"
is_edit = data.get('is_edit', False)
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 and is_edit and len(pieces) == 1:
f.transform_matrix = json.dumps({
"is_composite": False,
"matrix": p['raw_matrix'],
"settings": {
"quality": quality,
"infill": infill_density,
"support": support_enable,
"support_pattern": support_pattern
}
})
db.session.add(f)
db.session.commit()
target_file_id = data.get('target_file_id')
if is_edit and target_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'
if print_file.transform_matrix and 'is_composite' in print_file.transform_matrix:
composite_data = {
"is_composite": True,
"parts": [],
"settings": {
"quality": quality,
"infill": infill_density,
"support": support_enable,
"support_pattern": support_pattern
}
}
for p in pieces:
pf = PrintFile.query.get(p['file_id'])
if pf:
composite_data['parts'].append({
"file_id": pf.id,
"name": pf.original_filename,
"url": url_for('main.serve_proxy_file', file_id=pf.id),
"raw_matrix": p.get('raw_matrix', p['matrix'])
})
print_file.transform_matrix = json.dumps(composite_data)
elif len(pieces) == 1:
print_file.transform_matrix = json.dumps({
"is_composite": False,
"matrix": pieces[0].get('raw_matrix', pieces[0]['matrix']),
"settings": {
"quality": quality,
"infill": infill_density,
"support": support_enable,
"support_pattern": support_pattern
}
})
db.session.commit()
temp_filename = f"temp_edit_{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)
elif len(inputs) == 1 and is_edit:
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)
# 构建组合文件元数据树 (is_composite: true)
composite_data = {
"is_composite": True,
"parts": [],
"settings": {
"quality": quality,
"infill": infill_density,
"support": support_enable,
"support_pattern": support_pattern
}
}
for p in pieces:
pf = PrintFile.query.get(p['file_id'])
if pf:
composite_data['parts'].append({
"file_id": pf.id,
"name": pf.original_filename,
"url": url_for('main.serve_proxy_file', file_id=pf.id),
"raw_matrix": p.get('raw_matrix', p['matrix'])
})
print_file = PrintFile(
filename=unique_filename,
original_filename=f"{combined_name}.stl",
file_type='stl',
user_id=current_user.id,
status='merging',
transform_matrix=json.dumps(composite_data)
)
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!'})