This commit is contained in:
2026-04-13 16:32:30 +08:00
parent dad17dbadd
commit 1de35f21d7
14 changed files with 1081 additions and 63 deletions

View File

@@ -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
View 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
View 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)

View File

@@ -415,7 +415,16 @@ def merge_and_slice():
inputs.append((path, p['matrix']))
# 只有在单一编辑模式才修改原模型的矩阵 (如果多模型/新建模式,我们不修改原模型,而是后续记录到新的包含实体上)
if 'raw_matrix' in p and is_edit and len(pieces) == 1:
f.transform_matrix = json.dumps(p['raw_matrix'])
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()
@@ -431,7 +440,13 @@ def merge_and_slice():
if print_file.transform_matrix and 'is_composite' in print_file.transform_matrix:
composite_data = {
"is_composite": True,
"parts": []
"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'])
@@ -444,7 +459,16 @@ def merge_and_slice():
})
print_file.transform_matrix = json.dumps(composite_data)
elif len(pieces) == 1:
print_file.transform_matrix = json.dumps(pieces[0].get('raw_matrix', pieces[0]['matrix']))
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()
@@ -474,7 +498,13 @@ def merge_and_slice():
# 构建组合文件元数据树 (is_composite: true)
composite_data = {
"is_composite": True,
"parts": []
"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'])

View File

@@ -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>

View File

@@ -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: `

View File

@@ -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;
if (isUniform) {
// Find previous scale
const prev = activeModel.scale[axis];
const ratio = val / prev;
activeModel.scale.x *= ratio;
activeModel.scale.y *= ratio;
activeModel.scale.z *= ratio;
} else {
activeModel.scale[axis] = val;
}
selectedModels.forEach(m => {
scene.attach(m);
if (isUniform) {
const prev = m.scale[axis];
const ratio = val / prev;
m.scale.x *= ratio;
m.scale.y *= ratio;
m.scale.z *= ratio;
} else {
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 {
selectModels([intersects[0].object]);
}
} else {
selectModel(null);
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,12 +647,29 @@ 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);
@@ -538,7 +726,7 @@ function loadSTL(fileId, url, name, status, matrixData, callback) {
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,
@@ -547,9 +735,15 @@ function loadSTL(fileId, url, name, status, matrixData, callback) {
geomTrans: new THREE.Matrix4().makeTranslation(-center.x, -center.y, -minZ)
};
if (matrixData && matrixData.trim() !== '' && matrixData !== 'None' && !matrixData.includes('"is_composite"')) {
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) {
@@ -622,7 +816,7 @@ function animate() {
}
function mergeAndSlice() {
selectModel(null); // Detach any active model to bake transformProxy world coordinates into its local matrix properties
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.') }}");
@@ -728,8 +922,13 @@ document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('add-model-btn-' + addId);
if (btn) {
let matrixData = btn.getAttribute('data-matrix');
if (matrixData && matrixData.includes('"is_composite"')) {
window.isCompositeEdit = true;
if (matrixData) {
try {
let d = JSON.parse(matrixData);
if (d && d.is_composite === true) {
window.isCompositeEdit = true;
}
} catch(e) {}
}
btn.click();
}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@@ -4,3 +4,5 @@ Flask-Login==0.6.3
Flask-Babel==4.0.0
huey==2.5.0
Werkzeug==3.0.1
requests
httpx

View File

@@ -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