Compare commits
2 Commits
a3f8a31432
...
1de35f21d7
| Author | SHA1 | Date | |
|---|---|---|---|
| 1de35f21d7 | |||
| dad17dbadd |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
__pycache__
|
||||
uploads/*
|
||||
tmp/*
|
||||
venv
|
||||
instance
|
||||
huey_queue.*
|
||||
@@ -75,8 +75,10 @@ def create_app():
|
||||
db.create_all()
|
||||
|
||||
from .routes import main_bp, auth_bp, admin_bp
|
||||
from .printer_routes import printer_bp
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(printer_bp)
|
||||
|
||||
return app
|
||||
|
||||
152
app/octoprint_client.py
Normal file
152
app/octoprint_client.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import requests
|
||||
from urllib.parse import urljoin
|
||||
|
||||
class OctoPrintClient:
|
||||
"""
|
||||
Client for interacting with the OctoPrint API using Application Keys or standard API Keys.
|
||||
Designed to be easily extensible.
|
||||
"""
|
||||
def __init__(self, base_url, api_key=None):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.session = requests.Session()
|
||||
if self.api_key:
|
||||
self.session.headers.update({"X-Api-Key": self.api_key})
|
||||
|
||||
def _request(self, method, endpoint, **kwargs):
|
||||
"""Internal method to handle API requests and standard parsing."""
|
||||
url = urljoin(self.base_url, endpoint)
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
|
||||
# Octoprint often returns 204 No Content for successful commands
|
||||
if response.status_code == 204:
|
||||
return True
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Application Key Workflow
|
||||
# -------------------------------------------------------------------------
|
||||
def request_app_key(self, app_name="My OctoPrint App"):
|
||||
"""
|
||||
Step 1: Start the Application Key authorization flow.
|
||||
Returns: (app_token, client_token)
|
||||
You should poll verify_app_key(client_token) until the user allows it in OctoPrint UI.
|
||||
"""
|
||||
data = self._request("POST", "/plugin/appkeys/request", json={"app": app_name})
|
||||
return data.get("app_token"), data.get("client_token")
|
||||
|
||||
def verify_app_key(self, client_token):
|
||||
"""
|
||||
Step 2: Check if the requested app key is approved.
|
||||
Returns True if authorized, False if still pending.
|
||||
Raises an exception if denied or timed out.
|
||||
"""
|
||||
url = urljoin(self.base_url, "/plugin/appkeys/probe")
|
||||
# App Key probe requires passing the client_token as a Bearer token
|
||||
# Don't use self._request since it uses the established session/api_key
|
||||
response = requests.get(url, headers={"Authorization": f"Bearer {client_token}"})
|
||||
|
||||
if response.status_code == 204:
|
||||
return True
|
||||
elif response.status_code == 202:
|
||||
return False # Pending approval
|
||||
else:
|
||||
raise Exception(f"App key request denied or expired (HTTP {response.status_code})")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Files
|
||||
# -------------------------------------------------------------------------
|
||||
def get_files(self, location="local"):
|
||||
"""
|
||||
Retrieve all files available on OctoPrint.
|
||||
location: 'local' (internal storage) or 'sdcard' (SD card on printer)
|
||||
"""
|
||||
return self._request("GET", f"/api/files/{location}")
|
||||
|
||||
def select_file(self, location, path, print_after_select=False):
|
||||
"""Select a file, and optionally start printing it immediately."""
|
||||
payload = {"command": "select", "print": print_after_select}
|
||||
return self._request("POST", f"/api/files/{location}/{path}", json=payload)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Printer Status
|
||||
# -------------------------------------------------------------------------
|
||||
def get_printer_status(self):
|
||||
"""
|
||||
Get the current printer state (e.g., temperatures, operational state).
|
||||
Note: If printer is disconnected, this may return an HTTP error.
|
||||
"""
|
||||
try:
|
||||
return self._request("GET", "/api/printer")
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 409:
|
||||
return {"state": {"text": "Offline/Disconnected"}}
|
||||
raise
|
||||
|
||||
def get_job_info(self):
|
||||
"""Get information about the current print job and progress."""
|
||||
return self._request("GET", "/api/job")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Printer Control
|
||||
# -------------------------------------------------------------------------
|
||||
def start_print(self):
|
||||
"""Start the print job (Requires a file to be selected first)."""
|
||||
return self._request("POST", "/api/job", json={"command": "start"})
|
||||
|
||||
def pause_print(self, action="pause"):
|
||||
"""
|
||||
Pause, resume, or toggle the print job.
|
||||
action: 'pause', 'resume', or 'toggle'
|
||||
"""
|
||||
return self._request("POST", "/api/job", json={"command": "pause", "action": action})
|
||||
|
||||
def cancel_print(self):
|
||||
"""Cancel the current print job."""
|
||||
return self._request("POST", "/api/job", json={"command": "cancel"})
|
||||
|
||||
def send_gcode(self, commands):
|
||||
"""
|
||||
Send a string or list of G-Code commands directly to the printer.
|
||||
Example: send_gcode("G28") or send_gcode(["G28", "G29"])
|
||||
"""
|
||||
if isinstance(commands, str):
|
||||
commands = [commands]
|
||||
return self._request("POST", "/api/printer/command", json={"commands": commands})
|
||||
|
||||
def home_axes(self, axes=["x", "y", "z"]):
|
||||
"""Convenience method to home the printer axes."""
|
||||
return self._request("POST", "/api/printer/printhead", json={"command": "home", "axes": axes})
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Webcam / Video
|
||||
# -------------------------------------------------------------------------
|
||||
def get_webcam_stream_url(self):
|
||||
"""
|
||||
Attempts to fetch the configured webcam stream URL from OctoPrint settings.
|
||||
Provides a fallback if settings are inaccessible.
|
||||
"""
|
||||
try:
|
||||
settings = self._request("GET", "/api/settings")
|
||||
stream_url = settings.get("webcam", {}).get("streamUrl", "/webcam/?action=stream")
|
||||
if stream_url.startswith("/"):
|
||||
return urljoin(self.base_url, stream_url)
|
||||
return stream_url
|
||||
except requests.HTTPError:
|
||||
# Fallback standard URL
|
||||
return urljoin(self.base_url, "/webcam/?action=stream")
|
||||
|
||||
# --- Example Usage / Extensibility test ---
|
||||
if __name__ == "__main__":
|
||||
# Example snippet of how to use the client:
|
||||
OCTOPRINT_URL = "http://octopi.local"
|
||||
# OCTOPRINT_KEY = "YOUR_APP_KEY_HERE"
|
||||
|
||||
# client = OctoPrintClient(OCTOPRINT_URL, OCTOPRINT_KEY)
|
||||
# print(client.get_printer_status())
|
||||
pass
|
||||
193
app/printer_routes.py
Normal file
193
app/printer_routes.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app, Response
|
||||
from flask_login import login_required, current_user
|
||||
import requests
|
||||
from .models import SystemConfig, db
|
||||
from .octoprint_client import OctoPrintClient
|
||||
|
||||
printer_bp = Blueprint('printer', __name__, url_prefix='/printer')
|
||||
|
||||
def get_octo_client():
|
||||
url = SystemConfig.query.filter_by(key='octoprint_url').first()
|
||||
apikey = SystemConfig.query.filter_by(key='octoprint_apikey').first()
|
||||
if url and url.value and apikey and apikey.value:
|
||||
return OctoPrintClient(url.value, apikey.value)
|
||||
return None
|
||||
|
||||
@printer_bp.route('/status')
|
||||
@login_required
|
||||
def status():
|
||||
client = get_octo_client()
|
||||
status_data = None
|
||||
job_data = None
|
||||
error = None
|
||||
if client:
|
||||
try:
|
||||
status_data = client.get_printer_status()
|
||||
job_data = client.get_job_info()
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
else:
|
||||
error = "OctoPrint is not configured."
|
||||
|
||||
return render_template('printer/status.html', status=status_data, job=job_data, error=error)
|
||||
|
||||
@printer_bp.route('/prepare')
|
||||
@login_required
|
||||
def prepare():
|
||||
client = get_octo_client()
|
||||
files = []
|
||||
error = None
|
||||
if client:
|
||||
try:
|
||||
res = client.get_files()
|
||||
files = res.get('files', [])
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
else:
|
||||
error = "OctoPrint is not configured."
|
||||
return render_template('printer/prepare.html', files=files, error=error)
|
||||
|
||||
@printer_bp.route('/api/print_file', methods=['POST'])
|
||||
@login_required
|
||||
def api_print_file():
|
||||
path = request.json.get('path')
|
||||
location = request.json.get('origin', 'local')
|
||||
client = get_octo_client()
|
||||
if client and path:
|
||||
try:
|
||||
client.select_file(location, path, print_after_select=True)
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
return jsonify({"success": False, "error": "Not configured or missing path"})
|
||||
|
||||
@printer_bp.route('/control')
|
||||
@login_required
|
||||
def control():
|
||||
client = get_octo_client()
|
||||
webcam_url = None
|
||||
error = None
|
||||
if client:
|
||||
try:
|
||||
webcam_url = client.get_webcam_stream_url()
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
else:
|
||||
error = "OctoPrint is not configured."
|
||||
return render_template('printer/control.html', webcam_url=webcam_url, error=error)
|
||||
|
||||
@printer_bp.route('/api/command', methods=['POST'])
|
||||
@login_required
|
||||
def api_command():
|
||||
cmd = request.json.get('command')
|
||||
client = get_octo_client()
|
||||
if client and cmd:
|
||||
try:
|
||||
if cmd == 'home':
|
||||
client.home_axes()
|
||||
elif cmd == 'pause':
|
||||
client.pause_print()
|
||||
elif cmd == 'cancel':
|
||||
client.cancel_print()
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
return jsonify({"success": False, "error": "Invalid client or command"})
|
||||
|
||||
@printer_bp.route('/octo_config', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def octo_config():
|
||||
if not current_user.is_admin:
|
||||
flash("Admin access required", "danger")
|
||||
return redirect(url_for('printer.status'))
|
||||
|
||||
if request.method == 'POST':
|
||||
url = request.form.get('octoprint_url', '')
|
||||
apikey = request.form.get('octoprint_apikey', '')
|
||||
|
||||
conf_url = SystemConfig.query.filter_by(key='octoprint_url').first()
|
||||
if not conf_url:
|
||||
conf_url = SystemConfig(key='octoprint_url')
|
||||
db.session.add(conf_url)
|
||||
conf_url.value = url.rstrip('/')
|
||||
|
||||
conf_key = SystemConfig.query.filter_by(key='octoprint_apikey').first()
|
||||
if not conf_key:
|
||||
conf_key = SystemConfig(key='octoprint_apikey')
|
||||
db.session.add(conf_key)
|
||||
conf_key.value = apikey
|
||||
|
||||
db.session.commit()
|
||||
flash("OctoPrint settings updated", "success")
|
||||
return redirect(url_for('printer.octo_config'))
|
||||
|
||||
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||
return render_template('printer/octo_config.html', configs=configs)
|
||||
|
||||
@printer_bp.route('/octo_embed')
|
||||
@login_required
|
||||
def octo_embed():
|
||||
if not current_user.is_admin:
|
||||
flash("Admin access required", "danger")
|
||||
return redirect(url_for('printer.status'))
|
||||
|
||||
url = SystemConfig.query.filter_by(key='octoprint_url').first()
|
||||
embed_url = url_for('printer.octo_proxy') if url and url.value else None
|
||||
return render_template('printer/octo_embed.html', embed_url=embed_url)
|
||||
|
||||
@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
|
||||
@printer_bp.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
|
||||
@login_required
|
||||
def octo_proxy(path):
|
||||
if not current_user.is_admin:
|
||||
return "Unauthorized", 403
|
||||
|
||||
url_config = SystemConfig.query.filter_by(key='octoprint_url').first()
|
||||
if not url_config or not url_config.value:
|
||||
return "OctoPrint URL not configured", 404
|
||||
|
||||
from urllib.parse import urlparse
|
||||
base_url = url_config.value.rstrip('/')
|
||||
target_url = f"{base_url}/{path}"
|
||||
|
||||
if request.query_string:
|
||||
target_url = f"{target_url}?{request.query_string.decode('utf-8')}"
|
||||
|
||||
# Build headers for reverse proxy, masking origin/referer to avoid CSRF
|
||||
parsed_base = urlparse(base_url)
|
||||
headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length']}
|
||||
headers['Host'] = parsed_base.netloc
|
||||
|
||||
if 'Origin' in headers:
|
||||
headers['Origin'] = base_url
|
||||
if 'Referer' in headers:
|
||||
headers['Referer'] = f"{base_url}/{path}"
|
||||
|
||||
headers['X-Forwarded-For'] = request.remote_addr
|
||||
headers['X-Forwarded-Host'] = request.host
|
||||
headers['X-Forwarded-Proto'] = request.scheme
|
||||
headers['X-Script-Name'] = '/printer/proxy'
|
||||
|
||||
try:
|
||||
resp = requests.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
data=request.get_data(),
|
||||
cookies=request.cookies,
|
||||
allow_redirects=False,
|
||||
stream=True
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return f"Proxy connection error: {str(e)}", 502
|
||||
|
||||
# Strip headers that might break the iframe or framing
|
||||
excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection', 'x-frame-options', 'content-security-policy']
|
||||
response_headers = [(name, value) for (name, value) in resp.headers.items() if name.lower() not in excluded_headers]
|
||||
|
||||
def generate():
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
return Response(generate(), resp.status_code, response_headers)
|
||||
105
app/routes.py
105
app/routes.py
@@ -9,7 +9,7 @@ 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 .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
|
||||
@@ -118,11 +118,14 @@ def files():
|
||||
original_filename=original_filename,
|
||||
file_type='stl',
|
||||
user_id=current_user.id,
|
||||
status='uploaded' # Only display as uploaded, no automatic slicing
|
||||
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'))
|
||||
|
||||
@@ -241,6 +244,7 @@ def settings():
|
||||
# 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')
|
||||
@@ -250,6 +254,7 @@ def settings():
|
||||
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),
|
||||
@@ -368,11 +373,6 @@ def serve_proxy_file(file_id):
|
||||
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)
|
||||
@@ -406,24 +406,77 @@ def merge_and_slice():
|
||||
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:
|
||||
f.transform_matrix = json.dumps(p['raw_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()
|
||||
|
||||
if len(inputs) == 0:
|
||||
return jsonify({'error': 'Invalid files'}), 400
|
||||
target_file_id = data.get('target_file_id')
|
||||
|
||||
is_edit = data.get('is_edit', False)
|
||||
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 len(inputs) == 1 and is_edit:
|
||||
# User is just generating gcode for a single original model, do NOT pollute list with new STL
|
||||
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:
|
||||
@@ -442,12 +495,34 @@ def merge_and_slice():
|
||||
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'
|
||||
status='merging',
|
||||
transform_matrix=json.dumps(composite_data)
|
||||
)
|
||||
db.session.add(print_file)
|
||||
db.session.commit()
|
||||
|
||||
50
app/tasks.py
50
app/tasks.py
@@ -238,3 +238,53 @@ def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None,
|
||||
app.logger.error(f"Merge Exception: {e}")
|
||||
finally:
|
||||
db.session.remove()
|
||||
|
||||
@huey.task()
|
||||
def simplify_stl_task(file_id, filepath):
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
from .models import PrintFile, SystemConfig, db
|
||||
import os
|
||||
from stl_simplifier import simplify_stl
|
||||
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if not print_file:
|
||||
return
|
||||
|
||||
try:
|
||||
file_size_mb = os.path.getsize(filepath) / (1024 * 1024)
|
||||
|
||||
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||
skip_size = float(configs.get('proxy_skip_size_mb', '5.0'))
|
||||
|
||||
proxy_path = filepath + '.proxy.stl'
|
||||
|
||||
if file_size_mb <= skip_size:
|
||||
# File is small enough, no proxy needed
|
||||
print_file.status = 'uploaded'
|
||||
db.session.commit()
|
||||
return
|
||||
|
||||
# Aim for approx 7.5 MB for the proxy
|
||||
target_mb = 7.5
|
||||
keep_ratio = target_mb / file_size_mb
|
||||
if keep_ratio > 1.0:
|
||||
keep_ratio = 1.0
|
||||
elif keep_ratio < 0.01:
|
||||
keep_ratio = 0.01
|
||||
|
||||
app.logger.info(f"Simplifying {filepath}... Size: {file_size_mb:.2f}MB, Target Ratio: {keep_ratio:.3f}")
|
||||
|
||||
simplify_stl(filepath, proxy_path, keep_ratio=keep_ratio)
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Simplify task error: {e}")
|
||||
|
||||
# Update status to uploaded regardless of success or failure of proxy generation
|
||||
# So the user can still slice or download it
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if print_file:
|
||||
print_file.status = 'uploaded'
|
||||
db.session.commit()
|
||||
db.session.remove()
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
<div class="form-text">{{ _('Adjust the Y-axis compilation offset for combined files on the build plate.') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="proxy_skip_size_mb" class="form-label">{{ _('Proxy Skip Size (MB)') }}</label>
|
||||
<input type="number" class="form-control" name="proxy_skip_size_mb" id="proxy_skip_size_mb" value="{{ configs.get('proxy_skip_size_mb', '5.0') }}" step="0.1" min="0">
|
||||
<div class="form-text">{{ _('Files smaller than this will not generate a simplified proxy.') }}</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4">{{ _('Default Plater Settings') }}</h5>
|
||||
<hr>
|
||||
|
||||
|
||||
@@ -27,9 +27,17 @@
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top shadow-sm">
|
||||
<div class="container-fluid">
|
||||
<div class="container-fluid position-relative d-flex justify-content-between align-items-center">
|
||||
<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="d-none d-md-flex mx-auto" style="position: absolute; left: 50%; transform: translateX(-50%);">
|
||||
<div class="btn-group border border-secondary shadow-sm rounded-pill p-1 bg-dark" role="group" style="background-color: #1a1e21 !important;">
|
||||
<a href="{{ url_for('main.files') }}" class="btn btn-sm rounded-pill {% if not request.blueprint == 'printer' %}btn-primary active fw-bold text-white px-4{% else %}btn-transparent text-secondary border-0 px-3{% endif %}" style="transition: all 0.2s;"><i class="bi bi-layers me-1"></i>{{ _('Slicer') }}</a>
|
||||
<a href="{{ url_for('printer.status') }}" class="btn btn-sm rounded-pill {% if request.blueprint == 'printer' %}btn-primary active fw-bold text-white px-4{% else %}btn-transparent text-secondary border-0 px-3{% endif %}" style="transition: all 0.2s;"><i class="bi bi-printer-fill me-1"></i>{{ _('Printer') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex text-light align-items-center ms-auto">
|
||||
<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') }}
|
||||
@@ -57,6 +65,48 @@
|
||||
<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">
|
||||
|
||||
{% if request.blueprint == 'printer' %}
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mb-2 text-muted fw-bold text-uppercase" style="font-size: 0.75rem;">
|
||||
<span><i class="bi bi-list-task me-1"></i>{{ _('General Operations') }}</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column nav-pills gap-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'printer.status' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.status') }}">
|
||||
<i class="bi bi-activity me-2"></i>{{ _('Printer Status') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'printer.prepare' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.prepare') }}">
|
||||
<i class="bi bi-file-earmark-plus me-2"></i>{{ _('Prepare Print') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mb-1">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'printer.control' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.control') }}">
|
||||
<i class="bi bi-arrows-move me-2"></i>{{ _('Control') }}
|
||||
</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 / OctoPrint') }}</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 == 'printer.octo_config' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.octo_config') }}">
|
||||
<i class="bi bi-plug me-2"></i>{{ _('System Config') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'printer.octo_embed' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.octo_embed') }}">
|
||||
<i class="bi bi-window-sidebar me-2"></i>{{ _('OctoPrint Panel') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<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') }}">
|
||||
@@ -92,6 +142,8 @@
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -40,11 +40,16 @@
|
||||
<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="ps-4 text-muted">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
<span class="local-time" data-utc="{{ file.created_at.isoformat() }}">{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||
</td>
|
||||
<td class="fw-medium">{{ file.original_filename }}</td>
|
||||
<td id="status-{{ file.id }}">
|
||||
{% if file.status == 'waiting' %}
|
||||
<span class="badge bg-info text-dark rounded-pill fw-normal px-2" title="{{ _('Waiting in queue for slicing') }}"><i class="bi bi-hourglass-split me-1"></i>{{ _('Waiting') }}...</span>
|
||||
{% elif file.status == 'simplifying' %}
|
||||
<span class="badge bg-secondary text-dark rounded-pill fw-normal px-2" style="background-color: #e2e3e5 !important;"><i class="bi bi-funnel bi-spin me-1"></i>{{ _('Simplifying') }}...</span>
|
||||
{% elif file.status == 'uploaded' %}
|
||||
<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _('Uploaded') }}</span>
|
||||
{% elif file.status == 'merging' %}
|
||||
@@ -86,6 +91,25 @@
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Convert UTC to local time
|
||||
document.querySelectorAll('.local-time').forEach(el => {
|
||||
let utcStr = el.getAttribute('data-utc');
|
||||
if (!utcStr) return;
|
||||
if (!utcStr.endsWith('Z') && !utcStr.includes('+')) {
|
||||
utcStr += 'Z';
|
||||
}
|
||||
const localDate = new Date(utcStr);
|
||||
if (!isNaN(localDate)) {
|
||||
const year = localDate.getFullYear();
|
||||
const month = String(localDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(localDate.getDate()).padStart(2, '0');
|
||||
const hours = String(localDate.getHours()).padStart(2, '0');
|
||||
const minutes = String(localDate.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(localDate.getSeconds()).padStart(2, '0');
|
||||
el.textContent = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
});
|
||||
|
||||
const checkInterval = 1000;
|
||||
let pollTimer = null;
|
||||
|
||||
@@ -105,6 +129,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const actionsTd = document.getElementById('actions-container-' + id);
|
||||
|
||||
if (status === 'waiting') statusTd.innerHTML = '<span class="badge bg-info text-dark rounded-pill fw-normal px-2"><i class="bi bi-hourglass-split me-1"></i>{{ _("Waiting") }}...</span>';
|
||||
else if (status === 'simplifying') statusTd.innerHTML = '<span class="badge bg-secondary text-dark rounded-pill fw-normal px-2" style="background-color: #e2e3e5 !important;"><i class="bi bi-funnel bi-spin me-1"></i>{{ _("Simplifying") }}...</span>';
|
||||
else if (status === 'uploaded') statusTd.innerHTML = '<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _("Uploaded") }}</span>';
|
||||
else if (status === 'merging') statusTd.innerHTML = '<span class="badge bg-primary text-light rounded-pill fw-normal px-2"><i class="bi bi-intersect me-1"></i>{{ _("Merging") }}...</span>';
|
||||
else if (status === 'slicing') statusTd.innerHTML = '<span class="badge bg-warning text-dark rounded-pill fw-normal px-2"><i class="bi bi-gear-wide-connected bi-spin me-1"></i>{{ _("Slicing") }}...</span>';
|
||||
@@ -128,7 +153,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
actionsTd.innerHTML = actionsHtml;
|
||||
}
|
||||
|
||||
if (status === 'waiting' || status === 'slicing' || status === 'merging') {
|
||||
if (status === 'waiting' || status === 'slicing' || status === 'merging' || status === 'simplifying') {
|
||||
hasPending = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="mb-1 legend-item user-select-none" data-type="SUPPORT" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #57b357;"></span>{{ _('Support') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="SKIRT" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #00ffff;"></span>{{ _('Skirt') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="SUPPORT-INTERFACE" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #2b6b2b;"></span>{{ _('Support Interface') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="TRAVEL" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #405060;"></span>{{ _('Travel (Move)') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="TRAVEL" style="cursor: pointer; transition: opacity 0.2s; opacity: 0.4;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #405060;"></span>{{ _('Travel (Move)') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Slider (Intra-Layer Progress) -->
|
||||
@@ -100,7 +100,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
uShowSupport: { value: 1.0 },
|
||||
uShowSkirt: { value: 1.0 },
|
||||
uShowSupportInterface: { value: 1.0 },
|
||||
uShowTravel: { value: 1.0 },
|
||||
uShowTravel: { value: 0.0 },
|
||||
uShowDefault: { value: 1.0 }
|
||||
},
|
||||
vertexShader: `
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<!-- 3D Area -->
|
||||
<div class="col-md-9 h-100 position-relative">
|
||||
<div id="plater-container" class="w-100 h-100 rounded shadow-sm border border-secondary" style="overflow: hidden; background: #f8f9fa;"></div>
|
||||
<div id="plater-container" class="w-100 h-100 rounded shadow-sm border border-secondary" style="overflow: hidden; background: #f8f9fa; position: relative;"></div>
|
||||
|
||||
<!-- Parameterized Scale Input Box -->
|
||||
<div id="scale-panel" class="position-absolute top-50 start-0 translate-middle-y ms-5 ps-4 d-none" style="z-index: 10; pointer-events: none;">
|
||||
@@ -174,6 +174,13 @@ let offsetX = {{ offset_x|default(0) }};
|
||||
let offsetY = {{ offset_y|default(0) }};
|
||||
let loadedModels = [];
|
||||
let activeModel = null;
|
||||
let selectedModels = [];
|
||||
let selectionBoxDiv = document.createElement('div');
|
||||
selectionBoxDiv.id = 'selection-box';
|
||||
selectionBoxDiv.style.cssText = 'position: absolute; border: 1px dashed #007bff; background: rgba(0, 123, 255, 0.1); pointer-events: none; display: none; z-index: 100;';
|
||||
document.getElementById('plater-container').appendChild(selectionBoxDiv);
|
||||
let dragStartPoint = null;
|
||||
let isDraggingBox = false;
|
||||
const initialAddId = new URLSearchParams(window.location.search).get('add');
|
||||
|
||||
initPlater();
|
||||
@@ -271,12 +278,24 @@ function initPlater() {
|
||||
});
|
||||
transformControl.addEventListener('dragging-changed', function (event) {
|
||||
orbit.enabled = !event.value;
|
||||
if (!event.value && activeModel) {
|
||||
scene.attach(activeModel);
|
||||
transformProxy.position.copy(activeModel.getWorldPosition(new THREE.Vector3()));
|
||||
if (!event.value && selectedModels.length > 0) {
|
||||
let center = new THREE.Vector3();
|
||||
selectedModels.forEach(m => {
|
||||
scene.attach(m);
|
||||
m.geometry.computeBoundingBox();
|
||||
let box = m.geometry.boundingBox.clone();
|
||||
box.applyMatrix4(m.matrixWorld);
|
||||
center.add(box.getCenter(new THREE.Vector3()));
|
||||
});
|
||||
center.divideScalar(selectedModels.length);
|
||||
|
||||
transformProxy.position.copy(center);
|
||||
transformProxy.rotation.set(0, 0, 0);
|
||||
transformProxy.scale.set(1, 1, 1);
|
||||
transformProxy.attach(activeModel);
|
||||
|
||||
selectedModels.forEach(m => {
|
||||
transformProxy.attach(m);
|
||||
});
|
||||
}
|
||||
});
|
||||
scene.add(transformControl);
|
||||
@@ -306,7 +325,7 @@ function setTransformMode(mode) {
|
||||
document.getElementById('btn-scale').className = mode === 'scale' ? 'btn btn-primary btn-sm rounded' : 'btn btn-outline-secondary btn-sm rounded';
|
||||
document.getElementById('btn-layflat').className = 'btn btn-outline-info btn-sm rounded';
|
||||
|
||||
if (mode === 'scale' && activeModel) {
|
||||
if (mode === 'scale' && selectedModels && selectedModels.length > 0) {
|
||||
document.getElementById('scale-panel').classList.remove('d-none');
|
||||
updateScalePanel();
|
||||
} else {
|
||||
@@ -324,47 +343,56 @@ function setTransformMode(mode) {
|
||||
}
|
||||
|
||||
function updateScalePanel() {
|
||||
if (!activeModel) return;
|
||||
const v = activeModel.getWorldScale(new THREE.Vector3());
|
||||
document.getElementById('scale-x').value = v.x.toFixed(3);
|
||||
document.getElementById('scale-y').value = v.y.toFixed(3);
|
||||
document.getElementById('scale-z').value = v.z.toFixed(3);
|
||||
if (selectedModels.length === 0) return;
|
||||
|
||||
// Check if scales match
|
||||
let firstScale = selectedModels[0].getWorldScale(new THREE.Vector3());
|
||||
let allXMatch = true, allYMatch = true, allZMatch = true;
|
||||
|
||||
for (let i = 1; i < selectedModels.length; i++) {
|
||||
let v = selectedModels[i].getWorldScale(new THREE.Vector3());
|
||||
if (Math.abs(v.x - firstScale.x) > 0.001) allXMatch = false;
|
||||
if (Math.abs(v.y - firstScale.y) > 0.001) allYMatch = false;
|
||||
if (Math.abs(v.z - firstScale.z) > 0.001) allZMatch = false;
|
||||
}
|
||||
|
||||
document.getElementById('scale-x').value = allXMatch ? firstScale.x.toFixed(3) : '';
|
||||
document.getElementById('scale-y').value = allYMatch ? firstScale.y.toFixed(3) : '';
|
||||
document.getElementById('scale-z').value = allZMatch ? firstScale.z.toFixed(3) : '';
|
||||
}
|
||||
|
||||
function applyScaleInput(axis) {
|
||||
if (!activeModel) return;
|
||||
let val = parseFloat(document.getElementById('scale-' + axis).value);
|
||||
if (selectedModels.length === 0) return;
|
||||
let valStr = document.getElementById('scale-' + axis).value;
|
||||
if (valStr === '') return;
|
||||
let val = parseFloat(valStr);
|
||||
if (isNaN(val) || val <= 0.001) val = 1.0;
|
||||
|
||||
scene.attach(activeModel); // temporarily detach to operate purely on local=world scale
|
||||
|
||||
const isUniform = document.getElementById('scale-uniform').checked;
|
||||
|
||||
selectedModels.forEach(m => {
|
||||
scene.attach(m);
|
||||
if (isUniform) {
|
||||
// Find previous scale
|
||||
const prev = activeModel.scale[axis];
|
||||
const prev = m.scale[axis];
|
||||
const ratio = val / prev;
|
||||
activeModel.scale.x *= ratio;
|
||||
activeModel.scale.y *= ratio;
|
||||
activeModel.scale.z *= ratio;
|
||||
m.scale.x *= ratio;
|
||||
m.scale.y *= ratio;
|
||||
m.scale.z *= ratio;
|
||||
} else {
|
||||
activeModel.scale[axis] = val;
|
||||
m.scale[axis] = val;
|
||||
}
|
||||
m.updateMatrixWorld(true);
|
||||
});
|
||||
|
||||
activeModel.updateMatrixWorld(true);
|
||||
|
||||
// re-attach proxy pivot logic without modifying the actual spatial scale
|
||||
transformProxy.scale.set(1, 1, 1);
|
||||
transformProxy.attach(activeModel);
|
||||
selectedModels.forEach(m => {
|
||||
transformProxy.attach(m);
|
||||
});
|
||||
|
||||
updateScalePanel();
|
||||
}
|
||||
|
||||
function removeActiveModel() {
|
||||
if (activeModel) {
|
||||
removeModel(activeModel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function onKeyDown(event) {
|
||||
switch (event.key.toLowerCase()) {
|
||||
@@ -386,6 +414,15 @@ function onPointerDown(event) {
|
||||
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
dragStartPoint = { x: event.clientX, y: event.clientY };
|
||||
isDraggingBox = false;
|
||||
orbit.enabled = false;
|
||||
document.addEventListener('pointermove', onPointerMoveBox);
|
||||
document.addEventListener('pointerup', onPointerUpBox);
|
||||
}
|
||||
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const intersects = raycaster.intersectObjects(loadedModels, true);
|
||||
@@ -424,30 +461,146 @@ function onPointerDown(event) {
|
||||
|
||||
// Exit lay flat mode and reset to translate
|
||||
setTransformMode('translate');
|
||||
selectModel(obj);
|
||||
selectModels([obj]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (intersects.length > 0) {
|
||||
selectModel(intersects[0].object);
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
toggleModelSelection(intersects[0].object);
|
||||
} else {
|
||||
selectModel(null);
|
||||
selectModels([intersects[0].object]);
|
||||
}
|
||||
} else {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
selectModels([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectModel(model) {
|
||||
if (activeModel && activeModel !== model) {
|
||||
scene.attach(activeModel);
|
||||
function onPointerMoveBox(event) {
|
||||
if (!dragStartPoint) return;
|
||||
const dx = Math.abs(event.clientX - dragStartPoint.x);
|
||||
const dy = Math.abs(event.clientY - dragStartPoint.y);
|
||||
if (dx > 5 || dy > 5) {
|
||||
isDraggingBox = true;
|
||||
|
||||
const container = document.getElementById('plater-container');
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
const startX = dragStartPoint.x - rect.left;
|
||||
const startY = dragStartPoint.y - rect.top;
|
||||
const currentX = event.clientX - rect.left;
|
||||
const currentY = event.clientY - rect.top;
|
||||
|
||||
selectionBoxDiv.style.display = 'block';
|
||||
selectionBoxDiv.style.left = Math.min(currentX, startX) + 'px';
|
||||
selectionBoxDiv.style.top = Math.min(currentY, startY) + 'px';
|
||||
selectionBoxDiv.style.width = Math.abs(currentX - startX) + 'px';
|
||||
selectionBoxDiv.style.height = Math.abs(currentY - startY) + 'px';
|
||||
}
|
||||
activeModel = model;
|
||||
if (model) {
|
||||
scene.attach(model);
|
||||
transformProxy.position.copy(model.getWorldPosition(new THREE.Vector3()));
|
||||
}
|
||||
|
||||
function onPointerUpBox(event) {
|
||||
document.removeEventListener('pointermove', onPointerMoveBox);
|
||||
document.removeEventListener('pointerup', onPointerUpBox);
|
||||
orbit.enabled = true;
|
||||
|
||||
if (isDraggingBox) {
|
||||
selectionBoxDiv.style.display = 'none';
|
||||
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const minX = Math.min(dragStartPoint.x, event.clientX) - rect.left;
|
||||
const maxX = Math.max(dragStartPoint.x, event.clientX) - rect.left;
|
||||
const minY = Math.min(dragStartPoint.y, event.clientY) - rect.top;
|
||||
const maxY = Math.max(dragStartPoint.y, event.clientY) - rect.top;
|
||||
|
||||
let newSelection = [...selectedModels];
|
||||
|
||||
loadedModels.forEach(m => {
|
||||
m.geometry.computeBoundingBox();
|
||||
let box = m.geometry.boundingBox.clone();
|
||||
box.applyMatrix4(m.matrixWorld);
|
||||
|
||||
// Project 8 corners
|
||||
const corners = [
|
||||
new THREE.Vector3(box.min.x, box.min.y, box.min.z),
|
||||
new THREE.Vector3(box.max.x, box.min.y, box.min.z),
|
||||
new THREE.Vector3(box.min.x, box.max.y, box.min.z),
|
||||
new THREE.Vector3(box.max.x, box.max.y, box.min.z),
|
||||
new THREE.Vector3(box.min.x, box.min.y, box.max.z),
|
||||
new THREE.Vector3(box.max.x, box.min.y, box.max.z),
|
||||
new THREE.Vector3(box.min.x, box.max.y, box.max.z),
|
||||
new THREE.Vector3(box.max.x, box.max.y, box.max.z)
|
||||
];
|
||||
|
||||
let inside = false;
|
||||
corners.forEach(v => {
|
||||
v.project(camera);
|
||||
let sx = (v.x * .5 + .5) * rect.width;
|
||||
let sy = (v.y * -.5 + .5) * rect.height;
|
||||
if (sx >= minX && sx <= maxX && sy >= minY && sy <= maxY) {
|
||||
inside = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (inside && !newSelection.includes(m)) {
|
||||
newSelection.push(m);
|
||||
}
|
||||
});
|
||||
selectModels(newSelection);
|
||||
} else if (dragStartPoint && !isDraggingBox) {
|
||||
// Just a ctrl+click that missed logic is handled by raycaster above, but we have to ensure no double toggle
|
||||
}
|
||||
dragStartPoint = null;
|
||||
isDraggingBox = false;
|
||||
}
|
||||
|
||||
|
||||
function toggleModelSelection(model) {
|
||||
let newSel = [...selectedModels];
|
||||
if (newSel.includes(model)) {
|
||||
newSel = newSel.filter(m => m !== model);
|
||||
} else {
|
||||
newSel.push(model);
|
||||
}
|
||||
selectModels(newSel);
|
||||
}
|
||||
|
||||
function selectModels(models) {
|
||||
selectedModels.forEach(m => {
|
||||
scene.attach(m);
|
||||
m.material.color.setHex(0xcccccc);
|
||||
});
|
||||
|
||||
selectedModels = models;
|
||||
activeModel = selectedModels.length > 0 ? selectedModels[selectedModels.length - 1] : null;
|
||||
|
||||
if (selectedModels.length > 0) {
|
||||
// compute joint center
|
||||
let center = new THREE.Vector3();
|
||||
let count = 0;
|
||||
selectedModels.forEach(m => {
|
||||
m.material.color.setHex(0x0d6efd);
|
||||
scene.attach(m); // ensure in world
|
||||
m.geometry.computeBoundingBox();
|
||||
let box = m.geometry.boundingBox.clone();
|
||||
box.applyMatrix4(m.matrixWorld);
|
||||
center.add(box.getCenter(new THREE.Vector3()));
|
||||
count++;
|
||||
});
|
||||
center.divideScalar(count);
|
||||
|
||||
transformProxy.position.copy(center);
|
||||
transformProxy.rotation.set(0, 0, 0);
|
||||
transformProxy.scale.set(1, 1, 1);
|
||||
transformProxy.attach(model);
|
||||
|
||||
selectedModels.forEach(m => {
|
||||
transformProxy.attach(m);
|
||||
});
|
||||
|
||||
transformControl.attach(transformProxy);
|
||||
if(transformControl.getMode() === 'scale') {
|
||||
document.getElementById('scale-panel').classList.remove('d-none');
|
||||
@@ -459,14 +612,32 @@ function selectModel(model) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeModel(model) {
|
||||
if (activeModel === model) {
|
||||
transformControl.detach();
|
||||
scene.attach(model);
|
||||
activeModel = null;
|
||||
// Keep a backward compatible selectModel definition for single cases
|
||||
function selectModel(model) {
|
||||
if (model) {
|
||||
selectModels([model]);
|
||||
} else {
|
||||
selectModels([]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function removeModel(model) {
|
||||
if (selectedModels.includes(model)) {
|
||||
selectedModels = selectedModels.filter(m => m !== model);
|
||||
if (selectedModels.length === 0) transformControl.detach();
|
||||
}
|
||||
scene.attach(model);
|
||||
scene.remove(model);
|
||||
loadedModels = loadedModels.filter(m => m !== model);
|
||||
activeModel = selectedModels.length > 0 ? selectedModels[0] : null;
|
||||
}
|
||||
|
||||
function removeActiveModel() {
|
||||
if (selectedModels.length > 0) {
|
||||
[...selectedModels].forEach(m => removeModel(m));
|
||||
selectModels([]);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPlate() {
|
||||
@@ -476,24 +647,86 @@ function clearPlate() {
|
||||
scene.remove(m);
|
||||
});
|
||||
loadedModels = [];
|
||||
selectedModels = [];
|
||||
activeModel = null;
|
||||
}
|
||||
|
||||
function addModelToPlate(btnElement, fileId, url, name, status) {
|
||||
let matrixData = btnElement ? btnElement.getAttribute('data-matrix') : null;
|
||||
|
||||
if (matrixData) {
|
||||
try {
|
||||
let data = JSON.parse(matrixData);
|
||||
if (data.settings) {
|
||||
if (data.settings.infill) document.getElementById('infill-density').value = data.settings.infill;
|
||||
if (data.settings.support) {
|
||||
let supportSelect = document.getElementById('support-type');
|
||||
supportSelect.value = data.settings.support;
|
||||
supportSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
if (data.settings.support_pattern) document.getElementById('support-pattern').value = data.settings.support_pattern;
|
||||
if (data.settings.quality) document.getElementById('quality').value = data.settings.quality;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (matrixData && matrixData.includes('"is_composite"')) {
|
||||
try {
|
||||
let comp = JSON.parse(matrixData);
|
||||
if (comp.is_composite && comp.parts) {
|
||||
if (btnElement) {
|
||||
const iconSpan = btnElement.querySelector('i');
|
||||
const originalClass = iconSpan.className;
|
||||
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
|
||||
btnElement.disabled = true;
|
||||
|
||||
let totalParts = comp.parts.length;
|
||||
let loadedCount = 0;
|
||||
|
||||
comp.parts.forEach(part => {
|
||||
loadSTL(part.file_id, part.url, part.name, 'uploaded', JSON.stringify(part.raw_matrix), () => {
|
||||
loadedCount++;
|
||||
if (loadedCount === totalParts) {
|
||||
iconSpan.className = originalClass;
|
||||
btnElement.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
comp.parts.forEach(part => {
|
||||
loadSTL(part.file_id, part.url, part.name, 'uploaded', JSON.stringify(part.raw_matrix));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (btnElement) {
|
||||
const iconSpan = btnElement.querySelector('i');
|
||||
const originalClass = iconSpan.className;
|
||||
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
|
||||
btnElement.disabled = true;
|
||||
loadSTL(fileId, url, name, status, matrixData, () => {
|
||||
iconSpan.className = originalClass;
|
||||
btnElement.disabled = false;
|
||||
});
|
||||
} else {
|
||||
loadSTL(fileId, url, name, status, matrixData);
|
||||
}
|
||||
}
|
||||
|
||||
function loadSTL(fileId, url, name, status, matrixData, callback) {
|
||||
const loader = new THREE.STLLoader();
|
||||
loader.load(url, function (geometry) {
|
||||
// By default STLs center or are offset, let's normalize slightly to be on top of the plate
|
||||
geometry.computeBoundingBox();
|
||||
const center = geometry.boundingBox.getCenter(new THREE.Vector3());
|
||||
const minZ = geometry.boundingBox.min.z;
|
||||
geometry.translate(-center.x, -center.y, -minZ);
|
||||
|
||||
const material = new THREE.MeshPhongMaterial({ color: 0x0d6efd, specular: 0x111111, shininess: 200 });
|
||||
const material = new THREE.MeshPhongMaterial({ color: 0xcccccc, specular: 0x111111, shininess: 200 });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.userData = {
|
||||
fileId: fileId,
|
||||
@@ -502,10 +735,15 @@ function addModelToPlate(btnElement, fileId, url, name, status) {
|
||||
geomTrans: new THREE.Matrix4().makeTranslation(-center.x, -center.y, -minZ)
|
||||
};
|
||||
|
||||
let matrixData = btnElement.getAttribute('data-matrix');
|
||||
if (matrixData && matrixData.trim() !== '' && matrixData !== 'None') {
|
||||
try {
|
||||
let mArray = JSON.parse(matrixData);
|
||||
// Skip if it actually is a composite (handled by addModelToPlate)
|
||||
if (mArray && mArray.is_composite === true) return;
|
||||
|
||||
if (mArray && !Array.isArray(mArray) && mArray.matrix) {
|
||||
mArray = mArray.matrix;
|
||||
}
|
||||
let savedMatrix = new THREE.Matrix4().fromArray(mArray);
|
||||
savedMatrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
|
||||
} catch (e) {
|
||||
@@ -514,7 +752,6 @@ function addModelToPlate(btnElement, fileId, url, name, status) {
|
||||
mesh.position.y = (Math.random() - 0.5) * 50;
|
||||
}
|
||||
} else {
|
||||
// Random slight offset so they don't exactly stack
|
||||
mesh.position.x = (Math.random() - 0.5) * 50;
|
||||
mesh.position.y = (Math.random() - 0.5) * 50;
|
||||
}
|
||||
@@ -523,12 +760,10 @@ function addModelToPlate(btnElement, fileId, url, name, status) {
|
||||
loadedModels.push(mesh);
|
||||
selectModel(mesh);
|
||||
|
||||
iconSpan.className = originalClass;
|
||||
btnElement.disabled = false;
|
||||
if (callback) callback();
|
||||
}, undefined, function (error) {
|
||||
console.error(error);
|
||||
iconSpan.className = originalClass;
|
||||
btnElement.disabled = false;
|
||||
if (callback) callback();
|
||||
alert("{{ _('Error loading STL model file.') }}");
|
||||
});
|
||||
}
|
||||
@@ -581,6 +816,8 @@ function animate() {
|
||||
}
|
||||
|
||||
function mergeAndSlice() {
|
||||
selectModels([]); // Detach any active model to bake transformProxy world coordinates into its local matrix properties
|
||||
|
||||
if (loadedModels.length === 0) {
|
||||
alert("{{ _('Please add at least one model to the build plate.') }}");
|
||||
return;
|
||||
@@ -591,14 +828,27 @@ function mergeAndSlice() {
|
||||
return;
|
||||
}
|
||||
|
||||
let isEdit = (loadedModels.length === 1 && String(loadedModels[0].userData.fileId) === String(initialAddId));
|
||||
let isEdit = false;
|
||||
let targetFileId = null;
|
||||
|
||||
if (window.isCompositeEdit) {
|
||||
isEdit = true;
|
||||
targetFileId = initialAddId;
|
||||
} else if (loadedModels.length === 1 && String(loadedModels[0].userData.fileId) === String(initialAddId)) {
|
||||
isEdit = true;
|
||||
targetFileId = initialAddId;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
const singleModel = loadedModels[0];
|
||||
if (singleModel.userData.status === 'sliced') {
|
||||
// Just checking if we want to warn
|
||||
if (loadedModels.length === 1 && loadedModels[0].userData.status === 'sliced') {
|
||||
if (!confirm("{{ _('This model has already been sliced. The existing GCode will be overwritten. Continue?') }}")) {
|
||||
return;
|
||||
}
|
||||
} else if (window.isCompositeEdit) {
|
||||
if (!confirm("{{ _('You are editing a composite model. The existing composite will be updated and re-sliced. Continue?') }}")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,7 +863,7 @@ function mergeAndSlice() {
|
||||
return {
|
||||
file_id: m.userData.fileId,
|
||||
matrix: mat.elements, // Array of 16 numbers used for slicing
|
||||
raw_matrix: m.matrix.elements // Local visual properties
|
||||
raw_matrix: m.matrixWorld.elements // Use world matrix explicitly just in case
|
||||
};
|
||||
});
|
||||
|
||||
@@ -636,7 +886,7 @@ function mergeAndSlice() {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ pieces: pieces, quality: quality, infill: infill, support: support, support_pattern: supportPattern, is_edit: isEdit })
|
||||
body: JSON.stringify({ pieces: pieces, quality: quality, infill: infill, support: support, support_pattern: supportPattern, is_edit: isEdit, target_file_id: targetFileId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
@@ -671,6 +921,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (addId) {
|
||||
const btn = document.getElementById('add-model-btn-' + addId);
|
||||
if (btn) {
|
||||
let matrixData = btn.getAttribute('data-matrix');
|
||||
if (matrixData) {
|
||||
try {
|
||||
let d = JSON.parse(matrixData);
|
||||
if (d && d.is_composite === true) {
|
||||
window.isCompositeEdit = true;
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
btn.click();
|
||||
}
|
||||
}
|
||||
|
||||
87
app/templates/printer/control.html
Normal file
87
app/templates/printer/control.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{% 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-arrows-move text-primary me-2"></i>{{ _('Printer Control') }}</h1>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="row row-cols-1 row-cols-lg-2 g-4">
|
||||
<!-- Webcam Stream -->
|
||||
<div class="col">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-dark text-light fw-bold rounded-top">
|
||||
<i class="bi bi-camera-video me-1"></i>{{ _('Live Webcam') }}
|
||||
</div>
|
||||
<div class="card-body p-0 ratio ratio-16x9">
|
||||
<img src="{{ webcam_url }}" alt="{{ _('Loading webcam stream...') }}" class="w-100 h-100 object-fit-cover">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Motion Control -->
|
||||
<div class="col">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light fw-bold text-secondary">
|
||||
<i class="bi bi-dpad me-1"></i>{{ _('Basic Control') }}
|
||||
</div>
|
||||
<div class="card-body text-center d-flex flex-column justify-content-center align-items-center">
|
||||
<!-- Home button -->
|
||||
<button class="btn btn-lg btn-primary rounded-circle mb-4 shadow" style="width: 80px; height: 80px;" onclick="sendCommand('home')" title="{{ _('Home All Axes') }}">
|
||||
<i class="bi bi-house-door fs-2"></i>
|
||||
</button>
|
||||
<div class="text-muted mb-4">{{ _('Home All Axes') }} (G28)</div>
|
||||
|
||||
<!-- Quick macros -->
|
||||
<div class="d-flex gap-3 justify-content-center flex-wrap w-100">
|
||||
<button class="btn btn-outline-danger flex-fill shadow-sm py-3" onclick="sendCommand('pause')" title="{{ _('Pause/Resume Print') }}">
|
||||
<i class="bi bi-pause-circle fs-4 d-block mb-1"></i>{{ _('Pause') }}
|
||||
</button>
|
||||
<button class="btn btn-outline-warning flex-fill shadow-sm py-3" onclick="sendCommand('cancel')" title="{{ _('Cancel Print') }}">
|
||||
<i class="bi bi-stop-circle fs-4 d-block mb-1"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function sendCommand(cmdName) {
|
||||
if ((cmdName === 'cancel' || cmdName === 'home') && !confirm("Are you sure you want to perform this action?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ url_for("printer.api_command") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({command: cmdName})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
flashMessage("success", "Command " + cmdName + " sent.");
|
||||
} else {
|
||||
flashMessage("danger", "Control failed: " + data.error);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
flashMessage("danger", "Network Error: " + err);
|
||||
});
|
||||
}
|
||||
function flashMessage(type, text) {
|
||||
const container = document.querySelector('.toast-container');
|
||||
if(!container) return alert(text);
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center border-0 bg-${type} text-white show`;
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body fw-medium">${text}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
42
app/templates/printer/octo_config.html
Normal file
42
app/templates/printer/octo_config.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"><i class="bi bi-gear-wide-connected text-primary me-2"></i>{{ _('OctoPrint Configuration') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-light fw-bold text-secondary border-bottom-0">
|
||||
<i class="bi bi-link-45deg me-1"></i>{{ _('Connection Settings') }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('printer.octo_config') }}">
|
||||
<div class="mb-3">
|
||||
<label for="octoprint_url" class="form-label fw-bold">{{ _('OctoPrint Base URL') }}</label>
|
||||
<div class="input-group mb-3 shadow-sm">
|
||||
<span class="input-group-text bg-white text-muted" id="url-addon"><i class="bi bi-globe"></i></span>
|
||||
<input type="url" class="form-control" id="octoprint_url" name="octoprint_url" aria-describedby="url-addon" placeholder="e.g. http://octopi.local" value="{{ configs.get('octoprint_url', '') }}" required>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{{ _('The local IP address or hostname of your OctoPrint server.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="octoprint_apikey" class="form-label fw-bold">{{ _('API Key / Application Key') }}</label>
|
||||
<div class="input-group shadow-sm">
|
||||
<span class="input-group-text bg-white text-muted" id="key-addon"><i class="bi bi-key"></i></span>
|
||||
<input type="password" class="form-control" id="octoprint_apikey" name="octoprint_apikey" aria-describedby="key-addon" placeholder="{{ _('Paste API Key here') }}" value="{{ configs.get('octoprint_apikey', '') }}">
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{{ _('Can be found in OctoPrint Settings -> Application Keys or API.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-primary px-4 rounded-pill shadow-sm"><i class="bi bi-save2 me-2"></i>{{ _('Save Connection Settings') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
28
app/templates/printer/octo_embed.html
Normal file
28
app/templates/printer/octo_embed.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% 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-window-sidebar text-info me-2"></i>{{ _('OctoPrint Panel (Embedded)') }}</h1>
|
||||
</div>
|
||||
|
||||
{% if embed_url %}
|
||||
<div class="card shadow rounded overflow-hidden" style="height: calc(100vh - 180px); min-height: 500px;">
|
||||
<!-- iFrame wrapper for responsivness -->
|
||||
<div class="w-100 h-100 position-relative">
|
||||
<iframe src="{{ embed_url }}"
|
||||
class="position-absolute border-0 w-100 h-100"
|
||||
style="top: 0; left: 0;"
|
||||
allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning shadow-sm border-0 d-flex align-items-center" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-4 text-warning me-3"></i>
|
||||
<div>
|
||||
<strong>{{ _('Configuration Required:') }}</strong>
|
||||
{{ _('The OctoPrint URL is not set. Please go to the ') }} <a href="{{ url_for('printer.octo_config') }}" class="alert-link text-decoration-underline">{{ _('System Configuration') }}</a> {{ _('page to set it up.') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
62
app/templates/printer/prepare.html
Normal file
62
app/templates/printer/prepare.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% 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-file-earmark-plus text-primary me-2"></i>{{ _('Prepare Print') }}</h1>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-bold text-secondary">
|
||||
<i class="bi bi-card-text me-1"></i>{{ _('Available Files on Printer') }}
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for f in files %}
|
||||
{% if f.type == 'machinecode' %}
|
||||
<div class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3">
|
||||
<div class="me-auto text-truncate" style="max-width: 80%;">
|
||||
<h6 class="mb-1"><i class="bi bi-file-earmark-code text-primary me-2"></i>{{ f.name }}</h6>
|
||||
<small class="text-muted d-block">{{ _('Size:') }} {{ f.size }} bytes, {{ _('Time:') }} {{ f.gcodeAnalysis.estimatedPrintTime if f.gcodeAnalysis else 'Unknown' }}s</small>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-success rounded-pill px-3 shadow-sm" onclick="printFile('{{ f.origin }}', '{{ f.path }}')"><i class="bi bi-play-fill me-1"></i>{{ _('Print Now') }}</button>
|
||||
<!-- <button class="btn btn-sm btn-outline-secondary rounded-pill ms-2" onclick="selectFile('{{ f.origin }}', '{{ f.path }}')">{{ _('Select') }}</button> -->
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="list-group-item text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4 d-block mb-3"></i>
|
||||
<p>{{ _('No printable files found. Go slice some G-Code first!') }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function printFile(origin, path) {
|
||||
if(!confirm("{{ _('Send this file to print immediately?') }}\n\n" + path)) return;
|
||||
|
||||
fetch('{{ url_for("printer.api_print_file") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ origin: origin, path: path })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
alert("{{ _('Print starting! Going to dashboard...') }}");
|
||||
window.location.href = "{{ url_for('printer.status') }}";
|
||||
} else {
|
||||
alert("Error: " + data.error);
|
||||
}
|
||||
})
|
||||
.catch(err => alert("Error: " + err));
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
105
app/templates/printer/status.html
Normal file
105
app/templates/printer/status.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{% 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-activity text-primary me-2"></i>{{ _('Printer Status') }}</h1>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('printer.octo_config') }}" class="alert-link">{{ _('Go to Configuration') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif status %}
|
||||
<div class="row row-cols-1 row-cols-md-2 g-4">
|
||||
<!-- State Card -->
|
||||
<div class="col">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light fw-bold text-secondary">
|
||||
<i class="bi bi-info-circle me-1"></i>{{ _('Current State') }}
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<h3 class="display-6 mt-3 text-primary">{{ status.get('state', {}).get('text', 'Unknown') }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Temperature Card -->
|
||||
<div class="col">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light fw-bold text-secondary">
|
||||
<i class="bi bi-thermometer-half me-1"></i>{{ _('Temperatures') }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% set temps = status.get('temperature', {}) %}
|
||||
|
||||
<h5 class="mb-1"><i class="bi bi-fire text-danger me-2"></i>{{ _('Tool/Nozzle') }}</h5>
|
||||
<h4 class="ms-4 mb-4">
|
||||
{{ temps.get('tool0', {}).get('actual', 0) }} °C
|
||||
<small class="text-muted fs-6">/ {{ temps.get('tool0', {}).get('target', 0) }} °C</small>
|
||||
</h4>
|
||||
|
||||
<h5 class="mb-1"><i class="bi bi-square-fill text-warning me-2"></i>{{ _('Bed') }}</h5>
|
||||
<h4 class="ms-4">
|
||||
{{ temps.get('bed', {}).get('actual', 0) }} °C
|
||||
<small class="text-muted fs-6">/ {{ temps.get('bed', {}).get('target', 0) }} °C</small>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if job and job.get('job', {}).get('file', {}).get('name') %}
|
||||
<div class="card shadow-sm mt-4 border-success">
|
||||
<div class="card-header bg-success text-white fw-bold">
|
||||
<i class="bi bi-play-circle me-1"></i>{{ _('Active Print Job') }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5>{{ job.get('job', {}).get('file', {}).get('name') }}</h5>
|
||||
|
||||
{% set progress = job.get('progress', {}).get('completion', 0) %}
|
||||
{% if progress == None %}{% set progress = 0 %}{% endif %}
|
||||
<div class="progress mt-3 mb-2" style="height: 25px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: {{ progress }}%;" aria-valuenow="{{ progress }}" aria-valuemin="0" aria-valuemax="100">
|
||||
{{ "%.1f"|format(progress) }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between text-muted small mt-2">
|
||||
<span><strong>{{ _('Print Time:') }}</strong> {{ job.get('progress', {}).get('printTime', 0) }}s</span>
|
||||
<span><strong>{{ _('Time Left:') }}</strong> {{ job.get('progress', {}).get('printTimeLeft', 0) }}s</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 gap-2 d-flex">
|
||||
<button class="btn btn-warning" onclick="sendCmd('pause')"><i class="bi bi-pause-fill me-1"></i>{{ _('Pause/Resume') }}</button>
|
||||
<button class="btn btn-danger" onclick="sendCmd('cancel')"><i class="bi bi-stop-fill me-1"></i>{{ _('Cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function sendCmd(cmd) {
|
||||
if(cmd === 'cancel' && !confirm("{{ _('Are you sure you want to cancel the print?') }}")) return;
|
||||
|
||||
fetch('{{ url_for("printer.api_command") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({command: cmd})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert("Error: " + data.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
setTimeout(() => { if (!window.pauseRefresh) window.location.reload(); }, 15000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -44,6 +44,10 @@
|
||||
"No files uploaded yet.": "No files uploaded yet.",
|
||||
"Drag & Drop STL file here or Click to Select": "Drag & Drop STL file here or Click to Select",
|
||||
"Uploading...": "Uploading...",
|
||||
"Simplifying": "Simplifying",
|
||||
"Simplifying...": "Simplifying...",
|
||||
"Proxy Skip Size (MB)": "Proxy Skip Size (MB)",
|
||||
"Files smaller than this will not generate a simplified proxy.": "Files smaller than this will not generate a simplified proxy.",
|
||||
"Slicing queued!": "Slicing queued!",
|
||||
"Draft Quality": "Draft Quality",
|
||||
"Standard Quality": "Standard Quality",
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
"No files uploaded yet.": "还没有上传文件。",
|
||||
"Drag & Drop STL file here or Click to Select": "将 STL 文件拖放到此处或点击选择",
|
||||
"Uploading...": "上传中...",
|
||||
"Simplifying": "简化中",
|
||||
"Simplifying...": "正在简化...",
|
||||
"Proxy Skip Size (MB)": "代理免简化大小 (MB)",
|
||||
"Files smaller than this will not generate a simplified proxy.": "极小体积的文件无需降维生成加速展现的代理文件。",
|
||||
"Upload Complete!": "上传完成!",
|
||||
"Upload error.": "上传出错。",
|
||||
"Upload failed.": "上传失败。",
|
||||
|
||||
@@ -4,3 +4,5 @@ Flask-Login==0.6.3
|
||||
Flask-Babel==4.0.0
|
||||
huey==2.5.0
|
||||
Werkzeug==3.0.1
|
||||
requests
|
||||
httpx
|
||||
138
stl_merger.py
138
stl_merger.py
@@ -2,89 +2,52 @@ import struct
|
||||
import math
|
||||
import os
|
||||
|
||||
def transform_stl(input_path, output_path, matrix):
|
||||
# matrix is a flat 16 float array from Three.js (column-major format)
|
||||
# [m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, m11, m12, m13, m14, m15]
|
||||
|
||||
with open(input_path, 'rb') as f:
|
||||
header = f.read(80)
|
||||
num_faces = struct.unpack('<I', f.read(4))[0]
|
||||
|
||||
faces = f.read()
|
||||
|
||||
# Precompute for speed
|
||||
def apply_m(_x, _y, _z):
|
||||
w = _x * matrix[3] + _y * matrix[7] + _z * matrix[11] + matrix[15]
|
||||
nx = (_x * matrix[0] + _y * matrix[4] + _z * matrix[8] + matrix[12]) / w
|
||||
ny = (_x * matrix[1] + _y * matrix[5] + _z * matrix[9] + matrix[13]) / w
|
||||
nz = (_x * matrix[2] + _y * matrix[6] + _z * matrix[10] + matrix[14]) / w
|
||||
return nx, ny, nz
|
||||
|
||||
def apply_rot(_nx, _ny, _nz):
|
||||
# Normals don't need translation, only upper 3x3
|
||||
# Assuming isotropic scaling for normals, otherwise needs inverse transpose
|
||||
x = _nx * matrix[0] + _ny * matrix[4] + _nz * matrix[8]
|
||||
y = _nx * matrix[1] + _ny * matrix[5] + _nz * matrix[9]
|
||||
z = _nx * matrix[2] + _ny * matrix[6] + _nz * matrix[10]
|
||||
length = math.sqrt(x*x + y*y + z*z)
|
||||
if length > 0.000001:
|
||||
return x/length, y/length, z/length
|
||||
return 0.0, 0.0, 0.0
|
||||
|
||||
out = bytearray(80 + 4 + num_faces * 50)
|
||||
out[0:80] = header
|
||||
out[80:84] = struct.pack('<I', num_faces)
|
||||
|
||||
offset = 84
|
||||
src_offset = 0
|
||||
for _ in range(num_faces):
|
||||
face_data = faces[src_offset:src_offset+50]
|
||||
if len(face_data) < 50:
|
||||
break
|
||||
|
||||
nx, ny, nz, v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z, attr = struct.unpack('<12fH', face_data)
|
||||
|
||||
# Transform normal
|
||||
nnx, nny, nnz = apply_rot(nx, ny, nz)
|
||||
# Transform vertices
|
||||
nv1x, nv1y, nv1z = apply_m(v1x, v1y, v1z)
|
||||
nv2x, nv2y, nv2z = apply_m(v2x, v2y, v2z)
|
||||
nv3x, nv3y, nv3z = apply_m(v3x, v3y, v3z)
|
||||
|
||||
struct.pack_into('<12fH', out, offset, nnx, nny, nnz, nv1x, nv1y, nv1z, nv2x, nv2y, nv2z, nv3x, nv3y, nv3z, attr)
|
||||
offset += 50
|
||||
src_offset += 50
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(out)
|
||||
|
||||
return num_faces
|
||||
|
||||
def merge_stls(input_files, output_path):
|
||||
try:
|
||||
import trimesh
|
||||
from stl import mesh
|
||||
import numpy as np
|
||||
|
||||
meshes = []
|
||||
for path, matrix in input_files:
|
||||
mesh = trimesh.load(path, file_type='stl')
|
||||
m = np.array(matrix, dtype=np.float64).reshape((4, 4)).T
|
||||
mesh.apply_transform(m)
|
||||
meshes.append(mesh)
|
||||
try:
|
||||
# 重新换回轻量级的 numpy-stl 以防内存溢出 (OOM)
|
||||
m = mesh.Mesh.from_file(path)
|
||||
|
||||
mat = np.array(matrix, dtype=np.float64).reshape((4, 4)).T
|
||||
|
||||
vectors = m.vectors.reshape(-1, 3)
|
||||
hom_vectors = np.hstack((vectors, np.ones((len(vectors), 1), dtype=np.float32)))
|
||||
transformed = (mat @ hom_vectors.T).T
|
||||
m.vectors = transformed[:, :3].reshape(-1, 3, 3)
|
||||
|
||||
# 检测缩放矩阵是否引发镜像翻转 (行列式为负数)
|
||||
det = np.linalg.det(mat[:3, :3])
|
||||
if det < 0:
|
||||
# 发生镜像反转,不仅法线会反向,三角形三个顶点的顺逆时针(Winding Order)也会错乱
|
||||
# 强行交换每个三角形的顶点2和顶点3以纠正渲染正反面
|
||||
m.vectors[:, [1, 2]] = m.vectors[:, [2, 1]]
|
||||
|
||||
m.update_normals()
|
||||
meshes.append(m)
|
||||
except Exception as e:
|
||||
print(f"Error processing path {path} with stl mesh: {e}")
|
||||
|
||||
if not meshes:
|
||||
return
|
||||
elif len(meshes) == 1:
|
||||
merged = meshes[0]
|
||||
else:
|
||||
merged = trimesh.util.concatenate(meshes)
|
||||
|
||||
merged.export(output_path, file_type='stl')
|
||||
if len(meshes) == 1:
|
||||
meshes[0].save(output_path)
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Trimesh fast-merge failed: {e}, falling back to struct parsing.")
|
||||
pass # Fallback to slower python struct method
|
||||
|
||||
merged_data = np.concatenate([m.data for m in meshes])
|
||||
merged_mesh = mesh.Mesh(merged_data)
|
||||
merged_mesh.save(output_path)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
print(f"Mesh fast-merge failed: {e}. Falling back to struct parsing.")
|
||||
|
||||
# Extreme fallback just in case no stl libraries work
|
||||
total_faces = 0
|
||||
meshes_data = []
|
||||
|
||||
@@ -94,10 +57,6 @@ def merge_stls(input_files, output_path):
|
||||
faces = struct.unpack('<I', f.read(4))[0]
|
||||
data = f.read(faces * 50)
|
||||
|
||||
# transform data
|
||||
# To speed things up, we could just do the transform in python like above
|
||||
# For this simple prototype, let's process each file into memory:
|
||||
|
||||
def apply_m(_x, _y, _z):
|
||||
w = _x * matrix[3] + _y * matrix[7] + _z * matrix[11] + matrix[15]
|
||||
nx = (_x * matrix[0] + _y * matrix[4] + _z * matrix[8] + matrix[12]) / w
|
||||
@@ -105,26 +64,34 @@ def merge_stls(input_files, output_path):
|
||||
nz = (_x * matrix[2] + _y * matrix[6] + _z * matrix[10] + matrix[14]) / w
|
||||
return nx, ny, nz
|
||||
|
||||
def apply_rot(_nx, _ny, _nz):
|
||||
x = _nx * matrix[0] + _ny * matrix[4] + _nz * matrix[8]
|
||||
y = _nx * matrix[1] + _ny * matrix[5] + _nz * matrix[9]
|
||||
z = _nx * matrix[2] + _ny * matrix[6] + _nz * matrix[10]
|
||||
length = math.sqrt(x*x + y*y + z*z)
|
||||
if length > 0.000001:
|
||||
return x/length, y/length, z/length
|
||||
return 0.0, 0.0, 0.0
|
||||
|
||||
new_data = bytearray(faces * 50)
|
||||
src_offset = 0
|
||||
dst_offset = 0
|
||||
for _ in range(faces):
|
||||
n_x, n_y, n_z, v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z, attr = struct.unpack_from('<12fH', data, src_offset)
|
||||
|
||||
nnx, nny, nnz = apply_rot(n_x, n_y, n_z)
|
||||
nv1x, nv1y, nv1z = apply_m(v1x, v1y, v1z)
|
||||
nv2x, nv2y, nv2z = apply_m(v2x, v2y, v2z)
|
||||
nv3x, nv3y, nv3z = apply_m(v3x, v3y, v3z)
|
||||
|
||||
# Recalculate normal properly using cross product to fix flipped/sheared surfaces
|
||||
Ux = nv2x - nv1x
|
||||
Uy = nv2y - nv1y
|
||||
Uz = nv2z - nv1z
|
||||
Vx = nv3x - nv1x
|
||||
Vy = nv3y - nv1y
|
||||
Vz = nv3z - nv1z
|
||||
|
||||
nnx = Uy * Vz - Uz * Vy
|
||||
nny = Uz * Vx - Ux * Vz
|
||||
nnz = Ux * Vy - Uy * Vx
|
||||
|
||||
l = math.sqrt(nnx**2 + nny**2 + nnz**2)
|
||||
if l > 1e-8:
|
||||
nnx, nny, nnz = nnx/l, nny/l, nnz/l
|
||||
else:
|
||||
nnx, nny, nnz = 0.0, 0.0, 0.0
|
||||
|
||||
struct.pack_into('<12fH', new_data, dst_offset, nnx, nny, nnz, nv1x, nv1y, nv1z, nv2x, nv2y, nv2z, nv3x, nv3y, nv3z, attr)
|
||||
src_offset += 50
|
||||
dst_offset += 50
|
||||
@@ -132,7 +99,6 @@ def merge_stls(input_files, output_path):
|
||||
meshes_data.append(new_data)
|
||||
total_faces += faces
|
||||
|
||||
# write merged
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(b'\0' * 80)
|
||||
f.write(struct.pack('<I', total_faces))
|
||||
|
||||
@@ -5,6 +5,70 @@ import sys
|
||||
import os
|
||||
|
||||
def simplify_stl(input_path, output_path, keep_ratio=0.1):
|
||||
try:
|
||||
# Try using professional pymeshlab first
|
||||
import pymeshlab
|
||||
ms = pymeshlab.MeshSet()
|
||||
ms.load_new_mesh(input_path)
|
||||
|
||||
target_faces = int(ms.current_mesh().face_number() * keep_ratio)
|
||||
|
||||
# Optimize using quadric edge collapse to preserve 95% visual effect
|
||||
try:
|
||||
ms.apply_filter('meshing_decimation_quadric_edge_collapse',
|
||||
targetfacenum=target_faces,
|
||||
preserveboundary=True,
|
||||
preservenormal=True,
|
||||
preservetopology=True)
|
||||
except AttributeError:
|
||||
ms.meshing_decimation_quadric_edge_collapse(
|
||||
targetfacenum=target_faces,
|
||||
preserveboundary=True,
|
||||
preservenormal=True,
|
||||
preservetopology=True
|
||||
)
|
||||
|
||||
ms.save_current_mesh(output_path)
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Pymeshlab simplification failed: {e}. Falling back to Open3D...")
|
||||
|
||||
try:
|
||||
# Try using open3d as second fallback
|
||||
import open3d as o3d
|
||||
o3d_mesh = o3d.io.read_triangle_mesh(input_path)
|
||||
if len(o3d_mesh.triangles) > 0:
|
||||
target_faces = max(1, int(len(o3d_mesh.triangles) * keep_ratio))
|
||||
smp_mesh = o3d_mesh.simplify_quadric_decimation(target_number_of_triangles=target_faces)
|
||||
smp_mesh.compute_triangle_normals()
|
||||
o3d.io.write_triangle_mesh(output_path, smp_mesh)
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Open3D simplification failed: {e}. Falling back to PyFQMR...")
|
||||
|
||||
try:
|
||||
# Try using pyfqmr as third fallback
|
||||
import pyfqmr
|
||||
import trimesh
|
||||
mesh_data = trimesh.load(input_path, file_type='stl')
|
||||
target_faces = max(1, int(len(mesh_data.faces) * keep_ratio))
|
||||
simplifier = pyfqmr.Simplify()
|
||||
simplifier.setMesh(mesh_data.vertices, mesh_data.faces)
|
||||
simplifier.simplify_mesh(target_count=target_faces, aggressiveness=7, preserve_border=True, verbose=False)
|
||||
|
||||
mesh_parts = simplifier.getMesh()
|
||||
smp_mesh = trimesh.Trimesh(vertices=mesh_parts[0], faces=mesh_parts[1], process=False)
|
||||
smp_mesh.export(output_path, file_type='stl')
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"PyFQMR simplification failed: {e}. Falling back to custom algorithm...")
|
||||
|
||||
try:
|
||||
try:
|
||||
import trimesh
|
||||
|
||||
BIN
test_out.STL
BIN
test_out.STL
Binary file not shown.
Reference in New Issue
Block a user