基础的切片和质量控制
This commit is contained in:
82
app/__init__.py
Normal file
82
app/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import os
|
||||
import json
|
||||
from flask import Flask, request, session, current_app
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
db = SQLAlchemy()
|
||||
login_manager = LoginManager()
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
if dbapi_connection.__class__.__module__ == "sqlite3":
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.close()
|
||||
|
||||
# Load all i18n jsons
|
||||
i18n_dict = {}
|
||||
|
||||
def load_i18n(app):
|
||||
global i18n_dict
|
||||
i18n_dir = os.path.join(app.root_path, '..', 'assets', 'i18n')
|
||||
if os.path.exists(i18n_dir):
|
||||
for f in os.listdir(i18n_dir):
|
||||
if f.endswith('.json'):
|
||||
lang_code = f.replace('.json', '')
|
||||
with open(os.path.join(i18n_dir, f), 'r', encoding='utf-8') as jf:
|
||||
i18n_dict[lang_code] = json.load(jf)
|
||||
|
||||
def get_locale():
|
||||
# 用户手动选择的语言保存在 cookie 中优先
|
||||
lang = request.cookies.get('lang')
|
||||
if lang in i18n_dict:
|
||||
return lang
|
||||
|
||||
# Check accept_languages with our available dict keys
|
||||
best_match = request.accept_languages.best_match(list(i18n_dict.keys()))
|
||||
return best_match if best_match else 'en'
|
||||
|
||||
def _t(key):
|
||||
lang = get_locale()
|
||||
# Fallback to english if language missing or key missing
|
||||
if lang in i18n_dict and key in i18n_dict[lang]:
|
||||
return i18n_dict[lang][key]
|
||||
if 'en' in i18n_dict and key in i18n_dict['en']:
|
||||
return i18n_dict['en'][key]
|
||||
return key # fallback to the key itself
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__, static_url_path='/assets', static_folder='../assets')
|
||||
app.config['SECRET_KEY'] = 'your-secret-key-change-it-in-production'
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///aio_3d.db'
|
||||
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'connect_args': {'timeout': 15}}
|
||||
app.config['UPLOAD_FOLDER'] = os.path.abspath(os.path.join(app.root_path, '..', 'uploads'))
|
||||
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
|
||||
load_i18n(app)
|
||||
|
||||
db.init_app(app)
|
||||
login_manager.init_app(app)
|
||||
|
||||
# Inject translation function into jinja
|
||||
@app.context_processor
|
||||
def inject_i18n():
|
||||
return dict(_=_t)
|
||||
|
||||
login_manager.login_view = 'auth.login'
|
||||
|
||||
with app.app_context():
|
||||
from . import models
|
||||
db.create_all()
|
||||
|
||||
from .routes import main_bp, auth_bp, admin_bp
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
return app
|
||||
31
app/models.py
Normal file
31
app/models.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
from flask_login import UserMixin
|
||||
from . import db, login_manager
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(200))
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
is_guest = db.Column(db.Boolean, default=True)
|
||||
guest_cookie_id = db.Column(db.String(100), unique=True, nullable=True) # 用于绑定不同设备的Guest
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
print_files = db.relationship('PrintFile', backref='uploader', lazy=True)
|
||||
|
||||
class PrintFile(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
original_filename = db.Column(db.String(255), nullable=False) # 保存原有名称
|
||||
file_type = db.Column(db.String(10)) # stl 或者 gcode
|
||||
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
|
||||
|
||||
class SystemConfig(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(50), unique=True, nullable=False)
|
||||
value = db.Column(db.String(255), nullable=False)
|
||||
229
app/routes.py
Normal file
229
app/routes.py
Normal file
@@ -0,0 +1,229 @@
|
||||
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
|
||||
import os
|
||||
import uuid
|
||||
import configparser
|
||||
from datetime import datetime
|
||||
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])
|
||||
|
||||
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):
|
||||
from app import i18n_dict
|
||||
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('/slice', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def slice_page():
|
||||
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)
|
||||
|
||||
print_file = PrintFile(
|
||||
filename=unique_filename,
|
||||
original_filename=original_filename,
|
||||
file_type='stl',
|
||||
user_id=current_user.id,
|
||||
status='waiting'
|
||||
)
|
||||
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
|
||||
|
||||
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()
|
||||
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.]"
|
||||
|
||||
return render_template('gcode_preview.html', file=print_file, content=content, line_count=line_count)
|
||||
|
||||
@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)
|
||||
|
||||
if os.path.exists(stl_path):
|
||||
os.remove(stl_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')
|
||||
def settings():
|
||||
configs = 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)
|
||||
85
app/tasks.py
Normal file
85
app/tasks.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from huey import SqliteHuey
|
||||
import subprocess
|
||||
import os
|
||||
from .models import db, PrintFile, SystemConfig
|
||||
|
||||
huey = SqliteHuey(filename='huey_queue.db')
|
||||
|
||||
import configparser
|
||||
|
||||
@huey.task()
|
||||
def slice_stl_task(file_id, stl_filepath, quality_preset=None):
|
||||
# This is run by the Huey worker
|
||||
# We need to create an app context to interact with the database
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if not print_file:
|
||||
return
|
||||
|
||||
# Cache variables and commit slicing status
|
||||
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
gcode_filepath = os.path.join(app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
print_file.status = 'slicing'
|
||||
db.session.commit()
|
||||
|
||||
# Remove DB session to avoid locking the sqlite db during long slicing operations
|
||||
db.session.remove()
|
||||
|
||||
try:
|
||||
# Create Cura engine options
|
||||
# use our local minimal configurations detached from the entire Cura framework
|
||||
print_config_path = os.path.abspath(os.path.join(app.root_path, '..', 'print_config'))
|
||||
printers_path = os.path.join(print_config_path, 'printers')
|
||||
extruders_path = os.path.join(print_config_path, 'extruders')
|
||||
materials_path = os.path.join(print_config_path, 'materials')
|
||||
presets_path = os.path.join(print_config_path, 'presets')
|
||||
env = os.environ.copy()
|
||||
env["CURA_ENGINE_SEARCH_PATH"] = f"{printers_path}:{extruders_path}:{materials_path}:{presets_path}"
|
||||
|
||||
command = [
|
||||
"CuraEngine", "slice",
|
||||
"-j", os.path.join(printers_path, "creality_ender3v3se.def.json")
|
||||
]
|
||||
|
||||
# Apply quality presets if any
|
||||
if quality_preset:
|
||||
config = configparser.ConfigParser()
|
||||
preset_path = os.path.join(presets_path, 'creality', 'base', quality_preset)
|
||||
if os.path.exists(preset_path):
|
||||
config.read(preset_path)
|
||||
if config.has_section('values'):
|
||||
for key, val in config.items('values'):
|
||||
command.extend(['-s', f"{key}={val}"])
|
||||
|
||||
command.extend([
|
||||
"-l", stl_filepath,
|
||||
"-o", gcode_filepath
|
||||
])
|
||||
|
||||
app.logger.info(f"Running command: {' '.join(command)}")
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
# Re-fetch print_file and update status
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if not print_file:
|
||||
return
|
||||
|
||||
if process.returncode == 0:
|
||||
print_file.status = 'sliced'
|
||||
else:
|
||||
print_file.status = 'failed'
|
||||
app.logger.error(f"CuraEngine Error: {stderr.decode()}")
|
||||
|
||||
except Exception as e:
|
||||
# Re-fetch in case of exception
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if print_file:
|
||||
print_file.status = 'failed'
|
||||
app.logger.error(f"Subprocess Exception: {e}")
|
||||
|
||||
db.session.commit()
|
||||
db.session.remove()
|
||||
|
||||
21
app/templates/admin_settings.html
Normal file
21
app/templates/admin_settings.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% 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">System Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5>CuraEngine Configurations</h5>
|
||||
<hr>
|
||||
<form method="POST">
|
||||
<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">
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" onclick="alert('Settings saved (demo)')">Save Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
42
app/templates/admin_users.html
Normal file
42
app/templates/admin_users.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% 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">User Management</h1>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created At</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-danger">Admin</span>
|
||||
{% elif user.is_guest %}
|
||||
<span class="badge bg-secondary">Guest</span>
|
||||
{% else %}
|
||||
<span class="badge bg-primary">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" {% if user.id == current_user.id %}disabled{% endif %}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
135
app/templates/base.html
Normal file
135
app/templates/base.html
Normal file
@@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AIO 3D Slicer</title>
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="{{ url_for('static', filename='css/bootstrap-icons.css') }}" rel="stylesheet">
|
||||
<style>
|
||||
body { padding-top: 56px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: #f8f9fa; }
|
||||
.sidebar { position: fixed; top: 56px; bottom: 0; left: 0; z-index: 100; padding: 0; box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); background-color: #fff; }
|
||||
.sidebar-sticky { position: relative; top: 0; height: calc(100vh - 56px); padding-top: 1rem; overflow-x: hidden; overflow-y: auto; }
|
||||
|
||||
.nav-pills .nav-link { border-radius: 0.5rem; padding: 0.75rem 1rem; font-weight: 500; transition: all 0.2s ease; margin-bottom: 0.25rem; }
|
||||
.nav-pills .nav-link:hover { background-color: rgba(13,110,253,0.05); color: #0d6efd !important; }
|
||||
.nav-pills .nav-link.active { background-color: #0d6efd; color: white !important; box-shadow: 0 4px 6px rgba(13,110,253,0.3); }
|
||||
|
||||
.navbar-brand { font-size: 1.25rem; letter-spacing: 0.5px; }
|
||||
.card { border: none; border-radius: 0.75rem; overflow: hidden; }
|
||||
.card-header { border-bottom: 1px solid rgba(0,0,0,.05); background-color: transparent; }
|
||||
|
||||
.toast-container { margin-bottom: 20px; margin-right: 20px; }
|
||||
.toast { border-radius: 0.5rem; box-shadow: 0 0.5rem 1rem rgba(0,0,0,.15); opacity: 0.95; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top shadow-sm">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold" href="{{ url_for('main.index') }}"><i class="bi bi-printer me-2"></i>AIO 3D Slicer</a>
|
||||
<div class="d-flex text-light align-items-center">
|
||||
<div class="dropdown me-3">
|
||||
<button class="btn btn-sm btn-outline-light dropdown-toggle" type="button" id="langDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-globe me-1"></i>{{ _('Language') }}
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="langDropdown">
|
||||
<li><a class="dropdown-item {% if request.cookies.get('lang') == 'en' %}active{% endif %}" href="{{ url_for('main.set_language', lang='en') }}"><i class="bi bi-translate me-2"></i>English</a></li>
|
||||
<li><a class="dropdown-item {% if request.cookies.get('lang') == 'zh-cn' %}active{% endif %}" href="{{ url_for('main.set_language', lang='zh-cn') }}"><i class="bi bi-translate me-2"></i>中文 (简体)</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if current_user.is_guest %}
|
||||
<span class="me-3 text-secondary"><i class="bi bi-incognito me-1"></i>{{ _('Guest') }} ({{ current_user.username }})</span>
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-outline-light btn-sm rounded-pill px-3">{{ _('Login') }}</a>
|
||||
{% else %}
|
||||
<span class="me-3 text-success fw-semibold"><i class="bi bi-person-circle me-1"></i>{{ current_user.username }}</span>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-light btn-sm rounded-pill px-3">{{ _('Logout') }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-white sidebar collapse border-end">
|
||||
<div class="sidebar-sticky pt-3 px-2">
|
||||
<ul class="nav flex-column nav-pills gap-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'main.index' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.index') }}">
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-2 text-muted fw-bold text-uppercase" style="font-size: 0.75rem;">
|
||||
<span><i class="bi bi-shield-lock me-1"></i>{{ _('Admin Options') }}</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column nav-pills gap-1 mb-2">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'admin.settings' %}active text-white shadow-sm{% endif %}" href="{{ url_for('admin.settings') }}">
|
||||
<i class="bi bi-gear me-2"></i>{{ _('System Settings') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'admin.users' %}active text-white shadow-sm{% endif %}" href="{{ url_for('admin.users') }}">
|
||||
<i class="bi bi-people me-2"></i>{{ _('User Management') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 mt-4 bg-light min-vh-100 pb-5">
|
||||
|
||||
<!-- Toast Notification Container -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1055;">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
{% set toast_class = 'bg-success text-white' if category == 'success' else 'bg-danger text-white' if category == 'danger' else 'bg-warning text-dark' if category == 'warning' else 'bg-primary text-white' %}
|
||||
<div class="toast align-items-center border-0 {{ toast_class }}" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body fw-medium">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
|
||||
<script>
|
||||
// Initialize Toasts automatically
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var toastElList = [].slice.call(document.querySelectorAll('.toast'))
|
||||
var toastList = toastElList.map(function (toastEl) {
|
||||
return new bootstrap.Toast(toastEl, { delay: 3000 }).show()
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
132
app/templates/files.html
Normal file
132
app/templates/files.html
Normal file
@@ -0,0 +1,132 @@
|
||||
{% 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>
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-4 fw-semibold text-secondary">{{ _('Date Uploaded') }}</th>
|
||||
<th class="fw-semibold text-secondary">{{ _('Original Name') }}</th>
|
||||
<th class="fw-semibold text-secondary">{{ _('Status') }}</th>
|
||||
<th class="pe-4 fw-semibold text-secondary">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for file in files %}
|
||||
<tr id="file-row-{{ file.id }}" data-status="{{ file.status }}">
|
||||
<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' %}
|
||||
<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 == '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' %}
|
||||
<span class="badge bg-success rounded-pill fw-normal px-2"><i class="bi bi-check-circle me-1"></i>{{ _('Sliced') }}</span>
|
||||
{% elif file.status == 'failed' %}
|
||||
<span class="badge bg-danger rounded-pill fw-normal px-2"><i class="bi bi-x-circle me-1"></i>{{ _('Failed') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="pe-4">
|
||||
<div class="d-flex gap-2" id="actions-container-{{ file.id }}">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('main.delete_file', file_id=file.id) }}" method="POST" onsubmit="return confirm('{{ _('Are you sure you want to delete this file?') }}');">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger shadow-sm" title="{{ _('Delete') }}"><i class="bi bi-trash3"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-5">
|
||||
<i class="bi bi-folder-x display-4 d-block mb-3 opacity-50"></i>
|
||||
{{ _('No files uploaded yet.') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const checkInterval = 1000;
|
||||
let pollTimer = null;
|
||||
|
||||
function fetchStatus() {
|
||||
fetch(`{{ url_for('main.files_status') }}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
let hasPending = false;
|
||||
for (const [id, status] of Object.entries(data)) {
|
||||
const tr = document.getElementById('file-row-' + id);
|
||||
if (!tr) continue;
|
||||
|
||||
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>';
|
||||
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 = '';
|
||||
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);
|
||||
actionsHtml += `<a href="${downloadUrl}" class="btn btn-sm btn-outline-primary shadow-sm" title="{{ _('Download GCode') }}"><i class="bi bi-download"></i></a>\n`;
|
||||
actionsHtml += `<a href="${previewUrl}" class="btn btn-sm btn-outline-info shadow-sm" title="{{ _('GCode Preview') }}"><i class="bi bi-eye"></i></a>\n`;
|
||||
}
|
||||
const deleteUrl = `{{ url_for('main.delete_file', file_id=999999999) }}`.replace('999999999', id);
|
||||
actionsHtml += `<form action="${deleteUrl}" method="POST" onsubmit="return confirm('{{ _('Are you sure you want to delete this file?') }}');">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger shadow-sm" title="{{ _('Delete') }}"><i class="bi bi-trash3"></i></button>
|
||||
</form>`;
|
||||
actionsTd.innerHTML = actionsHtml;
|
||||
}
|
||||
|
||||
if (status === 'waiting' || status === 'uploaded' || status === 'slicing') {
|
||||
hasPending = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop polling if there are no more pending files in the user's scope
|
||||
if (!hasPending && pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching file statuses:', error));
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
if (needsPolling) {
|
||||
pollTimer = setInterval(fetchStatus, checkInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
22
app/templates/gcode_preview.html
Normal file
22
app/templates/gcode_preview.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% 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">GCode Preview: {{ file.original_filename }}</h1>
|
||||
<a href="{{ url_for('main.files') }}" class="btn btn-secondary btn-sm">Back to Files</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-info text-dark d-flex justify-content-between">
|
||||
<span>File Info</span>
|
||||
<span>Total Lines: {{ line_count }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text text-muted mb-1">Below is a text preview of the generated GCode (first 500 lines).</p>
|
||||
<pre class="bg-dark text-light p-3 rounded" style="max-height: 500px; overflow-y: auto; font-size: 13px;"><code>{{ content }}</code></pre>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-primary">Download Full GCode File</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
18
app/templates/index.html
Normal file
18
app/templates/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% 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">{{ _('Dashboard') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card text-white bg-primary mb-3 shadow-sm border-0">
|
||||
<div class="card-header border-0 fs-5 fw-medium"><i class="bi bi-bar-chart-fill me-2"></i>{{ _('Total Prints') }}</div>
|
||||
<div class="card-body mt-2">
|
||||
<h5 class="card-title">{{ _('You have sliced') }} <b class="fs-1 mx-2">{{ current_user.print_files|length }}</b> {{ _('files') }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
26
app/templates/login.html
Normal file
26
app/templates/login.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 mt-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Login</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" name="username" id="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" name="password" id="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
234
app/templates/slice.html
Normal file
234
app/templates/slice.html
Normal file
@@ -0,0 +1,234 @@
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user