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 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/') 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='uploaded' # Only display as uploaded, no automatic slicing ) db.session.add(print_file) db.session.commit() 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/') @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('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 --- @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') 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), ('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//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/') @login_required def serve_file(file_id): f = PrintFile.query.get_or_404(file_id) if f.user_id != current_user.id and not current_user.is_admin: abort(403) path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) return send_file(path) @main_bp.route('/proxy/') @login_required def serve_proxy_file(file_id): f = PrintFile.query.get_or_404(file_id) if f.user_id != current_user.id and not current_user.is_admin: abort(403) path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename) proxy_path = path + '.proxy.stl' if not os.path.exists(proxy_path): try: simplify_stl(path, proxy_path, keep_ratio=0.05) # compress to 90% 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 += "等合并切片" elif len(pieces) > 1: 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 is_edit = data.get('is_edit', False) if len(inputs) == 1 and is_edit: # 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!'})