tmp
This commit is contained in:
@@ -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)
|
||||
@@ -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'])
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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 %}
|
||||
Reference in New Issue
Block a user