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 app.models import db, User, PrintFile, SystemConfig from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task from app import i18n_dict # import trimesh.repair from app.utils.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('slice/index.html') @main_bp.route('/set_language/') 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: if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return jsonify({'error': 'No file part'}), 400 flash('No file part', 'danger') return redirect(request.url) uploaded_files = request.files.getlist('file') success_count = 0 for file in uploaded_files: if file.filename == '': continue 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}_{success_count}{ext}" filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename) file.save(filepath) 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) success_count += 1 if success_count > 0: if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return jsonify({'success': True, 'count': success_count}) flash(f'{success_count} file(s) uploaded successfully!', 'success') else: if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return jsonify({'error': 'No valid files uploaded'}), 400 flash('No valid files uploaded', 'danger') 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('slice/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/') @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/') @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('slice/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/', 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 --- 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('slice/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/') @login_required def serve_file(file_id): f = PrintFile.query.get_or_404(file_id) if f.user_id != current_user.id and not current_user.is_admin: abort(403) path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) return send_file(path) @main_bp.route('/proxy/') @login_required def serve_proxy_file(file_id): f = PrintFile.query.get_or_404(file_id) if f.user_id != current_user.id and not current_user.is_admin: abort(403) path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) proxy_path = path + '.proxy.stl' if 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!'})