Compare commits
17 Commits
570af7c225
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c8de5e664 | |||
| 91bedce2d7 | |||
| 6ccd3eb9c1 | |||
| 42e3050fa2 | |||
| 75ceec0798 | |||
| e542c482d7 | |||
| a26f7214f9 | |||
| 40b8cc8023 | |||
| ced6c67e83 | |||
| 0b2199ec49 | |||
| 72e3a165ac | |||
| 2dbecfe0d4 | |||
| 366372da6e | |||
| 22a6493e24 | |||
| 0416922a94 | |||
| 6981553101 | |||
| f0f9d658eb |
2
.gitignore
vendored
@@ -4,3 +4,5 @@ tmp/*
|
||||
venv
|
||||
instance
|
||||
huey_queue.*
|
||||
*.AppImage
|
||||
frpc/*
|
||||
79
502_err.html
Normal file
@@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>502 - AIO 切片服务器未连接</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
color: #333;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
background: #fff;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
font-size: 60px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 15px;
|
||||
color: #dc3545;
|
||||
}
|
||||
p {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
color: #555;
|
||||
text-align: left;
|
||||
}
|
||||
.suggestions {
|
||||
background: #f1f3f5;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
.suggestions ul {
|
||||
margin: 10px 0 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.suggestions li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">🔌</div>
|
||||
<h1>AIO 切片服务器连接失败 (502)</h1>
|
||||
<p>非常抱歉,公共代理服务器目前无法连接到后端的树莓派 AIO 切片服务。这通常是由于物理设备离线或网络异常导致的。</p>
|
||||
|
||||
<div class="suggestions">
|
||||
<strong>可能的原因:</strong>
|
||||
<ul>
|
||||
<li>树莓派尚未通电或正在启动中。</li>
|
||||
<li>树莓派所在的局域网断网,或未能成功连接到互联网。</li>
|
||||
</ul>
|
||||
<strong>建议操作:</strong>
|
||||
<ul>
|
||||
<li>请检查机器电源,稍等片刻后刷新页面重试。</li>
|
||||
<li>若问题仍然存在,请<strong>联系管理员</strong>检查网络配置。</li>
|
||||
<li>在局域网失效时,您可以尝试通过连接<strong>树莓派的本地热点</strong>直接进行文件传输。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
65
README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# AIO 3D Print Web Platform
|
||||
|
||||
简介
|
||||
----
|
||||
|
||||
这是一个基于 Python 的 Web 打印管理平台,通过调用 OctoPrint 的 API 来控制支持 Klipper 的打印机,集成切片、文件管理和打印机操作等功能。前端资源位于 `app/assets`,包含若干界面截图与帮助文档(见下方示例图片)。
|
||||
|
||||
示例图片
|
||||
---------
|
||||
|
||||

|
||||
|
||||
切片助手示例:
|
||||
|
||||

|
||||
|
||||
快速开始
|
||||
--------
|
||||
|
||||
1. 克隆仓库:
|
||||
|
||||
```bash
|
||||
git clone https://gitea.lhye.work/lhye200/AIO_3D_Print_Web_Platform.git
|
||||
cd AIO_3D_Print_Exp
|
||||
```
|
||||
|
||||
2. 运行安装脚本(会创建虚拟环境并安装 Python 依赖,下载运行需要的文件,安装 systemd 服务):
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
|
||||
安装脚本说明
|
||||
-------------
|
||||
|
||||
- 安装脚本会创建 `venv`、安装 `requirements.txt` 中列出的依赖,并尝试设置 systemd 服务。
|
||||
- 安装脚本可**可选**下载 PrusaSlicer 的 AppImage 二进制(用于本地进行切片):
|
||||
- 二进制来源: https://github.com/davidk/PrusaSlicer-ARM.AppImage
|
||||
- 源码: https://github.com/prusa3d/PrusaSlicer
|
||||
- 控制方式(环境变量):
|
||||
- `PRUSA_SKIP_DOWNLOAD=1` : 跳过下载二进制(默认会询问)
|
||||
- `PRUSA_AGPL_ACCEPT=1` : 自动同意 AGPLv3 条款并下载(默认需要交互确认)
|
||||
|
||||
支持的切片引擎
|
||||
---------------
|
||||
- `Cura` 有一定支持,但由于其配置方式复杂容易出错,现使用体验不佳。
|
||||
- `PrusaSlicer` 较为全面的支持。
|
||||
|
||||
许可与第三方
|
||||
---------------
|
||||
|
||||
- 本仓库根目录的 `LICENSE` 为本项目主体采用的许可证(GPLv3)。
|
||||
- 本项目可选使用的第三方软件 PrusaSlicer 受 AGPLv3 约束;相关说明与合规提示见 [third_party/PRUSASLICER.md](third_party/PRUSASLICER.md)。
|
||||
- 如果你在服务器上运行并通过网络提供基于 AGPL 组件的服务,AGPL 可能要求你向使用该服务的用户公开对应源码。
|
||||
|
||||
AI 协助声明
|
||||
----------------
|
||||
|
||||
本仓库的部分内容由 AI 生成。
|
||||
|
||||
更多信息
|
||||
------------
|
||||
|
||||
- 代码结构与前端资源位于 `app/`,包括 `app/assets`(图片、脚本、样式)与 `app/templates`。
|
||||
- 请阅读 `install.sh` 以了解安装过程的详细步骤与可配置选项。
|
||||
16
aio-3d-huey.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=AIO 3D Printer Slice and Manage Platform Huey Tasks
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=1000
|
||||
|
||||
# Placeholder path; installer will replace with actual repository path
|
||||
WorkingDirectory=${REPO_DIR}/
|
||||
ExecStart=${REPO_DIR}/run_huey.sh
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
17
aio-3d-main.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=AIO 3D Printer Slice and Manage Platform
|
||||
After=aio-3d-huey.service network.target
|
||||
Wants=aio-3d-huey.service network.target
|
||||
|
||||
[Service]
|
||||
User=1000
|
||||
|
||||
# Placeholder path; installer will replace with actual repository path
|
||||
WorkingDirectory=${REPO_DIR}/
|
||||
ExecStart=${REPO_DIR}/run_main.sh
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -52,9 +52,14 @@ def _t(key):
|
||||
def create_app():
|
||||
app = Flask(__name__, static_url_path='/assets', static_folder='assets')
|
||||
app.config['SECRET_KEY'] = 'your-secret-key-change-it-in-production'
|
||||
app.config['SESSION_COOKIE_NAME'] = 'aio_session' # Prevent collision with OctoPrint's 'session' cookie
|
||||
app.config['REMEMBER_COOKIE_NAME'] = 'aio_remember'
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///../instance/aio_3d.db'
|
||||
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'connect_args': {'timeout': 15}}
|
||||
app.config['UPLOAD_FOLDER'] = os.path.abspath(os.path.join(app.root_path, '..', 'uploads'))
|
||||
app.config['UPLOAD_FOLDER'] = os.environ.get('UPLOAD_FOLDER', os.path.abspath(os.path.join(app.root_path, '..', 'uploads')))
|
||||
app.config['PRINT_CONFIG_FOLDER'] = os.environ.get('PRINT_CONFIG_FOLDER', os.path.abspath(os.path.join(app.root_path, '..', 'print_config')))
|
||||
app.config['PRUSA_SLICE_BIN'] = os.environ.get('PRUSA_SLICE_BIN', os.path.abspath(os.path.join(app.root_path, '..', 'prusaslicer', 'PrusaSlicer-2.9.4-aarch64-full.AppImage')))
|
||||
|
||||
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
|
||||
@@ -78,9 +83,11 @@ def create_app():
|
||||
from .routes.auth_routes import auth_bp
|
||||
from .routes.admin_routes import admin_bp
|
||||
from .routes.printer_routes import printer_bp
|
||||
from .utils.api_handle import api_bp
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(printer_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
return app
|
||||
|
||||
58
app/assets/doc/printer_helper_de.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Drucker-Helfer — Kurzanleitung
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- Druckerstatus
|
||||
- Druck vorbereiten
|
||||
- Steuerung
|
||||
- Drucker-Helfer (diese Seite)
|
||||
- Systemkonfiguration (Admin)
|
||||
- OctoPrint-Panel (Admin)
|
||||
|
||||
---
|
||||
|
||||
## Druckerstatus
|
||||
|
||||
Zeigt aktuellen Druckerzustand, Temperaturen und aktive Aufgaben an.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Druck vorbereiten
|
||||
|
||||
GCode an den Drucker senden, Temperaturen setzen und mit `Druck vorbereiten` bzw. `Jetzt drucken` starten.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Steuerung
|
||||
|
||||
Manuelle Grundsteuerungen: Achsen homing, Düsen/Betten bewegen, Pause/Fortsetzen, Druck abbrechen.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Drucker-Helfer (diese Seite)
|
||||
|
||||
Tipps zur Fehlerbehebung (Netzwerk, Filament, Bettleveling) und Checkliste vor dem Drucken.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Systemkonfiguration (Admin)
|
||||
|
||||
Admin-Einstellungen für Druckerabmessungen, Limits, Basisprofile und Verbindungsdaten.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## OctoPrint-Panel (Admin)
|
||||
|
||||
Eingebettetes OctoPrint-Panel: `OctoPrint Basis-URL` und API-Key konfigurieren, Live-Panel verwenden.
|
||||
|
||||

|
||||
|
||||
64
app/assets/doc/printer_helper_en.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Printer Helper — Quick Guide
|
||||
|
||||
## Table of Contents
|
||||
- Printer Status
|
||||
- Prepare Print
|
||||
- Control
|
||||
- Printer Helper (this page)
|
||||
- System Configuration (Admin)
|
||||
- OctoPrint Panel (Admin)
|
||||
|
||||
---
|
||||
|
||||
## Printer Status
|
||||
|
||||
Shows current printer state, temperatures and active job information.
|
||||
|
||||

|
||||
|
||||
Use this page to monitor `Printer Status` and `Active Print Job`.
|
||||
|
||||
---
|
||||
|
||||
## Prepare Print
|
||||
|
||||
Send prepared GCode to the printer, set temperatures and start a print using `Prepare Print` and `Print Now`.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Control
|
||||
|
||||
Basic manual controls: home axes, move nozzle/bed, pause/resume and cancel print.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Printer Helper (this page)
|
||||
|
||||
Guides common troubleshooting steps (connectivity, filament, bed leveling) and quick checks before printing.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## System Configuration (Admin)
|
||||
|
||||
Admin-only settings for printer dimensions, limits, shared profiles and connection settings.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## OctoPrint Panel (Admin)
|
||||
|
||||
Embedded OctoPrint panel: configure `OctoPrint Base URL`, API key and use the live panel when available.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
If you want I can add annotated screenshots for specific printer models.
|
||||
|
||||
60
app/assets/doc/printer_helper_zh-cn.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 打印助手 — 快速指南
|
||||
|
||||
## 目录
|
||||
- 打印机状态
|
||||
- 准备打印
|
||||
- 控制
|
||||
- 打印助手(本页)
|
||||
- 系统配置(管理员)
|
||||
- OctoPrint 面板(管理员)
|
||||
|
||||
---
|
||||
|
||||
## 打印机状态
|
||||
|
||||
显示当前打印机状态、温度和任务信息。
|
||||
|
||||

|
||||
|
||||
可在此查看 `打印机状态` 与 `当前打印任务`。
|
||||
|
||||
---
|
||||
|
||||
## 准备打印
|
||||
|
||||
将准备好的 GCode 发送到打印机,设置温度并使用 `准备打印` 或 `立即打印` 开始。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 控制
|
||||
|
||||
手动控制:回原点、移动喷嘴/平台、暂停/恢复与取消打印。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 打印助手(本页)
|
||||
|
||||
提供常见故障排查步骤(网络、挤出机、床平整)和打印前检查清单。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 系统配置(管理员)
|
||||
|
||||
管理员设置打印机尺寸、限制、基础配置和连接信息。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## OctoPrint 面板(管理员)
|
||||
|
||||
内嵌 OctoPrint 面板:配置 `OctoPrint 基础 URL`、API 密钥并使用可用的实时面板。
|
||||
|
||||

|
||||
|
||||
82
app/assets/doc/slice_helper_de.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Slice-Helfer — Kurzanleitung
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
- Startseite
|
||||
- Meine Dateien
|
||||
- Plater (Bauteilplatte)
|
||||
- Konto-Verwaltung
|
||||
- Slice-Helfer (diese Seite)
|
||||
- Systemeinstellungen (Admin)
|
||||
- Benutzerverwaltung (Admin)
|
||||
- API-Schlüssel (Admin)
|
||||
|
||||
---
|
||||
|
||||
## Startseite
|
||||
|
||||
Übersicht des Slicer-Dashboards und schnelle Aktionen.
|
||||
|
||||

|
||||
|
||||
Benutzen Sie die Navigation, um `Startseite` zu öffnen und über `STL Hochladen & Slicen` einen neuen Slice zu starten.
|
||||
|
||||
---
|
||||
|
||||
## Meine Dateien
|
||||
|
||||
Verwalten Sie hochgeladene STL- und GCode-Dateien: hochladen, herunterladen, löschen.
|
||||
|
||||

|
||||
|
||||
Wichtige Aktionen: `STL hochladen`, `GCode Herunterladen`, `Löschen`.
|
||||
|
||||
---
|
||||
|
||||
## Plater (Bauteilplatte)
|
||||
|
||||
Modelle auf der Bauteilplatte anordnen, verschieben, drehen und skalieren. Vor dem Slicen `Zusammenführen & Slicen`.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Konto-Verwaltung
|
||||
|
||||
Für angemeldete Benutzer: Profil, Passwort ändern und aktive Sitzungen verwalten.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Slice-Helfer (diese Seite)
|
||||
|
||||
Erklärung der empfohlenen Slicing-Schritte: `Qualitätsprofil` wählen, `Support` und `Fülldichte` konfigurieren, dann `Hochladen & Slicen`.
|
||||
|
||||

|
||||
|
||||
Statusmeldungen: `Wartend`, `Slicen`, `Gesliced`, `Fehlgeschlagen`.
|
||||
|
||||
---
|
||||
|
||||
## Systemeinstellungen (Admin)
|
||||
|
||||
Admins konfigurieren globale Slicer-Engines und Standardprofile.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Benutzerverwaltung (Admin)
|
||||
|
||||
Admins können Benutzer hinzufügen/ändern und Quoten sowie Rollen setzen (`Benutzer`, `Admin`).
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## API-Schlüssel (Admin)
|
||||
|
||||
Verwalten Sie API-Schlüssel für externe Integrationen: `Neuen API-Schlüssel erstellen` und `Schlüssel generieren`.
|
||||
|
||||

|
||||
|
||||
88
app/assets/doc/slice_helper_en.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Slice Helper — Quick Guide
|
||||
|
||||
## Table of Contents
|
||||
- Home
|
||||
- My Files
|
||||
- Plater (Build Plate)
|
||||
- Account Management
|
||||
- Slice Helper (this page)
|
||||
- System Settings (Admin)
|
||||
- User Management (Admin)
|
||||
- API Keys (Admin)
|
||||
|
||||
---
|
||||
|
||||
## Home
|
||||
|
||||
Overview of the slicer dashboard and quick actions.
|
||||
|
||||

|
||||
|
||||
Use the top navigation to open `Home` and start a new slice via `Upload & Slice STL`.
|
||||
|
||||
---
|
||||
|
||||
## My Files
|
||||
|
||||
Manage uploaded STL and GCode files. You can upload, delete and download sliced GCode.
|
||||
|
||||

|
||||
|
||||
Common actions: `Upload STL`, `Download GCode`, `Delete`.
|
||||
|
||||
---
|
||||
|
||||
## Plater (Build Plate)
|
||||
|
||||
Arrange models on the build plate before slicing. Use translate/rotate/scale tools and `Merge & Slice`.
|
||||
|
||||

|
||||
|
||||
Tip: ensure all models fit the printable area before slicing.
|
||||
|
||||
---
|
||||
|
||||
## Account Management
|
||||
|
||||
Available when logged in. Update profile, change password, and manage active sessions.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Slice Helper (this page)
|
||||
|
||||
This page explains slice workflows and recommended settings. Choose a `Quality Profile`, set `Support` and `Infill Density` then `Upload & Slice`.
|
||||
|
||||

|
||||
|
||||
Status messages: `Waiting`, `Slicing`, `Sliced`, `Failed`.
|
||||
|
||||
---
|
||||
|
||||
## System Settings (Admin)
|
||||
|
||||
Admins can configure global slicer engines and default profiles under `System Settings`.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## User Management (Admin)
|
||||
|
||||
Admins can add/edit users, set quotas and roles (`User`, `Admin`).
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## API Keys (Admin)
|
||||
|
||||
Manage API keys used by external tools. `Create New API Key`, name it and `Generate Key`.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
If you need example workflows or screenshots, tell me which page to expand.
|
||||
|
||||
84
app/assets/doc/slice_helper_zh-cn.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 切片助手 — 快速指南
|
||||
|
||||
## 目录
|
||||
- 主页
|
||||
- 我的文件
|
||||
- 构建板 (Plater)
|
||||
- 账号管理
|
||||
- 切片助手(本页)
|
||||
- 系统设置(管理员)
|
||||
- 用户管理(管理员)
|
||||
- API 密钥(管理员)
|
||||
|
||||
---
|
||||
|
||||
## 主页
|
||||
|
||||
切片仪表盘概览与快速操作入口。
|
||||
|
||||

|
||||
|
||||
使用导航栏进入“主页”,通过 `上传并切片 STL` 开始新切片。
|
||||
|
||||
---
|
||||
|
||||
## 我的文件
|
||||
|
||||
管理已上传的 STL 与 GCode,可上传、下载或删除文件。
|
||||
|
||||

|
||||
|
||||
常用操作:`上传STL`、`下载 GCode`、`删除`。
|
||||
|
||||
---
|
||||
|
||||
## 构建板 (Plater)
|
||||
|
||||
在构建板上放置与调整模型(平移/旋转/缩放),确认位置后使用 `合并并切片`。
|
||||
|
||||

|
||||
|
||||
提示:切片前确保模型均在可打印范围内。
|
||||
|
||||
---
|
||||
|
||||
## 账号管理
|
||||
|
||||
登录用户可在此更新资料、修改密码并管理活跃会话。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 切片助手(本页)
|
||||
|
||||
本页说明推荐的切片流程与设置:选择 `质量配置`、设置 `支撑` 与 `填充密度`,然后 `上传 & 切片`。
|
||||
|
||||

|
||||
|
||||
状态提示:`等待中`、`切片中`、`已切片`、`失败`。
|
||||
|
||||
---
|
||||
|
||||
## 系统设置(管理员)
|
||||
|
||||
管理员可在此配置全局切片引擎与默认配置文件。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 用户管理(管理员)
|
||||
|
||||
管理员可添加/编辑用户并设置配额与角色(`普通用户`、`管理员`)。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## API 密钥(管理员)
|
||||
|
||||
管理外部工具使用的 API 密钥;点击 `创建新的 API 密钥`,输入名称并 `生成密钥`。
|
||||
|
||||

|
||||
|
||||
281
app/assets/i18n/de.json
Normal file
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"Language": "Sprache",
|
||||
"English": "English",
|
||||
"Chinese": "中文",
|
||||
"Guest": "Gast",
|
||||
"Login": "Anmelden",
|
||||
"Logout": "Abmelden",
|
||||
"Home": "Startseite",
|
||||
"New Slice": "Neuer Slice",
|
||||
"My Files": "Meine Dateien",
|
||||
"Admin Options": "Admin-Optionen",
|
||||
"System Settings": "Systemeinstellungen",
|
||||
"User Management": "Benutzerverwaltung",
|
||||
"Dashboard": "Dashboard",
|
||||
"Total Prints": "Gesamte Drucke",
|
||||
"You have sliced": "Sie haben",
|
||||
"files": "Dateien gesliced.",
|
||||
"Upload & Slice STL": "STL Hochladen & Slicen",
|
||||
"Select STL File": "STL-Datei auswählen",
|
||||
"Quality Profile": "Qualitätsprofil",
|
||||
"Upload & Slice": "Hochladen & Slicen",
|
||||
"3D Preview Area": "3D-Vorschaubereich",
|
||||
"Upload a file to display": "Eine Datei zur Anzeige hochladen",
|
||||
"Date Uploaded": "Hochladedatum",
|
||||
"Original Name": "Originalname",
|
||||
"Status": "Status",
|
||||
"Actions": "Aktionen",
|
||||
"Waiting": "Wartend",
|
||||
"Merging": "Zusammenführen",
|
||||
"Waiting in queue for slicing": "Wartet in der Warteschlange aufs Slicen",
|
||||
"Slicing": "Slicen",
|
||||
"Sliced": "Gesliced",
|
||||
"Uploaded": "Hochgeladen",
|
||||
"Failed": "Fehlgeschlagen",
|
||||
"This model has already been sliced. The existing GCode will be overwritten. Continue?": "Dieses Modell wurde bereits gesliced. Der existierende GCode wird überschrieben. Fortfahren?",
|
||||
"Upload STL": "STL hochladen",
|
||||
"Download GCode": "GCode Herunterladen",
|
||||
"GCode Preview": "GCode Vorschau",
|
||||
"Delete": "Löschen",
|
||||
"No files uploaded yet.": "Noch keine Dateien hochgeladen.",
|
||||
"Drag & Drop STL files here or Click to Select": "STL-Dateien hierher ziehen & ablegen oder zum Auswählen klicken",
|
||||
"Uploading...": "Lädt hoch...",
|
||||
"Simplifying": "Vereinfache",
|
||||
"Simplifying...": "Vereinfache...",
|
||||
"Proxy Skip Size (MB)": "Proxy-Überspringgröße (MB)",
|
||||
"Files smaller than this will not generate a simplified proxy.": "Dateien, die kleiner sind, werden keine vereinfachte Proxy generieren.",
|
||||
"Upload Complete!": "Hochladen abgeschlossen!",
|
||||
"Upload error.": "Fehler beim Hochladen.",
|
||||
"Upload failed.": "Hochladen fehlgeschlagen.",
|
||||
"Please upload a valid .stl file!": "Bitte laden Sie eine gültige .stl Datei hoch!",
|
||||
"Slicing queued!": "Slicen in die Warteschlange eingereiht!",
|
||||
"Draft Quality": "Entwurfsqualität",
|
||||
"Standard Quality": "Standardqualität",
|
||||
"High Quality": "Hohe Qualität",
|
||||
"Dynamic Quality": "Dynamische Qualität",
|
||||
"Low Quality": "Niedrige Qualität",
|
||||
"Super Quality": "Super Qualität",
|
||||
"Ultra Quality": "Ultra Qualität",
|
||||
"Plater": "Druckplatte",
|
||||
"Layer Progress:": "Schichtfortschritt:",
|
||||
"Loading and Parsing GCode Data...": "Lade und verarbeite GCode-Daten...",
|
||||
"Failed to load GCode preview.": "Fehler beim Laden der GCode-Vorschau.",
|
||||
"Outer Wall": "Außenwand",
|
||||
"Inner Wall": "Innenwand",
|
||||
"Infill": "Füllung",
|
||||
"Skin/TopBottom": "Hülle/ObenUnten",
|
||||
"Travel (Move)": "Bewegung (Reise)",
|
||||
"Skirt": "Skirt",
|
||||
"Support Interface": "Stützstruktur-Grenzschicht",
|
||||
"Back": "Zurück",
|
||||
"Layer": "Schicht",
|
||||
"Plater / Build Plate": "Druckbettleiste",
|
||||
"Translate (W)": "Verschieben (W)",
|
||||
"Rotate (E)": "Drehen (E)",
|
||||
"Scale (R)": "Skalieren (R)",
|
||||
"Scale": "Skalieren",
|
||||
"Uniform Scale": "Gleichmäßige Skalierung",
|
||||
"Lay Flat": "Flach legen",
|
||||
"Remove Selected (Del)": "Ausgewähltes entfernen (Del)",
|
||||
"Available Models": "Verfügbare Modelle",
|
||||
"No STL models uploaded yet. Go upload some first.": "Noch keine STL-Modelle hochgeladen. Laden Sie zuerst einige hoch.",
|
||||
"Other Settings": "Andere Einstellungen",
|
||||
"Infill Density": "Fülldichte",
|
||||
"Support": "Stützstruktur",
|
||||
"None": "Keine",
|
||||
"Touching Buildplate": "Nur Druckbett berührend",
|
||||
"Everywhere": "Überall",
|
||||
"Support Type": "Stützstruktur-Typ",
|
||||
"Tree": "Baum",
|
||||
"Lines": "Linien",
|
||||
"Grid": "Gitter",
|
||||
"Triangles": "Dreiecke",
|
||||
"Concentric": "Konzentrisch",
|
||||
"Zig Zag": "Zickzack",
|
||||
"Cross": "Kreuz",
|
||||
"Gyroid": "Gyroid",
|
||||
"Honeycomb": "Wabe",
|
||||
"Octagon": "Achteck",
|
||||
"Clear Board": "Druckbett leeren",
|
||||
"Merge & Slice": "Zusammenführen & Slicen",
|
||||
"Error loading STL model file.": "Fehler beim Laden der STL-Datei.",
|
||||
"Please add at least one model to the build plate.": "Bitte fügen Sie mindestens ein Modell zur Druckplatte hinzu.",
|
||||
"One or more models are outside the print area. Please adjust them before slicing.": "Mindestens ein Modell liegt außerhalb des Druckbereichs. Bitte anpassen.",
|
||||
"Error:": "Fehler:",
|
||||
"ID": "ID",
|
||||
"Username": "Benutzername",
|
||||
"Role": "Rolle",
|
||||
"Created At": "Erstellt am",
|
||||
"Admin": "Admin",
|
||||
"User": "Benutzer",
|
||||
"WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?": "WARNUNG: Sind Sie sicher, dass Sie diesen Benutzer UND ALLE seine Dateien löschen wollen?",
|
||||
"SliceEngine Configurations": "Slicing-Engine-Konfigurationen",
|
||||
"Plater Origin Offset X (mm)": "Druckbett Ursprung Offset X (mm)",
|
||||
"Adjust the X-axis compilation offset for combined files on the build plate.": "X-Achsen-Offset für kombinierte Dateien anpassen.",
|
||||
"Plater Origin Offset Y (mm)": "Druckbett Ursprung Offset Y (mm)",
|
||||
"Adjust the Y-axis compilation offset for combined files on the build plate.": "Y-Achsen-Offset für kombinierte Dateien anpassen.",
|
||||
"Default Plater Settings": "Standard-Druckbetteinstellungen",
|
||||
"Default Infill Density (%)": "Standard-Fülldichte (%)",
|
||||
"Default Support": "Standard-Stützstruktur",
|
||||
"Default Support Type": "Standard-Stützstruktur-Typ",
|
||||
"Default Quality Profile": "Standard-Qualitätsprofil",
|
||||
"Save Settings": "Einstellungen speichern",
|
||||
"You are editing a composite model. The existing composite will be updated and re-sliced. Continue?": "Sie bearbeiten ein kombiniiges Modell. Fortfahren?",
|
||||
"Select": "Auswählen",
|
||||
"OctoPrint settings updated": "OctoPrint-Einstellungen aktualisiert",
|
||||
"Settings updated successfully": "Einstellungen erfolgreich aktualisiert",
|
||||
"Print starting! Going to dashboard...": "Druck startet! Gehe zum Dashboard...",
|
||||
"Are you sure you want to delete this file?": "Sind Sie sicher, dass Sie diese Datei löschen wollen?",
|
||||
"OctoPrint Base URL": "OctoPrint Basis-URL",
|
||||
"Temperatures": "Temperaturen",
|
||||
"Notice": "Hinweis",
|
||||
"Please upload valid .stl files!": "Bitte gültige .stl hochladen!",
|
||||
"Print Time:": "Druckzeit:",
|
||||
"System Configuration": "Systemkonfiguration",
|
||||
"Command": "Befehl",
|
||||
"Time Left:": "Verbleibende Zeit:",
|
||||
"Yes": "Ja",
|
||||
"Current State": "Aktueller Zustand",
|
||||
"Error updating settings": "Fehler beim Aktualisieren der Einstellungen",
|
||||
"Available Files on Printer": "Verfügbare Dateien auf dem Drucker",
|
||||
"Control failed: ": "Steuerung fehlgeschlagen: ",
|
||||
"Loading webcam stream...": "Lade Webcam-Stream...",
|
||||
"Live Webcam": "Live-Webcam",
|
||||
"No printable files found. Go slice some G-Code first!": "Keine druckbaren Dateien gefunden. Erst G-Code slicen!",
|
||||
"Save Connection Settings": "Verbindungseinstellungen speichern",
|
||||
"Validation Failed": "Validierung fehlgeschlagen",
|
||||
"Cancel Print": "Drucken abbrechen",
|
||||
"Send this file to print immediately?": "Diese Datei sofort zum Druck senden?",
|
||||
"Print Now": "Jetzt drucken",
|
||||
"OctoPrint Panel (Embedded)": "OctoPrint-Panel (Eingebettet)",
|
||||
"Are you sure you want to perform this action?": "Sind Sie sicher, dass Sie die Aktion ausführen wollen?",
|
||||
"Control": "Steuerung",
|
||||
"Cancel": "Abbrechen",
|
||||
"sent.": "gesendet.",
|
||||
"The local IP address or hostname of your OctoPrint server.": "Lokale IP-Adresse oder Hostname Ihres OctoPrint-Servers.",
|
||||
"OctoPrint Panel": "OctoPrint-Panel",
|
||||
"General Operations": "Allgemeine Operationen",
|
||||
"Confirm": "Bestätigen",
|
||||
"Basic Control": "Basiskontrolle",
|
||||
"Size:": "Größe:",
|
||||
"Prepare Print": "Druck vorbereiten",
|
||||
"API Key / Application Key": "API-Schlüssel / Anwendungsschlüssel",
|
||||
"Configuration Required:": "Konfiguration erforderlich:",
|
||||
"Paste API Key here": "API-Schlüssel hier einfügen",
|
||||
"Tool/Nozzle": "Werkzeug/Düse",
|
||||
"Pause/Resume": "Pause/Fortsetzen",
|
||||
"Can be found in OctoPrint Settings -> Application Keys or API.": "Befindet sich in den OctoPrint-Einstellungen -> Anwendungsschlüssel oder API.",
|
||||
"Preview": "Vorschau",
|
||||
"Pause": "Pause",
|
||||
"Bed": "Druckbett",
|
||||
"Printer Status": "Druckerstatus",
|
||||
"Printer": "Drucker",
|
||||
"Admin / OctoPrint": "Admin / OctoPrint",
|
||||
"Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.": "Absoluter Pfad zum Speichern von GCode. Leer lassen für Systemstandard.",
|
||||
"Custom GCode Output Folder": "Benutzerdefinierter GCode-Ausgabeordner",
|
||||
"Go to Configuration": "Zur Konfiguration springen",
|
||||
"OK": "OK",
|
||||
"Connection Settings": "Verbindungseinstellungen",
|
||||
"OctoPrint Configuration": "OctoPrint-Konfiguration",
|
||||
"The OctoPrint URL is not set. Please go to the ": "Die OctoPrint-URL ist nicht gesetzt. Gehen Sie zu ",
|
||||
"Go to Print": "Zum Druck",
|
||||
"Time:": "Zeit:",
|
||||
"Are you sure you want to cancel the print?": "Wollen Sie den Druck wirklich abbrechen?",
|
||||
"Uploading and linking GCode...": "GCode hochladen und verknüpfen...",
|
||||
"Active Print Job": "Aktiver Druckauftrag",
|
||||
"Upload External GCode": "Externen GCode hochladen",
|
||||
"Slicer": "Slicer",
|
||||
"System Config": "Systemkonfiguration",
|
||||
"Home All Axes": "Alle Achsen auf Nullposition",
|
||||
"Pause/Resume Print": "Druck Pausieren/Fortsetzen",
|
||||
"Slice": "Slicen",
|
||||
"Network error": "Netzwerkfehler",
|
||||
"Printer Control": "Drucker-Steuerung",
|
||||
"Error saving settings": "Fehler beim Speichern der Einstellungen",
|
||||
"page to set it up.": "Seite zum Einrichten.",
|
||||
"Network Error: ": "Netzwerkfehler: ",
|
||||
"3D Model Files (STL)": "3D-Modelldateien (STL)",
|
||||
"You have uploaded": "Sie haben hochgeladen",
|
||||
"Total Space Used": "Verwendeter Speicherplatz",
|
||||
"Sliced Files (GCode)": "Geslicte Dateien (GCode)",
|
||||
"You have sliced or uploaded": "Sie haben geslict oder hochgeladen",
|
||||
"Default Storage Quotas (MB)": "Standard Speicherquoten (MB)",
|
||||
"Guest STL Quota": "Gast STL Quoten",
|
||||
"Unlimited": "Unbegrenzt",
|
||||
"Guest GCode Quota": "Gast GCode Quoten",
|
||||
"New User STL Quota": "Neuer Benutzer STL Quoten",
|
||||
"New User GCode Quota": "Neuer Benutzer GCode Quoten",
|
||||
"Quota": "Quote",
|
||||
"GCode Storage Quota Exceeded. Please delete some files first.": "GCode Speicherplatz überschritten. Bitte löschen Sie zuerst einige Dateien.",
|
||||
"Edit Quota": "Quota bearbeiten",
|
||||
"Edit Quota for": "Quota bearbeiten für",
|
||||
"Reset Password": "Passwort zurücksetzen",
|
||||
"Reset Password for": "Passwort zurücksetzen für",
|
||||
"Save": "Speichern",
|
||||
"STL Quota": "STL Quote",
|
||||
"GCode Quota": "GCode Quote",
|
||||
"New Password": "Neues Passwort",
|
||||
"Add User": "Benutzer hinzufügen",
|
||||
"Password": "Passwort",
|
||||
"Is Admin": "Ist Administrator",
|
||||
"Create User": "Benutzer erstellen",
|
||||
"Build Plate Model Path (.stl)": "Pfad zum Build-Platte-Modell (.stl)",
|
||||
"Absolute path to a custom build plate STL model to show in the plater. Leave empty to use none.": "Absoluter Pfad zum benutzerdefinierten Build-Platte-STL-Modell, das im Plater angezeigt werden soll. Leer lassen, um keines zu verwenden.",
|
||||
"Default Material Profile": "Standard Materialprofil",
|
||||
"Slicing Engine Configurations": "Slicer-Engine-Konfigurationen",
|
||||
"Slicing Engine": "Slicer-Engine",
|
||||
"Select the engine to be used globally. Ensure the selected engine is installed and accessible on the server.": "Wählen Sie die Engine aus, die global verwendet werden soll. Stellen Sie sicher, dass die ausgewählte Engine installiert und auf dem Server zugänglich ist.",
|
||||
"Material Profile": "Materialprofil",
|
||||
"Custom": "Benutzerdefiniert",
|
||||
"Skirt/Brim": "Sürz / Rand",
|
||||
"Support material": "Stützmaterial",
|
||||
"Perimeter": "Umriss",
|
||||
"External perimeter": "Externer Umriss",
|
||||
"Solid infill": "Solide Füllung",
|
||||
"Overhang perimeter": "Überhang Umriss",
|
||||
"Internal infill": "Interne Füllung",
|
||||
"Bridge infill": "Brückefüllung",
|
||||
"Top solid infill": "Oberste solide Füllung",
|
||||
"Others": "Andere",
|
||||
"Are you sure you want to clear the board?": "Sind Sie sicher, dass Sie das Brett leeren möchten?",
|
||||
"d": "t",
|
||||
"h": "std",
|
||||
"m": "m",
|
||||
"s": "s",
|
||||
"Auto Leveling": "Auto-Nivellierung",
|
||||
"Account Management": "Kontoverwaltung",
|
||||
"Change Password": "Passwort ändern",
|
||||
"Current Password": "Aktuelles Passwort",
|
||||
"Confirm New Password": "Neues Passwort bestätigen",
|
||||
"Update Password": "Passwort aktualisieren",
|
||||
"Active Sessions": "Aktive Sitzungen",
|
||||
"Device": "Gerät",
|
||||
"IP Address": "IP-Adresse",
|
||||
"Last Active": "Zuletzt aktiv",
|
||||
"Action": "Aktion",
|
||||
"This Device": "Dieses Gerät",
|
||||
"Unknown Device": "Unbekanntes Gerät",
|
||||
"Are you sure you want to terminate this session?": "Sind Sie sicher, dass Sie diese Sitzung beenden möchten?",
|
||||
"Logout from this device?": "Von diesem Gerät abmelden?",
|
||||
"No active sessions found.": "Keine aktiven Sitzungen gefunden.",
|
||||
"Please login to view the webcam stream.": "Bitte melden Sie sich an, um die Live-Kamera zu sehen.",
|
||||
"Remember Me": "Erinnere dich an mich",
|
||||
"Merge Guest Data": "Gästendaten zusammenführen",
|
||||
"Main configuration for the printer dimensions, limits and base profiles.": "Hauptkonfiguration für die Druckerabmessungen, -grenzen und Basisprofile.",
|
||||
"API Keys Management": "API-Schlüsselverwaltung",
|
||||
"Create New API Key": "Neuen API-Schlüssel erstellen",
|
||||
"Key Name": "Schlüsselname",
|
||||
"Generate Key": "Schlüssel generieren",
|
||||
"Are you sure you want to delete this API Key?": "Sind Sie sicher, dass Sie diesen API-Schlüssel löschen möchten?",
|
||||
"API Key Name": "API-Schlüsselname",
|
||||
"No API keys found.": "Keine API-Schlüssel gefunden.",
|
||||
"API Keys": "API-Schlüssel",
|
||||
"Slice Helper": "Slice-Helfer",
|
||||
"Printer Helper": "Drucker-Helfer",
|
||||
"For security reasons, please change your default admin password.": "Aus Sicherheitsgründen ändern Sie bitte Ihr Standard-Administratorpasswort.",
|
||||
"Your new password cannot be the default \"admin123\".": "Ihr neues Passwort darf nicht das Standardpasswort \"admin123\" sein.",
|
||||
"Current password is incorrect.": "Das aktuelle Passwort ist falsch.",
|
||||
"New passwords do not match.": "Die neuen Passwörter stimmen nicht überein.",
|
||||
"New password must be at least 6 characters.": "Das neue Passwort muss mindestens 6 Zeichen lang sein.",
|
||||
"Password updated successfully.": "Passwort erfolgreich aktualisiert."
|
||||
}
|
||||
@@ -103,7 +103,7 @@
|
||||
"Admin": "Admin",
|
||||
"User": "User",
|
||||
"WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?": "WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?",
|
||||
"CuraEngine Configurations": "CuraEngine Configurations",
|
||||
"SliceEngine Configurations": "SliceEngine Configurations",
|
||||
"Plater Origin Offset X (mm)": "Plater Origin Offset X (mm)",
|
||||
"Adjust the X-axis compilation offset for combined files on the build plate.": "Adjust the X-axis compilation offset for combined files on the build plate.",
|
||||
"Plater Origin Offset Y (mm)": "Plater Origin Offset Y (mm)",
|
||||
@@ -113,5 +113,169 @@
|
||||
"Default Support": "Default Support",
|
||||
"Default Support Type": "Default Support Type",
|
||||
"Default Quality Profile": "Default Quality Profile",
|
||||
"Save Settings": "Save Settings"
|
||||
"Save Settings": "Save Settings",
|
||||
"You are editing a composite model. The existing composite will be updated and re-sliced. Continue?": "You are editing a composite model. The existing composite will be updated and re-sliced. Continue?",
|
||||
"Select": "Select",
|
||||
"OctoPrint settings updated": "OctoPrint settings updated",
|
||||
"Settings updated successfully": "Settings updated successfully",
|
||||
"This model has already been sliced. The existing GCode will be overwritten. Continue?": "This model has already been sliced. The existing GCode will be overwritten. Continue?",
|
||||
"Print starting! Going to dashboard...": "Print starting! Going to dashboard...",
|
||||
"Are you sure you want to delete this file?": "Are you sure you want to delete this file?",
|
||||
"OctoPrint Base URL": "OctoPrint Base URL",
|
||||
"Temperatures": "Temperatures",
|
||||
"Notice": "Notice",
|
||||
"Please upload valid .stl files!": "Please upload valid .stl files!",
|
||||
"Print Time:": "Print Time:",
|
||||
"System Configuration": "System Configuration",
|
||||
"Command": "Command",
|
||||
"Time Left:": "Time Left:",
|
||||
"Yes": "Yes",
|
||||
"Current State": "Current State",
|
||||
"Error updating settings": "Error updating settings",
|
||||
"Available Files on Printer": "Available Files on Printer",
|
||||
"Control failed: ": "Control failed: ",
|
||||
"Loading webcam stream...": "Loading webcam stream...",
|
||||
"Live Webcam": "Live Webcam",
|
||||
"No printable files found. Go slice some G-Code first!": "No printable files found. Go slice some G-Code first!",
|
||||
"Save Connection Settings": "Save Connection Settings",
|
||||
"Upload error.": "Upload error.",
|
||||
"Validation Failed": "Validation Failed",
|
||||
"Cancel Print": "Cancel Print",
|
||||
"Send this file to print immediately?": "Send this file to print immediately?",
|
||||
"Print Now": "Print Now",
|
||||
"OctoPrint Panel (Embedded)": "OctoPrint Panel (Embedded)",
|
||||
"Are you sure you want to perform this action?": "Are you sure you want to perform this action?",
|
||||
"Control": "Control",
|
||||
"Cancel": "Cancel",
|
||||
"sent.": "sent.",
|
||||
"The local IP address or hostname of your OctoPrint server.": "The local IP address or hostname of your OctoPrint server.",
|
||||
"OctoPrint Panel": "OctoPrint Panel",
|
||||
"General Operations": "General Operations",
|
||||
"Confirm": "Confirm",
|
||||
"Basic Control": "Basic Control",
|
||||
"Size:": "Size:",
|
||||
"Prepare Print": "Prepare Print",
|
||||
"API Key / Application Key": "API Key / Application Key",
|
||||
"Configuration Required:": "Configuration Required:",
|
||||
"Paste API Key here": "Paste API Key here",
|
||||
"Tool/Nozzle": "Tool/Nozzle",
|
||||
"Pause/Resume": "Pause/Resume",
|
||||
"Can be found in OctoPrint Settings -> Application Keys or API.": "Can be found in OctoPrint Settings -> Application Keys or API.",
|
||||
"Preview": "Preview",
|
||||
"Pause": "Pause",
|
||||
"Bed": "Bed",
|
||||
"Printer Status": "Printer Status",
|
||||
"Printer": "Printer",
|
||||
"Admin / OctoPrint": "Admin / OctoPrint",
|
||||
"Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.": "Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.",
|
||||
"Custom GCode Output Folder": "Custom GCode Output Folder",
|
||||
"Go to Configuration": "Go to Configuration",
|
||||
"OK": "OK",
|
||||
"Connection Settings": "Connection Settings",
|
||||
"OctoPrint Configuration": "OctoPrint Configuration",
|
||||
"The OctoPrint URL is not set. Please go to the ": "The OctoPrint URL is not set. Please go to the ",
|
||||
"Go to Print": "Go to Print",
|
||||
"Time:": "Time:",
|
||||
"Are you sure you want to cancel the print?": "Are you sure you want to cancel the print?",
|
||||
"Uploading and linking GCode...": "Uploading and linking GCode...",
|
||||
"Active Print Job": "Active Print Job",
|
||||
"Upload External GCode": "Upload External GCode",
|
||||
"Slicer": "Slicer",
|
||||
"System Config": "System Config",
|
||||
"Home All Axes": "Home All Axes",
|
||||
"Pause/Resume Print": "Pause/Resume Print",
|
||||
"Slice": "Slice",
|
||||
"Upload failed.": "Upload failed.",
|
||||
"Network error": "Network error",
|
||||
"Upload Complete!": "Upload Complete!",
|
||||
"Printer Control": "Printer Control",
|
||||
"Error saving settings": "Error saving settings",
|
||||
"page to set it up.": "page to set it up.",
|
||||
"Network Error: ": "Network Error: ",
|
||||
"Upload STL": "Upload STL",
|
||||
"Please upload a valid .stl file!": "Please upload a valid .stl file!",
|
||||
"3D Model Files (STL)": "3D Model Files (STL)",
|
||||
"You have uploaded": "You have uploaded",
|
||||
"Total Space Used": "Total Space Used",
|
||||
"Sliced Files (GCode)": "Sliced Files (GCode)",
|
||||
"You have sliced or uploaded": "You have sliced or uploaded",
|
||||
"Default Storage Quotas (MB)": "Default Storage Quotas (MB)",
|
||||
"Guest STL Quota": "Guest STL Quota",
|
||||
"Unlimited": "Unlimited",
|
||||
"Guest GCode Quota": "Guest GCode Quota",
|
||||
"New User STL Quota": "New User STL Quota",
|
||||
"New User GCode Quota": "New User GCode Quota",
|
||||
"Quota": "Quota",
|
||||
"GCode Storage Quota Exceeded. Please delete some files first.": "GCode Storage Quota Exceeded. Please delete some files first.",
|
||||
"Edit Quota": "Edit Quota",
|
||||
"Edit Quota for": "Edit Quota for",
|
||||
"Reset Password": "Reset Password",
|
||||
"Reset Password for": "Reset Password for",
|
||||
"Save": "Save",
|
||||
"STL Quota": "STL Quota",
|
||||
"GCode Quota": "GCode Quota",
|
||||
"New Password": "New Password",
|
||||
"Add User": "Add User",
|
||||
"Password": "Password",
|
||||
"Is Admin": "Is Admin",
|
||||
"Create User": "Create User",
|
||||
"Build Plate Model Path (.stl)": "Build Plate Model Path (.stl)",
|
||||
"Absolute path to a custom build plate STL model to show in the plater. Leave empty to use none.": "Absolute path to a custom build plate STL model to show in the plater. Leave empty to use none.",
|
||||
"Default Material Profile": "Default Material Profile",
|
||||
"Slicing Engine Configurations": "Slicing Engine Configurations",
|
||||
"Slicing Engine": "Slicing Engine",
|
||||
"Select the engine to be used globally. Ensure the selected engine is installed and accessible on the server.": "Select the engine to be used globally. Ensure the selected engine is installed and accessible on the server.",
|
||||
"Material Profile": "Material Profile",
|
||||
"Custom": "Custom",
|
||||
"Skirt/Brim": "Skirt/Brim",
|
||||
"Support material": "Support material",
|
||||
"Perimeter": "Perimeter",
|
||||
"External perimeter": "External perimeter",
|
||||
"Solid infill": "Solid infill",
|
||||
"Overhang perimeter": "Overhang perimeter",
|
||||
"Internal infill": "Internal infill",
|
||||
"Bridge infill": "Bridge infill",
|
||||
"Top solid infill": "Top solid infill",
|
||||
"Others": "Others",
|
||||
"Are you sure you want to clear the board?": "Are you sure you want to clear the board?",
|
||||
"d": "d",
|
||||
"h": "h",
|
||||
"m": "m",
|
||||
"s": "s",
|
||||
"Auto Leveling": "Auto Leveling",
|
||||
"Account Management": "Account Management",
|
||||
"Change Password": "Change Password",
|
||||
"Current Password": "Current Password",
|
||||
"Confirm New Password": "Confirm New Password",
|
||||
"Update Password": "Update Password",
|
||||
"Active Sessions": "Active Sessions",
|
||||
"Device": "Device",
|
||||
"IP Address": "IP Address",
|
||||
"Last Active": "Last Active",
|
||||
"Action": "Action",
|
||||
"This Device": "This Device",
|
||||
"Unknown Device": "Unknown Device",
|
||||
"Are you sure you want to terminate this session?": "Are you sure you want to terminate this session?",
|
||||
"Logout from this device?": "Logout from this device?",
|
||||
"No active sessions found.": "No active sessions found.",
|
||||
"Please login to view the webcam stream.": "Please login to view the webcam stream.",
|
||||
"Remember Me": "Remember Me",
|
||||
"Merge Guest Data": "Merge Guest Data",
|
||||
"Main configuration for the printer dimensions, limits and base profiles.": "Main configuration for the printer dimensions, limits and base profiles.",
|
||||
"API Keys Management": "API Keys Management",
|
||||
"Create New API Key": "Create New API Key",
|
||||
"Key Name": "Key Name",
|
||||
"Generate Key": "Generate Key",
|
||||
"Are you sure you want to delete this API Key?": "Are you sure you want to delete this API Key?",
|
||||
"API Key Name": "API Key Name",
|
||||
"No API keys found.": "No API keys found.",
|
||||
"API Keys": "API Keys",
|
||||
"Slice Helper": "Slice Helper",
|
||||
"Printer Helper": "Printer Helper",
|
||||
"For security reasons, please change your default admin password.": "For security reasons, please change your default admin password.",
|
||||
"Your new password cannot be the default \"admin123\".": "Your new password cannot be the default \"admin123\".",
|
||||
"Current password is incorrect.": "Current password is incorrect.",
|
||||
"New passwords do not match.": "New passwords do not match.",
|
||||
"New password must be at least 6 characters.": "New password must be at least 6 characters.",
|
||||
"Password updated successfully.": "Password updated successfully."
|
||||
}
|
||||
@@ -109,7 +109,7 @@
|
||||
"Admin": "管理员",
|
||||
"User": "普通用户",
|
||||
"WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?": "警告:确定要永久删除该用户以及TA上传的所有文件和切片吗?",
|
||||
"CuraEngine Configurations": "CuraEngine 配置",
|
||||
"SliceEngine Configurations": "切片引擎配置",
|
||||
"Plater Origin Offset X (mm)": "构建板原点偏移 X (mm)",
|
||||
"Adjust the X-axis compilation offset for combined files on the build plate.": "调整多文件在构建板合并切片时的X坐标偏移。",
|
||||
"Plater Origin Offset Y (mm)": "构建板原点偏移 Y (mm)",
|
||||
@@ -119,5 +119,163 @@
|
||||
"Default Support": "默认支撑类型",
|
||||
"Default Support Type": "默认支撑图案",
|
||||
"Default Quality Profile": "默认质量配置",
|
||||
"Save Settings": "保存设置"
|
||||
"Save Settings": "保存设置",
|
||||
"You are editing a composite model. The existing composite will be updated and re-sliced. Continue?": "您正在编辑合并模型。现有的组合将被更新并重新切片。继续吗?",
|
||||
"Select": "选择",
|
||||
"OctoPrint settings updated": "OctoPrint 设置已更新",
|
||||
"Settings updated successfully": "设置更新成功",
|
||||
"Print starting! Going to dashboard...": "打印开始!前往仪表板...",
|
||||
"Are you sure you want to delete this file?": "您确定要删除此文件吗?",
|
||||
"OctoPrint Base URL": "OctoPrint 基础 URL",
|
||||
"Temperatures": "温度",
|
||||
"Notice": "注意",
|
||||
"Please upload valid .stl files!": "请上传有效的 .stl 文件!",
|
||||
"Print Time:": "打印时间:",
|
||||
"System Configuration": "系统配置",
|
||||
"Command": "命令",
|
||||
"Time Left:": "剩余时间:",
|
||||
"Yes": "是",
|
||||
"Current State": "当前状态",
|
||||
"Error updating settings": "更新设置时出错",
|
||||
"Available Files on Printer": "打印机上的可用文件",
|
||||
"Control failed: ": "控制失败: ",
|
||||
"Loading webcam stream...": "正在加载摄像头流...",
|
||||
"Live Webcam": "实时摄像头",
|
||||
"No printable files found. Go slice some G-Code first!": "未找到可打印文件。请先切片一些 G-Code!",
|
||||
"Save Connection Settings": "保存连接设置",
|
||||
"Validation Failed": "验证失败",
|
||||
"Cancel Print": "取消打印",
|
||||
"Send this file to print immediately?": "立即发送此文件进行打印?",
|
||||
"Print Now": "立即打印",
|
||||
"OctoPrint Panel (Embedded)": "OctoPrint 面板 (嵌入)",
|
||||
"Are you sure you want to perform this action?": "您确定要执行此操作吗?",
|
||||
"Control": "控制",
|
||||
"Cancel": "取消",
|
||||
"sent.": "已发送。",
|
||||
"The local IP address or hostname of your OctoPrint server.": "您的 OctoPrint 服务器的本地 IP 地址或主机名。",
|
||||
"OctoPrint Panel": "OctoPrint 面板",
|
||||
"General Operations": "通用操作",
|
||||
"Confirm": "确认",
|
||||
"Basic Control": "基本控制",
|
||||
"Size:": "大小:",
|
||||
"Prepare Print": "准备打印",
|
||||
"API Key / Application Key": "API 密钥 / 应用程序密钥",
|
||||
"Configuration Required:": "需要配置:",
|
||||
"Paste API Key here": "在此处粘贴 API 密钥",
|
||||
"Tool/Nozzle": "工具/喷嘴",
|
||||
"Pause/Resume": "暂停/恢复",
|
||||
"Can be found in OctoPrint Settings -> Application Keys or API.": "可以在 OctoPrint 设置 -> 应用程序密钥或 API 中找到。",
|
||||
"Preview": "预览",
|
||||
"Pause": "暂停",
|
||||
"Bed": "热床",
|
||||
"Printer Status": "打印机状态",
|
||||
"Printer": "打印机",
|
||||
"Admin / OctoPrint": "管理员 / OctoPrint",
|
||||
"Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like \"/home/pi/.octoprint/uploads\"). Leave empty to use system default.": "保存本地切片 GCode 文件的绝对路径(例如 OctoPrint 的 uploads 文件夹:\"/home/pi/.octoprint/uploads\")。留空以使用系统默认值。",
|
||||
"Custom GCode Output Folder": "自定义 GCode 输出文件夹",
|
||||
"Go to Configuration": "前往配置",
|
||||
"OK": "确定",
|
||||
"Connection Settings": "连接设置",
|
||||
"OctoPrint Configuration": "OctoPrint 配置",
|
||||
"The OctoPrint URL is not set. Please go to the ": "未设置 OctoPrint URL。请前往",
|
||||
"Go to Print": "前往打印",
|
||||
"Time:": "时间:",
|
||||
"Are you sure you want to cancel the print?": "您确定要取消打印吗?",
|
||||
"Uploading and linking GCode...": "正在上传并链接 GCode...",
|
||||
"Active Print Job": "当前打印任务",
|
||||
"Upload External GCode": "上传外部 GCode",
|
||||
"Slicer": "切片软件",
|
||||
"System Config": "系统配置",
|
||||
"Home All Axes": "全部轴归零",
|
||||
"Pause/Resume Print": "暂停/恢复打印",
|
||||
"Slice": "切片",
|
||||
"Network error": "网络错误",
|
||||
"Printer Control": "打印机控制",
|
||||
"Error saving settings": "保存设置时出错",
|
||||
"page to set it up.": "页面进行设置。",
|
||||
"Network Error: ": "网络错误: ",
|
||||
"3D Model Files (STL)": "3D 模型文件 (STL)",
|
||||
"You have uploaded": "您已上传",
|
||||
"Total Space Used": "占用空间",
|
||||
"Sliced Files (GCode)": "已切片文件 (GCode)",
|
||||
"You have sliced or uploaded": "您已切片或上传",
|
||||
"Default Storage Quotas (MB)": "默认存储配额 (MB)",
|
||||
"Guest STL Quota": "访客 STL 配额",
|
||||
"Unlimited": "无限制",
|
||||
"Guest GCode Quota": "访客 GCode 配额",
|
||||
"New User STL Quota": "新用户 STL 配额",
|
||||
"New User GCode Quota": "新用户 GCode 配额",
|
||||
"Quota": "限额",
|
||||
"GCode Storage Quota Exceeded. Please delete some files first.": "GCode 存储配额已超限,请先删除部分旧文件。",
|
||||
"Edit Quota": "编辑配额",
|
||||
"Edit Quota for": "编辑配额用于",
|
||||
"Reset Password": "重置密码",
|
||||
"Reset Password for": "重置密码 用户:",
|
||||
"Save": "保存",
|
||||
"STL Quota": "STL 配额",
|
||||
"GCode Quota": "GCode 配额",
|
||||
"New Password": "新密码",
|
||||
"Add User": "添加用户",
|
||||
"Password": "密码",
|
||||
"Is Admin": "设为管理员",
|
||||
"Create User": "创建用户",
|
||||
"Build Plate Model Path (.stl)": "构建板模型路径 (.stl)",
|
||||
"Absolute path to a custom build plate STL model to show in the plater. Leave empty to use none.": "保存自定义构建板 STL 模型的绝对路径,以在 plater 中显示。留空以使用默认值。",
|
||||
"Default Material Profile": "默认材料配置",
|
||||
"Slicing Engine Configurations": "切片引擎配置",
|
||||
"Slicing Engine": "切片引擎",
|
||||
"Select the engine to be used globally. Ensure the selected engine is installed and accessible on the server.": "选择要全局使用的引擎。确保所选引擎已安装且在服务器上可访问。",
|
||||
"Material Profile": "材料配置",
|
||||
"Custom": "自定义",
|
||||
"Skirt/Brim": "裙边",
|
||||
"Support material": "支撑材料",
|
||||
"Perimeter": "轮廓",
|
||||
"External perimeter": "外部轮廓",
|
||||
"Solid infill": "实体填充",
|
||||
"Overhang perimeter": "悬垂轮廓",
|
||||
"Internal infill": "内部填充",
|
||||
"Bridge infill": "桥接填充",
|
||||
"Top solid infill": "顶部实体填充",
|
||||
"Others": "其他",
|
||||
"Are you sure you want to clear the board?": "您确定要清空构建板吗?",
|
||||
"d": "天",
|
||||
"h": "时",
|
||||
"m": "分",
|
||||
"s": "秒",
|
||||
"Auto Leveling": "自动调平",
|
||||
"Account Management": "账号管理",
|
||||
"Change Password": "修改密码",
|
||||
"Current Password": "当前密码",
|
||||
"Confirm New Password": "确认新密码",
|
||||
"Update Password": "更新密码",
|
||||
"Active Sessions": "活跃会话",
|
||||
"Device": "设备",
|
||||
"IP Address": "IP 地址",
|
||||
"Last Active": "最后活跃",
|
||||
"Action": "操作",
|
||||
"This Device": "当前设备",
|
||||
"Unknown Device": "未知设备",
|
||||
"Are you sure you want to terminate this session?": "您确定要终止此会话吗?",
|
||||
"Logout from this device?": "从此设备注销?",
|
||||
"No active sessions found.": "未找到活跃的会话。",
|
||||
"Please login to view the webcam stream.": "请登录以查看实时摄像头。",
|
||||
"Remember Me": "记住我",
|
||||
"Merge Guest Data": "合并访客数据",
|
||||
"Main configuration for the printer dimensions, limits and base profiles.": "打印机尺寸、限制和基础配置的主要配置。",
|
||||
"API Keys Management": "API 密钥管理",
|
||||
"Create New API Key": "创建新的 API 密钥",
|
||||
"Key Name": "密钥名称",
|
||||
"Generate Key": "生成密钥",
|
||||
"Are you sure you want to delete this API Key?": "您确定要删除此 API 密钥吗?",
|
||||
"API Key Name": "API 密钥名称",
|
||||
"No API keys found.": "未找到 API 密钥。",
|
||||
"API Keys": "API 密钥",
|
||||
"Slice Helper": "切片助手",
|
||||
"Printer Helper": "打印助手",
|
||||
"For security reasons, please change your default admin password.": "出于安全原因,请修改您的默认管理员密码。",
|
||||
"Your new password cannot be the default \"admin123\".": "新密码不能设置为系统默认的 \"admin123\"。",
|
||||
"Current password is incorrect.": "当前密码不正确。",
|
||||
"New passwords do not match.": "新密码不匹配。",
|
||||
"New password must be at least 6 characters.": "新密码必须至少6个字符。",
|
||||
"Password updated successfully.": "密码更新成功。"
|
||||
}
|
||||
BIN
app/assets/img/favicon.ico
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
app/assets/img/favicon.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
app/assets/img/logo.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
app/assets/img/slice_helper/account-management_de.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
app/assets/img/slice_helper/account-management_en.png
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
app/assets/img/slice_helper/account-management_zh-cn.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
app/assets/img/slice_helper/api-keys_de.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
app/assets/img/slice_helper/api-keys_en.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
app/assets/img/slice_helper/api-keys_zh-cn.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
app/assets/img/slice_helper/home_de.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
app/assets/img/slice_helper/home_en.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
app/assets/img/slice_helper/home_zh-cn.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
app/assets/img/slice_helper/my-files_de.png
Normal file
|
After Width: | Height: | Size: 297 KiB |
BIN
app/assets/img/slice_helper/my-files_en.png
Normal file
|
After Width: | Height: | Size: 291 KiB |
BIN
app/assets/img/slice_helper/my-files_zh-cn.png
Normal file
|
After Width: | Height: | Size: 297 KiB |
BIN
app/assets/img/slice_helper/plater_de.png
Normal file
|
After Width: | Height: | Size: 410 KiB |
BIN
app/assets/img/slice_helper/plater_en.png
Normal file
|
After Width: | Height: | Size: 404 KiB |
BIN
app/assets/img/slice_helper/plater_zh-cn.png
Normal file
|
After Width: | Height: | Size: 407 KiB |
BIN
app/assets/img/slice_helper/slice-helper_de.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
app/assets/img/slice_helper/slice-helper_en.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
app/assets/img/slice_helper/slice-helper_zh-cn.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
app/assets/img/slice_helper/system-settings_de.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
app/assets/img/slice_helper/system-settings_en.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
app/assets/img/slice_helper/system-settings_zh-cn.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
app/assets/img/slice_helper/user-management_de.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
app/assets/img/slice_helper/user-management_en.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
app/assets/img/slice_helper/user-management_zh-cn.png
Normal file
|
After Width: | Height: | Size: 344 KiB |
@@ -1,371 +0,0 @@
|
||||
( function () {
|
||||
|
||||
/**
|
||||
* Description: A THREE loader for STL ASCII files, as created by Solidworks and other CAD programs.
|
||||
*
|
||||
* Supports both binary and ASCII encoded files, with automatic detection of type.
|
||||
*
|
||||
* The loader returns a non-indexed buffer geometry.
|
||||
*
|
||||
* Limitations:
|
||||
* Binary decoding supports "Magics" color format (http://en.wikipedia.org/wiki/STL_(file_format)#Color_in_binary_STL).
|
||||
* There is perhaps some question as to how valid it is to always assume little-endian-ness.
|
||||
* ASCII decoding assumes file is UTF-8.
|
||||
*
|
||||
* Usage:
|
||||
* const loader = new STLLoader();
|
||||
* loader.load( './models/stl/slotted_disk.stl', function ( geometry ) {
|
||||
* scene.add( new THREE.Mesh( geometry ) );
|
||||
* });
|
||||
*
|
||||
* For binary STLs geometry might contain colors for vertices. To use it:
|
||||
* // use the same code to load STL as above
|
||||
* if (geometry.hasColors) {
|
||||
* material = new THREE.MeshPhongMaterial({ opacity: geometry.alpha, vertexColors: true });
|
||||
* } else { .... }
|
||||
* const mesh = new THREE.Mesh( geometry, material );
|
||||
*
|
||||
* For ASCII STLs containing multiple solids, each solid is assigned to a different group.
|
||||
* Groups can be used to assign a different color by defining an array of materials with the same length of
|
||||
* geometry.groups and passing it to the Mesh constructor:
|
||||
*
|
||||
* const mesh = new THREE.Mesh( geometry, material );
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* const materials = [];
|
||||
* const nGeometryGroups = geometry.groups.length;
|
||||
*
|
||||
* const colorMap = ...; // Some logic to index colors.
|
||||
*
|
||||
* for (let i = 0; i < nGeometryGroups; i++) {
|
||||
*
|
||||
* const material = new THREE.MeshPhongMaterial({
|
||||
* color: colorMap[i],
|
||||
* wireframe: false
|
||||
* });
|
||||
*
|
||||
* }
|
||||
*
|
||||
* materials.push(material);
|
||||
* const mesh = new THREE.Mesh(geometry, materials);
|
||||
*/
|
||||
|
||||
class STLLoader extends THREE.Loader {
|
||||
|
||||
constructor( manager ) {
|
||||
|
||||
super( manager );
|
||||
|
||||
}
|
||||
|
||||
load( url, onLoad, onProgress, onError ) {
|
||||
|
||||
const scope = this;
|
||||
const loader = new THREE.FileLoader( this.manager );
|
||||
loader.setPath( this.path );
|
||||
loader.setResponseType( 'arraybuffer' );
|
||||
loader.setRequestHeader( this.requestHeader );
|
||||
loader.setWithCredentials( this.withCredentials );
|
||||
loader.load( url, function ( text ) {
|
||||
|
||||
try {
|
||||
|
||||
onLoad( scope.parse( text ) );
|
||||
|
||||
} catch ( e ) {
|
||||
|
||||
if ( onError ) {
|
||||
|
||||
onError( e );
|
||||
|
||||
} else {
|
||||
|
||||
console.error( e );
|
||||
|
||||
}
|
||||
|
||||
scope.manager.itemError( url );
|
||||
|
||||
}
|
||||
|
||||
}, onProgress, onError );
|
||||
|
||||
}
|
||||
|
||||
parse( data ) {
|
||||
|
||||
function isBinary( data ) {
|
||||
|
||||
const reader = new DataView( data );
|
||||
const face_size = 32 / 8 * 3 + 32 / 8 * 3 * 3 + 16 / 8;
|
||||
const n_faces = reader.getUint32( 80, true );
|
||||
const expect = 80 + 32 / 8 + n_faces * face_size;
|
||||
|
||||
if ( expect === reader.byteLength ) {
|
||||
|
||||
return true;
|
||||
|
||||
} // An ASCII STL data must begin with 'solid ' as the first six bytes.
|
||||
// However, ASCII STLs lacking the SPACE after the 'd' are known to be
|
||||
// plentiful. So, check the first 5 bytes for 'solid'.
|
||||
// Several encodings, such as UTF-8, precede the text with up to 5 bytes:
|
||||
// https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
|
||||
// Search for "solid" to start anywhere after those prefixes.
|
||||
// US-ASCII ordinal values for 's', 'o', 'l', 'i', 'd'
|
||||
|
||||
|
||||
const solid = [ 115, 111, 108, 105, 100 ];
|
||||
|
||||
for ( let off = 0; off < 5; off ++ ) {
|
||||
|
||||
// If "solid" text is matched to the current offset, declare it to be an ASCII STL.
|
||||
if ( matchDataViewAt( solid, reader, off ) ) return false;
|
||||
|
||||
} // Couldn't find "solid" text at the beginning; it is binary STL.
|
||||
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
function matchDataViewAt( query, reader, offset ) {
|
||||
|
||||
// Check if each byte in query matches the corresponding byte from the current offset
|
||||
for ( let i = 0, il = query.length; i < il; i ++ ) {
|
||||
|
||||
if ( query[ i ] !== reader.getUint8( offset + i, false ) ) return false;
|
||||
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
function parseBinary( data ) {
|
||||
|
||||
const reader = new DataView( data );
|
||||
const faces = reader.getUint32( 80, true );
|
||||
let r,
|
||||
g,
|
||||
b,
|
||||
hasColors = false,
|
||||
colors;
|
||||
let defaultR, defaultG, defaultB, alpha; // process STL header
|
||||
// check for default color in header ("COLOR=rgba" sequence).
|
||||
|
||||
for ( let index = 0; index < 80 - 10; index ++ ) {
|
||||
|
||||
if ( reader.getUint32( index, false ) == 0x434F4C4F
|
||||
/*COLO*/
|
||||
&& reader.getUint8( index + 4 ) == 0x52
|
||||
/*'R'*/
|
||||
&& reader.getUint8( index + 5 ) == 0x3D
|
||||
/*'='*/
|
||||
) {
|
||||
|
||||
hasColors = true;
|
||||
colors = new Float32Array( faces * 3 * 3 );
|
||||
defaultR = reader.getUint8( index + 6 ) / 255;
|
||||
defaultG = reader.getUint8( index + 7 ) / 255;
|
||||
defaultB = reader.getUint8( index + 8 ) / 255;
|
||||
alpha = reader.getUint8( index + 9 ) / 255;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const dataOffset = 84;
|
||||
const faceLength = 12 * 4 + 2;
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const vertices = new Float32Array( faces * 3 * 3 );
|
||||
const normals = new Float32Array( faces * 3 * 3 );
|
||||
|
||||
for ( let face = 0; face < faces; face ++ ) {
|
||||
|
||||
const start = dataOffset + face * faceLength;
|
||||
const normalX = reader.getFloat32( start, true );
|
||||
const normalY = reader.getFloat32( start + 4, true );
|
||||
const normalZ = reader.getFloat32( start + 8, true );
|
||||
|
||||
if ( hasColors ) {
|
||||
|
||||
const packedColor = reader.getUint16( start + 48, true );
|
||||
|
||||
if ( ( packedColor & 0x8000 ) === 0 ) {
|
||||
|
||||
// facet has its own unique color
|
||||
r = ( packedColor & 0x1F ) / 31;
|
||||
g = ( packedColor >> 5 & 0x1F ) / 31;
|
||||
b = ( packedColor >> 10 & 0x1F ) / 31;
|
||||
|
||||
} else {
|
||||
|
||||
r = defaultR;
|
||||
g = defaultG;
|
||||
b = defaultB;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
for ( let i = 1; i <= 3; i ++ ) {
|
||||
|
||||
const vertexstart = start + i * 12;
|
||||
const componentIdx = face * 3 * 3 + ( i - 1 ) * 3;
|
||||
vertices[ componentIdx ] = reader.getFloat32( vertexstart, true );
|
||||
vertices[ componentIdx + 1 ] = reader.getFloat32( vertexstart + 4, true );
|
||||
vertices[ componentIdx + 2 ] = reader.getFloat32( vertexstart + 8, true );
|
||||
normals[ componentIdx ] = normalX;
|
||||
normals[ componentIdx + 1 ] = normalY;
|
||||
normals[ componentIdx + 2 ] = normalZ;
|
||||
|
||||
if ( hasColors ) {
|
||||
|
||||
colors[ componentIdx ] = r;
|
||||
colors[ componentIdx + 1 ] = g;
|
||||
colors[ componentIdx + 2 ] = b;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
geometry.setAttribute( 'position', new THREE.BufferAttribute( vertices, 3 ) );
|
||||
geometry.setAttribute( 'normal', new THREE.BufferAttribute( normals, 3 ) );
|
||||
|
||||
if ( hasColors ) {
|
||||
|
||||
geometry.setAttribute( 'color', new THREE.BufferAttribute( colors, 3 ) );
|
||||
geometry.hasColors = true;
|
||||
geometry.alpha = alpha;
|
||||
|
||||
}
|
||||
|
||||
return geometry;
|
||||
|
||||
}
|
||||
|
||||
function parseASCII( data ) {
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const patternSolid = /solid([\s\S]*?)endsolid/g;
|
||||
const patternFace = /facet([\s\S]*?)endfacet/g;
|
||||
let faceCounter = 0;
|
||||
const patternFloat = /[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source;
|
||||
const patternVertex = new RegExp( 'vertex' + patternFloat + patternFloat + patternFloat, 'g' );
|
||||
const patternNormal = new RegExp( 'normal' + patternFloat + patternFloat + patternFloat, 'g' );
|
||||
const vertices = [];
|
||||
const normals = [];
|
||||
const normal = new THREE.Vector3();
|
||||
let result;
|
||||
let groupCount = 0;
|
||||
let startVertex = 0;
|
||||
let endVertex = 0;
|
||||
|
||||
while ( ( result = patternSolid.exec( data ) ) !== null ) {
|
||||
|
||||
startVertex = endVertex;
|
||||
const solid = result[ 0 ];
|
||||
|
||||
while ( ( result = patternFace.exec( solid ) ) !== null ) {
|
||||
|
||||
let vertexCountPerFace = 0;
|
||||
let normalCountPerFace = 0;
|
||||
const text = result[ 0 ];
|
||||
|
||||
while ( ( result = patternNormal.exec( text ) ) !== null ) {
|
||||
|
||||
normal.x = parseFloat( result[ 1 ] );
|
||||
normal.y = parseFloat( result[ 2 ] );
|
||||
normal.z = parseFloat( result[ 3 ] );
|
||||
normalCountPerFace ++;
|
||||
|
||||
}
|
||||
|
||||
while ( ( result = patternVertex.exec( text ) ) !== null ) {
|
||||
|
||||
vertices.push( parseFloat( result[ 1 ] ), parseFloat( result[ 2 ] ), parseFloat( result[ 3 ] ) );
|
||||
normals.push( normal.x, normal.y, normal.z );
|
||||
vertexCountPerFace ++;
|
||||
endVertex ++;
|
||||
|
||||
} // every face have to own ONE valid normal
|
||||
|
||||
|
||||
if ( normalCountPerFace !== 1 ) {
|
||||
|
||||
console.error( 'THREE.STLLoader: Something isn\'t right with the normal of face number ' + faceCounter );
|
||||
|
||||
} // each face have to own THREE valid vertices
|
||||
|
||||
|
||||
if ( vertexCountPerFace !== 3 ) {
|
||||
|
||||
console.error( 'THREE.STLLoader: Something isn\'t right with the vertices of face number ' + faceCounter );
|
||||
|
||||
}
|
||||
|
||||
faceCounter ++;
|
||||
|
||||
}
|
||||
|
||||
const start = startVertex;
|
||||
const count = endVertex - startVertex;
|
||||
geometry.addGroup( start, count, groupCount );
|
||||
groupCount ++;
|
||||
|
||||
}
|
||||
|
||||
geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( vertices, 3 ) );
|
||||
geometry.setAttribute( 'normal', new THREE.Float32BufferAttribute( normals, 3 ) );
|
||||
return geometry;
|
||||
|
||||
}
|
||||
|
||||
function ensureString( buffer ) {
|
||||
|
||||
if ( typeof buffer !== 'string' ) {
|
||||
|
||||
return THREE.LoaderUtils.decodeText( new Uint8Array( buffer ) );
|
||||
|
||||
}
|
||||
|
||||
return buffer;
|
||||
|
||||
}
|
||||
|
||||
function ensureBinary( buffer ) {
|
||||
|
||||
if ( typeof buffer === 'string' ) {
|
||||
|
||||
const array_buffer = new Uint8Array( buffer.length );
|
||||
|
||||
for ( let i = 0; i < buffer.length; i ++ ) {
|
||||
|
||||
array_buffer[ i ] = buffer.charCodeAt( i ) & 0xff; // implicitly assumes little-endian
|
||||
|
||||
}
|
||||
|
||||
return array_buffer.buffer || array_buffer;
|
||||
|
||||
} else {
|
||||
|
||||
return buffer;
|
||||
|
||||
}
|
||||
|
||||
} // start
|
||||
|
||||
|
||||
const binData = ensureBinary( data );
|
||||
return isBinary( binData ) ? parseBinary( binData ) : parseASCII( ensureString( data ) );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
THREE.STLLoader = STLLoader;
|
||||
|
||||
} )();
|
||||
@@ -26,7 +26,22 @@ class PrintFile(db.Model):
|
||||
status = db.Column(db.String(50), default='waiting') # waiting, slicing, sliced, failed
|
||||
transform_matrix = db.Column(db.Text, nullable=True) # json format of 16-element array
|
||||
|
||||
class UserSession(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
session_token = db.Column(db.String(100), unique=True, nullable=False)
|
||||
ip_address = db.Column(db.String(50))
|
||||
user_agent = db.Column(db.String(255))
|
||||
last_active = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
|
||||
class SystemConfig(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(50), unique=True, nullable=False)
|
||||
value = db.Column(db.String(255), nullable=False)
|
||||
|
||||
class ApiKey(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(100), unique=True, nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import json
|
||||
import trimesh
|
||||
import uuid
|
||||
import os
|
||||
import configparser
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, session, make_response, send_file, abort, jsonify
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.models import db, User, PrintFile, SystemConfig
|
||||
from app.models import db, User, PrintFile, SystemConfig, ApiKey
|
||||
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
|
||||
from app import i18n_dict
|
||||
# import trimesh.repair
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
from app.utils.slice_engines import get_all_engines
|
||||
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
@@ -21,6 +21,12 @@ main_bp = Blueprint('main', __name__)
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
def get_gcode_dir():
|
||||
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
|
||||
if conf and conf.value and os.path.exists(conf.value):
|
||||
return conf.value
|
||||
return current_app.config['UPLOAD_FOLDER']
|
||||
|
||||
# Guest User Middleware
|
||||
@admin_bp.before_request
|
||||
def require_admin():
|
||||
@@ -39,6 +45,11 @@ def settings():
|
||||
default_support = request.form.get('default_support', 'false')
|
||||
default_support_pattern = request.form.get('default_support_pattern', 'tree')
|
||||
default_quality = request.form.get('default_quality', 'base_global_standard.inst.cfg')
|
||||
default_material = request.form.get('default_material', '')
|
||||
default_printer = request.form.get('default_printer', '')
|
||||
gcode_upload_folder = request.form.get('gcode_upload_folder', '').strip()
|
||||
slicer_engine = request.form.get('slicer_engine', 'cura')
|
||||
build_plate_model_path = request.form.get('build_plate_model_path', '').strip()
|
||||
|
||||
# update or create config entries
|
||||
config_items = [
|
||||
@@ -48,7 +59,16 @@ def settings():
|
||||
('default_infill', default_infill),
|
||||
('default_support', default_support),
|
||||
('default_support_pattern', default_support_pattern),
|
||||
('default_quality', default_quality)
|
||||
('default_quality', default_quality),
|
||||
('default_material', default_material),
|
||||
('default_printer', default_printer),
|
||||
('gcode_upload_folder', gcode_upload_folder),
|
||||
('slicer_engine', slicer_engine),
|
||||
('build_plate_model_path', build_plate_model_path),
|
||||
('default_guest_stl_quota_mb', request.form.get('default_guest_stl_quota_mb', '0')),
|
||||
('default_guest_gcode_quota_mb', request.form.get('default_guest_gcode_quota_mb', '0')),
|
||||
('default_user_stl_quota_mb', request.form.get('default_user_stl_quota_mb', '0')),
|
||||
('default_user_gcode_quota_mb', request.form.get('default_user_gcode_quota_mb', '0'))
|
||||
]
|
||||
for key, val in config_items:
|
||||
conf = SystemConfig.query.filter_by(key=key).first()
|
||||
@@ -63,13 +83,107 @@ def settings():
|
||||
return redirect(url_for('admin.settings'))
|
||||
|
||||
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||
presets = get_quality_presets()
|
||||
return render_template('admin/settings.html', configs=configs, presets=presets)
|
||||
engines = get_all_engines()
|
||||
return render_template('admin/settings.html', configs=configs, engines=engines)
|
||||
|
||||
@admin_bp.route('/users')
|
||||
def users():
|
||||
all_users = User.query.order_by(User.created_at.desc()).all()
|
||||
return render_template('admin/users.html', users=all_users)
|
||||
user_quotas = {}
|
||||
|
||||
# Load defaults
|
||||
def_guest_stl = SystemConfig.query.filter_by(key="default_guest_stl_quota_mb").first()
|
||||
def_guest_stl_val = def_guest_stl.value if def_guest_stl else '0'
|
||||
def_guest_gcode = SystemConfig.query.filter_by(key="default_guest_gcode_quota_mb").first()
|
||||
def_guest_gcode_val = def_guest_gcode.value if def_guest_gcode else '0'
|
||||
|
||||
def_user_stl = SystemConfig.query.filter_by(key="default_user_stl_quota_mb").first()
|
||||
def_user_stl_val = def_user_stl.value if def_user_stl else '0'
|
||||
def_user_gcode = SystemConfig.query.filter_by(key="default_user_gcode_quota_mb").first()
|
||||
def_user_gcode_val = def_user_gcode.value if def_user_gcode else '0'
|
||||
|
||||
for u in all_users:
|
||||
if u.is_admin:
|
||||
eff_stl = '0'
|
||||
eff_gcode = '0'
|
||||
elif u.is_guest:
|
||||
eff_stl = def_guest_stl_val
|
||||
eff_gcode = def_guest_gcode_val
|
||||
else:
|
||||
eff_stl = def_user_stl_val
|
||||
eff_gcode = def_user_gcode_val
|
||||
|
||||
sq = SystemConfig.query.filter_by(key=f"user_{u.id}_stl_quota_mb").first()
|
||||
gq = SystemConfig.query.filter_by(key=f"user_{u.id}_gcode_quota_mb").first()
|
||||
|
||||
user_stl = sq.value if sq else '0'
|
||||
user_gcode = gq.value if gq else '0'
|
||||
|
||||
user_quotas[u.id] = {
|
||||
'stl': user_stl,
|
||||
'gcode': user_gcode,
|
||||
'eff_stl': eff_stl if user_stl == '0' else user_stl,
|
||||
'eff_gcode': eff_gcode if user_gcode == '0' else user_gcode,
|
||||
}
|
||||
return render_template('admin/users.html', users=all_users, user_quotas=user_quotas)
|
||||
|
||||
@admin_bp.route('/user/add', methods=['POST'])
|
||||
def add_user():
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
is_admin = request.form.get('is_admin') == 'on'
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash("Username already exists", "danger")
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
u = User(username=username, is_guest=False, is_admin=is_admin)
|
||||
u.password_hash = generate_password_hash(password)
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
|
||||
# Save quotas
|
||||
stl_quota = request.form.get('stl_quota_mb', '0')
|
||||
gcode_quota = request.form.get('gcode_quota_mb', '0')
|
||||
if stl_quota != '0':
|
||||
db.session.add(SystemConfig(key=f"user_{u.id}_stl_quota_mb", value=stl_quota))
|
||||
if gcode_quota != '0':
|
||||
db.session.add(SystemConfig(key=f"user_{u.id}_gcode_quota_mb", value=gcode_quota))
|
||||
db.session.commit()
|
||||
|
||||
flash("User created.", "success")
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/quota', methods=['POST'])
|
||||
def update_quota(user_id):
|
||||
stl_quota = request.form.get('stl_quota_mb', '0')
|
||||
gcode_quota = request.form.get('gcode_quota_mb', '0')
|
||||
|
||||
def set_conf(k, v):
|
||||
c = SystemConfig.query.filter_by(key=k).first()
|
||||
if not c:
|
||||
c = SystemConfig(key=k)
|
||||
db.session.add(c)
|
||||
c.value = str(v)
|
||||
|
||||
set_conf(f"user_{user_id}_stl_quota_mb", stl_quota)
|
||||
set_conf(f"user_{user_id}_gcode_quota_mb", gcode_quota)
|
||||
db.session.commit()
|
||||
flash("Quotas updated.", "success")
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/password', methods=['POST'])
|
||||
def reset_password(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
if user.is_guest:
|
||||
flash("Cannot set password for guests.", "danger")
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
pwd = request.form.get('password')
|
||||
user.password_hash = generate_password_hash(pwd)
|
||||
db.session.commit()
|
||||
flash("Password updated.", "success")
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
|
||||
def delete_user(user_id):
|
||||
@@ -82,7 +196,7 @@ def delete_user(user_id):
|
||||
for print_file in print_files:
|
||||
stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename)
|
||||
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
gcode_path = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
gcode_path = os.path.join(get_gcode_dir(), gcode_filename)
|
||||
proxy_path = stl_path + '.proxy.stl'
|
||||
|
||||
if os.path.exists(stl_path):
|
||||
@@ -102,30 +216,32 @@ def delete_user(user_id):
|
||||
flash(f'User {user.username} and all their files have been deleted.', 'success')
|
||||
return redirect(url_for('admin.users'))
|
||||
|
||||
def get_bed_dimensions():
|
||||
try:
|
||||
path = os.path.join(current_app.root_path, '..', 'print_config', 'printers', 'creality_ender3v3se.def.json')
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
w = data['overrides']['machine_width']['default_value']
|
||||
h = data['overrides']['machine_depth']['default_value']
|
||||
hd = data['overrides']['machine_height']['default_value']
|
||||
return w, h, hd
|
||||
except:
|
||||
return 200, 200, 200
|
||||
|
||||
def get_quality_presets():
|
||||
try:
|
||||
|
||||
path = os.path.join(current_app.root_path, '..', 'print_config', 'quality', 'creality', 'presets')
|
||||
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
|
||||
presets = []
|
||||
for f in files:
|
||||
# name = f.replace('.inst.cfg', '').replace('base_', '').replace('_', ' ')
|
||||
name = f.replace('.inst.cfg', '')
|
||||
presets.append((f, name))
|
||||
presets.sort(key=lambda x: x[1])
|
||||
return presets
|
||||
except:
|
||||
return []
|
||||
|
||||
@admin_bp.route('/api_keys')
|
||||
def api_keys():
|
||||
keys = ApiKey.query.order_by(ApiKey.created_at.desc()).all()
|
||||
return render_template('admin/api_keys.html', keys=keys)
|
||||
|
||||
@admin_bp.route('/api_key/add', methods=['POST'])
|
||||
def add_api_key():
|
||||
name = request.form.get('name')
|
||||
if not name:
|
||||
flash("Name is required", "danger")
|
||||
return redirect(url_for('admin.api_keys'))
|
||||
|
||||
key_value = secrets.token_urlsafe(32)
|
||||
new_key = ApiKey(name=name, key=key_value)
|
||||
db.session.add(new_key)
|
||||
db.session.commit()
|
||||
flash(f"API Key '{name}' created.", "success")
|
||||
return redirect(url_for('admin.api_keys'))
|
||||
|
||||
@admin_bp.route('/api_key/<int:key_id>/delete', methods=['POST'])
|
||||
def delete_api_key(key_id):
|
||||
key = ApiKey.query.get_or_404(key_id)
|
||||
db.session.delete(key)
|
||||
db.session.commit()
|
||||
flash(f'API Key {key.name} deleted.', 'success')
|
||||
return redirect(url_for('admin.api_keys'))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
import trimesh
|
||||
import uuid
|
||||
import os
|
||||
import configparser
|
||||
@@ -8,11 +7,12 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.models import db, User, PrintFile, SystemConfig
|
||||
from app.models import db, User, PrintFile, SystemConfig, UserSession
|
||||
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
|
||||
from app import i18n_dict
|
||||
# import trimesh.repair
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
from app.routes.main_routes import get_quota_info
|
||||
from app.routes.admin_routes import get_gcode_dir
|
||||
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
@@ -28,16 +28,119 @@ def login():
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
user = User.query.filter_by(username=username, is_guest=False).first()
|
||||
|
||||
remember = bool(request.form.get('remember'))
|
||||
merge_data = bool(request.form.get('merge_data'))
|
||||
|
||||
|
||||
|
||||
if user and check_password_hash(user.password_hash, password):
|
||||
login_user(user)
|
||||
return redirect(url_for('main.index'))
|
||||
# Clear old password check flag
|
||||
session.pop('pwd_check_done', None)
|
||||
session.pop('must_change_password', None)
|
||||
login_user(user, remember=remember)
|
||||
session_token = str(uuid.uuid4())
|
||||
# 尝试获取反向代理传递的真实 IP
|
||||
client_ip = request.headers.get('X-Real-IP')
|
||||
if not client_ip:
|
||||
client_ip = request.remote_addr
|
||||
|
||||
user_session = UserSession(
|
||||
user_id=user.id,
|
||||
session_token=session_token,
|
||||
ip_address=client_ip,
|
||||
user_agent=request.user_agent.string
|
||||
)
|
||||
db.session.add(user_session)
|
||||
db.session.commit()
|
||||
session['user_session_token'] = session_token
|
||||
|
||||
if merge_data:
|
||||
guest_id = request.cookies.get('guest_id')
|
||||
if guest_id:
|
||||
guest_user = User.query.filter_by(guest_cookie_id=guest_id, is_guest=True).first()
|
||||
if guest_user:
|
||||
guest_files = PrintFile.query.filter_by(user_id=guest_user.id).all()
|
||||
|
||||
stl_quota, stl_used = get_quota_info(user, 'stl')
|
||||
gcode_quota, gcode_used = get_quota_info(user, 'gcode')
|
||||
|
||||
stl_quota_bytes = stl_quota * 1024 * 1024 if stl_quota > 0 else float('inf')
|
||||
gcode_quota_bytes = gcode_quota * 1024 * 1024 if gcode_quota > 0 else float('inf')
|
||||
|
||||
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'uploads')
|
||||
gcode_dir = get_gcode_dir()
|
||||
|
||||
for pf in guest_files:
|
||||
file_size = 0
|
||||
file_type = 'stl'
|
||||
is_external_gcode = pf.original_filename.lower().endswith(('.gcode', '.gco', '.g'))
|
||||
if is_external_gcode or pf.status == 'sliced':
|
||||
file_type = 'gcode'
|
||||
g_filename = pf.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
path = os.path.join(gcode_dir, g_filename)
|
||||
if os.path.exists(path):
|
||||
file_size = os.path.getsize(path)
|
||||
else:
|
||||
p2 = os.path.join(upload_dir, g_filename)
|
||||
if os.path.exists(p2): file_size = os.path.getsize(p2)
|
||||
else:
|
||||
path = os.path.join(upload_dir, pf.filename)
|
||||
if os.path.exists(path):
|
||||
file_size = os.path.getsize(path)
|
||||
|
||||
# Check quota
|
||||
can_merge = True
|
||||
if not user.is_admin:
|
||||
if file_type == 'stl' and (stl_used + file_size > stl_quota_bytes):
|
||||
can_merge = False
|
||||
elif file_type == 'gcode' and (gcode_used + file_size > gcode_quota_bytes):
|
||||
can_merge = False
|
||||
|
||||
if can_merge:
|
||||
pf.user_id = user.id
|
||||
if file_type == 'stl': stl_used += file_size
|
||||
else: gcode_used += file_size
|
||||
else:
|
||||
# delete from disk to prevent orphans
|
||||
stl_path = os.path.join(upload_dir, pf.filename)
|
||||
proxy_path = stl_path + '.proxy.stl'
|
||||
gcode_filename = pf.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
gp = os.path.join(gcode_dir, gcode_filename)
|
||||
fp = os.path.join(upload_dir, gcode_filename)
|
||||
if os.path.exists(stl_path): os.remove(stl_path)
|
||||
if os.path.exists(proxy_path): os.remove(proxy_path)
|
||||
if os.path.exists(gp): os.remove(gp)
|
||||
if os.path.exists(fp): os.remove(fp)
|
||||
db.session.delete(pf)
|
||||
|
||||
# Save changes to files first so SQLAlchemy doesn't try to nullify related keys
|
||||
db.session.commit()
|
||||
|
||||
# Delete guest user after merge
|
||||
db.session.delete(guest_user)
|
||||
db.session.commit()
|
||||
|
||||
response = make_response(redirect(url_for('main.index')))
|
||||
if merge_data:
|
||||
response.delete_cookie('guest_id')
|
||||
return response
|
||||
flash('Invalid username or password', 'danger')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
session_token = session.get('user_session_token')
|
||||
if session_token:
|
||||
user_session = UserSession.query.filter_by(session_token=session_token).first()
|
||||
if user_session:
|
||||
user_session.is_active = False
|
||||
db.session.commit()
|
||||
logout_user()
|
||||
session.pop('user_session_token', None)
|
||||
|
||||
response = make_response(redirect(url_for('main.index')))
|
||||
response.delete_cookie('guest_id') # Optionally clear guest cookie
|
||||
return response
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
import trimesh
|
||||
import uuid
|
||||
import os
|
||||
import configparser
|
||||
@@ -8,22 +7,119 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.models import db, User, PrintFile, SystemConfig
|
||||
from app.models import db, User, PrintFile, SystemConfig, UserSession
|
||||
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
|
||||
from app import i18n_dict
|
||||
# import trimesh.repair
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
|
||||
from app.routes.admin_routes import get_gcode_dir
|
||||
from app.utils.slice_engines import get_slicer_engine
|
||||
from app.utils.gcode_parser import get_gcode_metadata
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@main_bp.before_app_request
|
||||
def check_user_session():
|
||||
if current_user.is_authenticated and not current_user.is_guest:
|
||||
session_token = session.get('user_session_token')
|
||||
client_ip = request.headers.get('X-Real-IP') or request.remote_addr
|
||||
|
||||
if session_token:
|
||||
user_session = UserSession.query.filter_by(session_token=session_token).first()
|
||||
if not user_session or not user_session.is_active:
|
||||
logout_user()
|
||||
session.pop('user_session_token', None)
|
||||
flash('Your session has been terminated.', 'warning')
|
||||
return redirect(url_for('auth.login'))
|
||||
else:
|
||||
user_session.last_active = datetime.utcnow()
|
||||
user_session.ip_address = client_ip
|
||||
db.session.commit()
|
||||
else:
|
||||
# Re-authenticated via remember me, but no session token
|
||||
new_session_token = str(uuid.uuid4())
|
||||
user_session = UserSession(
|
||||
user_id=current_user.id,
|
||||
session_token=new_session_token,
|
||||
ip_address=client_ip,
|
||||
user_agent=request.user_agent.string,
|
||||
last_active=datetime.utcnow()
|
||||
)
|
||||
db.session.add(user_session)
|
||||
db.session.commit()
|
||||
session['user_session_token'] = new_session_token
|
||||
|
||||
# Check default admin password securely without checking hash on every request
|
||||
if current_user.is_admin:
|
||||
if session.get('pwd_check_done') is None:
|
||||
session['pwd_check_done'] = True
|
||||
if check_password_hash(current_user.password_hash, 'admin123'):
|
||||
session['must_change_password'] = True
|
||||
else:
|
||||
session.pop('must_change_password', None)
|
||||
|
||||
if session.get('must_change_password'):
|
||||
if request.endpoint and request.endpoint not in ['main.account', 'auth.logout', 'static']:
|
||||
flash('For security reasons, please change your default admin password.', 'warning')
|
||||
return redirect(url_for('main.account'))
|
||||
|
||||
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
|
||||
def get_quota_info(user, file_type):
|
||||
# Returns (quota_mb, current_size_bytes)
|
||||
if user.is_admin:
|
||||
quota_mb = 0.0
|
||||
else:
|
||||
conf = SystemConfig.query.filter_by(key=f"user_{user.id}_{file_type}_quota_mb").first()
|
||||
quota_mb = float(conf.value) if conf else 0.0
|
||||
|
||||
if quota_mb == 0.0:
|
||||
if user.is_guest:
|
||||
def_conf = SystemConfig.query.filter_by(key=f"default_guest_{file_type}_quota_mb").first()
|
||||
else:
|
||||
def_conf = SystemConfig.query.filter_by(key=f"default_user_{file_type}_quota_mb").first()
|
||||
quota_mb = float(def_conf.value) if def_conf else 0.0
|
||||
|
||||
user_files = PrintFile.query.filter_by(user_id=user.id).all()
|
||||
current_size = 0
|
||||
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'uploads')
|
||||
gcode_dir = get_gcode_dir()
|
||||
|
||||
for pf in user_files:
|
||||
if file_type == 'stl' and not pf.original_filename.lower().endswith(('.gcode', '.gco', '.g')):
|
||||
path = os.path.join(upload_dir, pf.filename)
|
||||
if os.path.exists(path):
|
||||
current_size += os.path.getsize(path)
|
||||
elif file_type == 'gcode':
|
||||
g_filename = pf.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
path = os.path.join(gcode_dir, g_filename)
|
||||
if os.path.exists(path):
|
||||
current_size += os.path.getsize(path)
|
||||
else:
|
||||
p2 = os.path.join(upload_dir, g_filename)
|
||||
if os.path.exists(p2): current_size += os.path.getsize(p2)
|
||||
|
||||
return quota_mb, current_size
|
||||
|
||||
def check_quota(user, file_type, size_bytes):
|
||||
if user.is_admin:
|
||||
return True
|
||||
quota_mb, current_size = get_quota_info(user, file_type)
|
||||
if quota_mb <= 0.0:
|
||||
return True
|
||||
if current_size + size_bytes > quota_mb * 1024 * 1024:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Guest User Middleware
|
||||
@main_bp.before_app_request
|
||||
def assign_guest_cookie():
|
||||
if request.path.startswith('/api/'):
|
||||
return
|
||||
if not current_user.is_authenticated:
|
||||
guest_id = request.cookies.get('guest_id')
|
||||
if not guest_id:
|
||||
@@ -48,8 +144,68 @@ def set_guest_cookie(response):
|
||||
# --- Main Routes ---
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
return render_template('slice/index.html')
|
||||
user_files = PrintFile.query.filter((PrintFile.user_id == current_user.id) | (current_user.is_admin)).all() if current_user.is_admin else PrintFile.query.filter_by(user_id=current_user.id).all()
|
||||
|
||||
stl_count = 0
|
||||
stl_size = 0
|
||||
gcode_count = 0
|
||||
gcode_size = 0
|
||||
|
||||
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'uploads')
|
||||
gcode_dir = get_gcode_dir()
|
||||
|
||||
for f in user_files:
|
||||
is_external_gcode = f.original_filename.lower().endswith(('.gcode', '.gco', '.g'))
|
||||
|
||||
if is_external_gcode:
|
||||
gcode_count += 1
|
||||
gcode_path = os.path.join(gcode_dir, f.filename.replace('.stl', '.gcode'))
|
||||
if os.path.exists(gcode_path):
|
||||
gcode_size += os.path.getsize(gcode_path)
|
||||
else:
|
||||
stl_count += 1
|
||||
stl_path = os.path.join(upload_dir, f.filename)
|
||||
if os.path.exists(stl_path):
|
||||
stl_size += os.path.getsize(stl_path)
|
||||
|
||||
if f.status == 'sliced':
|
||||
gcode_count += 1
|
||||
gcode_filename = f.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
gcode_path = os.path.join(gcode_dir, gcode_filename)
|
||||
if os.path.exists(gcode_path):
|
||||
gcode_size += os.path.getsize(gcode_path)
|
||||
else:
|
||||
gcode_fallback = os.path.join(upload_dir, gcode_filename)
|
||||
if os.path.exists(gcode_fallback):
|
||||
gcode_size += os.path.getsize(gcode_fallback)
|
||||
|
||||
def format_size(size_bytes):
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.2f} KB"
|
||||
elif size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.2f} MB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
|
||||
|
||||
|
||||
stl_quota_mb, stl_used_bytes = get_quota_info(current_user, 'stl')
|
||||
gcode_quota_mb, gcode_used_bytes = get_quota_info(current_user, 'gcode')
|
||||
|
||||
return render_template('slice/index.html',
|
||||
stl_count=stl_count,
|
||||
stl_size_str=format_size(stl_size),
|
||||
gcode_count=gcode_count,
|
||||
gcode_size_str=format_size(gcode_size),
|
||||
stl_used_bytes=stl_used_bytes,
|
||||
stl_quota_mb=stl_quota_mb,
|
||||
gcode_used_bytes=gcode_used_bytes,
|
||||
gcode_quota_mb=gcode_quota_mb,
|
||||
format_size=format_size
|
||||
)
|
||||
|
||||
@main_bp.route('/set_language/<lang>')
|
||||
def set_language(lang):
|
||||
@@ -78,6 +234,15 @@ def files():
|
||||
if file.filename == '':
|
||||
continue
|
||||
if file and file.filename.lower().endswith('.stl'):
|
||||
file.seek(0, os.SEEK_END)
|
||||
size_bytes = file.tell()
|
||||
file.seek(0, os.SEEK_SET)
|
||||
if not check_quota(current_user, 'stl', size_bytes):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'error': 'STL Storage Quota Exceeded'}), 400
|
||||
flash('STL Storage Quota Exceeded', 'danger')
|
||||
continue
|
||||
|
||||
original_filename = file.filename # Do not use secure_filename to keep Chinese characters
|
||||
ext = os.path.splitext(original_filename)[1].lower()
|
||||
if not ext:
|
||||
@@ -133,7 +298,12 @@ def download_gcode(file_id):
|
||||
return redirect(url_for('main.files'))
|
||||
|
||||
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
|
||||
gcode_dir = conf.value if (conf and conf.value and os.path.exists(conf.value)) else current_app.config['UPLOAD_FOLDER']
|
||||
filepath = os.path.join(gcode_dir, gcode_filename)
|
||||
if not os.path.exists(filepath):
|
||||
fallback = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
if os.path.exists(fallback): filepath = fallback
|
||||
|
||||
if os.path.exists(filepath):
|
||||
safe_name = print_file.original_filename.rsplit('.', 1)[0] + '.gcode'
|
||||
@@ -149,11 +319,26 @@ def preview_gcode(file_id):
|
||||
abort(403)
|
||||
|
||||
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
|
||||
gcode_dir = conf.value if (conf and conf.value and os.path.exists(conf.value)) else current_app.config['UPLOAD_FOLDER']
|
||||
filepath = os.path.join(gcode_dir, gcode_filename)
|
||||
if not os.path.exists(filepath):
|
||||
fallback = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
if os.path.exists(fallback): filepath = fallback
|
||||
|
||||
content = "File not found or not ready."
|
||||
line_count = 0
|
||||
|
||||
time_info = "-"
|
||||
layer1_time = "-"
|
||||
filament_used = "-"
|
||||
|
||||
if os.path.exists(filepath):
|
||||
metadata = get_gcode_metadata(filepath)
|
||||
time_info = metadata.get('print_time', '-')
|
||||
layer1_time = metadata.get('first_layer_time', '-')
|
||||
filament_used = metadata.get('filament_used', '-')
|
||||
|
||||
with open(filepath, 'r') as f:
|
||||
lines = f.readlines()
|
||||
line_count = len(lines)
|
||||
@@ -161,11 +346,15 @@ def preview_gcode(file_id):
|
||||
if line_count > 500:
|
||||
content += f"\n... \n[Preview truncated. Total lines: {line_count}. Please download to view full file.]"
|
||||
|
||||
w, h, hd = get_bed_dimensions()
|
||||
engine_name = SystemConfig.query.filter_by(key='slicer_engine').first()
|
||||
if engine_name:
|
||||
engine = get_slicer_engine(str(engine_name.value), current_app.config['PRINT_CONFIG_FOLDER'], current_app.config['PRUSA_SLICE_BIN'])
|
||||
w, h, hd = engine.get_bed_dimensions()
|
||||
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||
offset_x = float(configs.get('offset_x', '0.0'))
|
||||
offset_y = float(configs.get('offset_y', '0.0'))
|
||||
return render_template('slice/gcode_preview.html', file=print_file, content=content, line_count=line_count, machine_width=w, machine_depth=h, machine_height=hd, offset_x=offset_x, offset_y=offset_y)
|
||||
return render_template('slice/gcode_preview.html', file=print_file, content=content, line_count=line_count, machine_width=w, machine_depth=h, machine_height=hd, offset_x=offset_x, offset_y=offset_y,
|
||||
time_info=time_info, layer1_time=layer1_time, filament_used=filament_used)
|
||||
|
||||
@main_bp.route('/delete_file/<int:file_id>', methods=['POST'])
|
||||
@login_required
|
||||
@@ -176,7 +365,11 @@ def delete_file(file_id):
|
||||
|
||||
stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename)
|
||||
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
gcode_path = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
|
||||
gcode_dir = conf.value if (conf and conf.value and os.path.exists(conf.value)) else current_app.config['UPLOAD_FOLDER']
|
||||
gcode_path = os.path.join(gcode_dir, gcode_filename)
|
||||
fallback_gcode = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
|
||||
proxy_path = stl_path + '.proxy.stl'
|
||||
|
||||
if os.path.exists(stl_path):
|
||||
@@ -185,6 +378,8 @@ def delete_file(file_id):
|
||||
os.remove(proxy_path)
|
||||
if os.path.exists(gcode_path):
|
||||
os.remove(gcode_path)
|
||||
if os.path.exists(fallback_gcode):
|
||||
os.remove(fallback_gcode)
|
||||
|
||||
db.session.delete(print_file)
|
||||
db.session.commit()
|
||||
@@ -193,38 +388,18 @@ def delete_file(file_id):
|
||||
|
||||
# --- Auth Routes ---
|
||||
|
||||
def get_bed_dimensions():
|
||||
try:
|
||||
path = os.path.join(current_app.root_path, '..', 'print_config', 'printers', 'creality_ender3v3se.def.json')
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
w = data['overrides']['machine_width']['default_value']
|
||||
h = data['overrides']['machine_depth']['default_value']
|
||||
hd = data['overrides']['machine_height']['default_value']
|
||||
return w, h, hd
|
||||
except:
|
||||
return 200, 200, 200
|
||||
|
||||
def get_quality_presets():
|
||||
try:
|
||||
|
||||
path = os.path.join(current_app.root_path, '..', 'print_config', 'quality', 'creality', 'presets')
|
||||
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
|
||||
presets = []
|
||||
for f in files:
|
||||
# name = f.replace('.inst.cfg', '').replace('base_', '').replace('_', ' ')
|
||||
name = f.replace('.inst.cfg', '')
|
||||
presets.append((f, name))
|
||||
presets.sort(key=lambda x: x[1])
|
||||
return presets
|
||||
except:
|
||||
return []
|
||||
|
||||
@main_bp.route('/plater')
|
||||
@login_required
|
||||
def plater():
|
||||
w, h, hd = get_bed_dimensions()
|
||||
presets = get_quality_presets()
|
||||
quota_mb, current_size = get_quota_info(current_user, 'gcode')
|
||||
quota_exceeded = (quota_mb > 0 and current_size >= quota_mb * 1024 * 1024)
|
||||
|
||||
engine_name = SystemConfig.query.filter_by(key='slicer_engine').first()
|
||||
if engine_name:
|
||||
engine = get_slicer_engine(str(engine_name.value), current_app.config['PRINT_CONFIG_FOLDER'], current_app.config['PRUSA_SLICE_BIN'])
|
||||
w, h, hd = engine.get_bed_dimensions()
|
||||
print(f"Bed dimensions: {w}x{h}x{hd}")
|
||||
|
||||
|
||||
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||
offset_x = float(configs.get('offset_x', '0.0'))
|
||||
@@ -234,10 +409,32 @@ def plater():
|
||||
default_support = configs.get('default_support', 'false')
|
||||
default_support_pattern = configs.get('default_support_pattern', 'tree')
|
||||
default_quality = configs.get('default_quality', 'base_global_standard.inst.cfg')
|
||||
default_material = configs.get('default_material', '')
|
||||
|
||||
user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all()
|
||||
models = [{'id': f.id, 'name': f.original_filename, 'status': f.status, 'url': url_for('main.serve_proxy_file', file_id=f.id), 'transform_matrix': f.transform_matrix} for f in user_files]
|
||||
return render_template('slice/plater.html', w=w, h=h, hd=hd, presets=presets, last_quality=default_quality, models=models, offset_x=offset_x, offset_y=offset_y, default_infill=default_infill, default_support=default_support, default_support_pattern=default_support_pattern)
|
||||
return render_template('slice/plater.html', w=w, h=h, hd=hd, last_quality=default_quality, last_material=default_material, models=models, offset_x=offset_x, offset_y=offset_y, default_infill=default_infill, default_support=default_support, default_support_pattern=default_support_pattern, quota_exceeded=quota_exceeded, configs=configs)
|
||||
|
||||
|
||||
import re
|
||||
import markdown
|
||||
|
||||
@main_bp.route('/helper_slice')
|
||||
def helper_slice():
|
||||
lang = request.cookies.get('lang', 'en')
|
||||
filepath = os.path.join(current_app.root_path, 'assets', 'doc', f'slice_helper_{lang}.md')
|
||||
if not os.path.exists(filepath):
|
||||
filepath = os.path.join(current_app.root_path, 'assets', 'doc', 'slice_helper_en.md')
|
||||
|
||||
content_html = ""
|
||||
if os.path.exists(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
md_text = f.read()
|
||||
content_html = markdown.markdown(md_text, extensions=['fenced_code', 'tables'])
|
||||
# Rewrite relative image links to /assets/doc/
|
||||
content_html = re.sub(r'src="(?!http|/)([^"]+)"', r'src="/assets/doc/\1"', content_html)
|
||||
|
||||
return render_template('slice/helper_slice.html', content_html=content_html)
|
||||
|
||||
@main_bp.route('/file/<int:file_id>')
|
||||
@login_required
|
||||
@@ -263,9 +460,13 @@ def serve_proxy_file(file_id):
|
||||
@main_bp.route('/api/merge_and_slice', methods=['POST'])
|
||||
@login_required
|
||||
def merge_and_slice():
|
||||
quota_mb, current_size = get_quota_info(current_user, 'gcode')
|
||||
if quota_mb > 0 and current_size >= quota_mb * 1024 * 1024:
|
||||
return jsonify({'success': False, 'error': 'GCode Storage Quota Exceeded. Please delete some files first.'})
|
||||
data = request.json
|
||||
pieces = data.get('pieces', [])
|
||||
quality = data.get('quality', 'base_global_standard.inst.cfg')
|
||||
material = data.get('material', '')
|
||||
infill_density = data.get('infill', '20')
|
||||
support_enable = data.get('support', 'false')
|
||||
support_pattern = data.get('support_pattern', 'lines')
|
||||
@@ -303,6 +504,7 @@ def merge_and_slice():
|
||||
"matrix": p['raw_matrix'],
|
||||
"settings": {
|
||||
"quality": quality,
|
||||
"material": material,
|
||||
"infill": infill_density,
|
||||
"support": support_enable,
|
||||
"support_pattern": support_pattern
|
||||
@@ -326,6 +528,7 @@ def merge_and_slice():
|
||||
"parts": [],
|
||||
"settings": {
|
||||
"quality": quality,
|
||||
"material": material,
|
||||
"infill": infill_density,
|
||||
"support": support_enable,
|
||||
"support_pattern": support_pattern
|
||||
@@ -347,6 +550,7 @@ def merge_and_slice():
|
||||
"matrix": pieces[0].get('raw_matrix', pieces[0]['matrix']),
|
||||
"settings": {
|
||||
"quality": quality,
|
||||
"material": material,
|
||||
"infill": infill_density,
|
||||
"support": support_enable,
|
||||
"support_pattern": support_pattern
|
||||
@@ -358,7 +562,7 @@ def merge_and_slice():
|
||||
temp_filename = f"temp_edit_{uuid.uuid4().hex}.stl"
|
||||
temp_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], temp_filename)
|
||||
|
||||
merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=True)
|
||||
merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, material, infill_density, support_enable, support_pattern, delete_stl=True)
|
||||
elif len(inputs) == 1 and is_edit:
|
||||
target_file_id = pieces[0]['file_id']
|
||||
print_file = PrintFile.query.get(target_file_id)
|
||||
@@ -371,7 +575,7 @@ def merge_and_slice():
|
||||
temp_filename = f"temp_{uuid.uuid4().hex}.stl"
|
||||
temp_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], temp_filename)
|
||||
|
||||
merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=True)
|
||||
merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, material, infill_density, support_enable, support_pattern, delete_stl=True)
|
||||
else:
|
||||
# Multiple models, create a new "Merged Slice" PrintFile entry to keep track of combination
|
||||
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
@@ -384,6 +588,7 @@ def merge_and_slice():
|
||||
"parts": [],
|
||||
"settings": {
|
||||
"quality": quality,
|
||||
"material": material,
|
||||
"infill": infill_density,
|
||||
"support": support_enable,
|
||||
"support_pattern": support_pattern
|
||||
@@ -410,7 +615,76 @@ def merge_and_slice():
|
||||
db.session.add(print_file)
|
||||
db.session.commit()
|
||||
|
||||
merge_and_slice_task(print_file.id, inputs, merged_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=False)
|
||||
merge_and_slice_task(print_file.id, inputs, merged_filepath, quality, material, infill_density, support_enable, support_pattern, delete_stl=False)
|
||||
|
||||
return jsonify({'success': True, 'message': 'Plater slice queued!'})
|
||||
|
||||
|
||||
@main_bp.route('/api/build_plate_model')
|
||||
@login_required
|
||||
def build_plate_model():
|
||||
conf = SystemConfig.query.filter_by(key='build_plate_model_path').first()
|
||||
if conf and conf.value and os.path.exists(conf.value):
|
||||
return send_file(conf.value)
|
||||
abort(404)
|
||||
|
||||
@main_bp.route('/api/engine_options/<engine_name>')
|
||||
@login_required
|
||||
def engine_options(engine_name):
|
||||
engine = get_slicer_engine(engine_name, current_app.config['PRINT_CONFIG_FOLDER'], current_app.config['PRUSA_SLICE_BIN'])
|
||||
presets = engine.get_quality_presets()
|
||||
patterns = engine.get_support_patterns()
|
||||
materials = engine.get_materials() if hasattr(engine, 'get_materials') else []
|
||||
printers = engine.get_all_printers() if hasattr(engine, 'get_all_printers') else []
|
||||
return jsonify({'presets': presets, 'support_patterns': patterns, 'materials': materials, 'printers': printers})
|
||||
|
||||
@main_bp.route('/account', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def account():
|
||||
if current_user.is_guest:
|
||||
flash('Guests cannot manage accounts.', 'danger')
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'change_password':
|
||||
current_pass = request.form.get('current_password')
|
||||
new_pass = request.form.get('new_password')
|
||||
confirm_pass = request.form.get('confirm_password')
|
||||
|
||||
if not check_password_hash(current_user.password_hash, current_pass):
|
||||
flash('Current password is incorrect.', 'danger')
|
||||
elif new_pass != confirm_pass:
|
||||
flash('New passwords do not match.', 'danger')
|
||||
elif len(new_pass) < 6:
|
||||
flash('New password must be at least 6 characters.', 'danger')
|
||||
elif current_user.is_admin and new_pass == 'admin123':
|
||||
flash('Your new password cannot be the default "admin123".', 'danger')
|
||||
else:
|
||||
current_user.password_hash = generate_password_hash(new_pass)
|
||||
db.session.commit()
|
||||
# If they just changed it, clear the must change flag
|
||||
session.pop('must_change_password', None)
|
||||
flash('Password updated successfully.', 'success')
|
||||
|
||||
elif action == 'terminate_session':
|
||||
session_id = request.form.get('session_id')
|
||||
token_to_terminate = request.form.get('session_token')
|
||||
|
||||
my_session_token = session.get('user_session_token')
|
||||
if token_to_terminate == my_session_token:
|
||||
flash('You cannot terminate your current session from here. Please logout instead.', 'warning')
|
||||
else:
|
||||
us = UserSession.query.filter_by(id=session_id, user_id=current_user.id).first()
|
||||
if us:
|
||||
us.is_active = False
|
||||
db.session.commit()
|
||||
flash('Session terminated.', 'success')
|
||||
|
||||
return redirect(url_for('main.account'))
|
||||
|
||||
sessions = UserSession.query.filter_by(user_id=current_user.id, is_active=True).order_by(UserSession.last_active.desc()).all()
|
||||
current_token = session.get('user_session_token')
|
||||
|
||||
return render_template('slice/account.html', sessions=sessions, current_token=current_token)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app, Response
|
||||
from flask_login import login_required, current_user
|
||||
from websockets.sync.client import connect as ws_connect
|
||||
import os
|
||||
import websockets.exceptions
|
||||
import threading
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
from app.models import SystemConfig, db
|
||||
import uuid
|
||||
import traceback
|
||||
from werkzeug.utils import secure_filename
|
||||
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app, Response
|
||||
from flask_login import login_required, current_user
|
||||
from flask_sock import Server, ConnectionClosed
|
||||
from websockets.sync.client import connect as ws_connect
|
||||
from urllib.parse import urlparse, urlencode
|
||||
from app.models import SystemConfig, db, PrintFile
|
||||
from app.utils.octoprint_client import OctoPrintClient
|
||||
from app.utils.gcode_parser import get_gcode_metadata
|
||||
|
||||
printer_bp = Blueprint('printer', __name__, url_prefix='/printer')
|
||||
|
||||
@@ -17,6 +23,23 @@ def get_octo_client():
|
||||
return OctoPrintClient(url.value, apikey.value)
|
||||
return None
|
||||
|
||||
def _enrich_job_data(job_data):
|
||||
if job_data and job_data.get('job', {}).get('file', {}).get('name'):
|
||||
internal_name = job_data['job']['file']['name']
|
||||
internal_stl_name = str(internal_name)[:-5]+"stl"
|
||||
if current_user.is_authenticated and current_user.is_admin:
|
||||
pf = PrintFile.query.filter_by(filename=internal_stl_name).first()
|
||||
elif current_user.is_authenticated:
|
||||
pf = PrintFile.query.filter_by(filename=internal_stl_name, user_id=current_user.id).first()
|
||||
else:
|
||||
pf = None
|
||||
|
||||
if pf:
|
||||
job_data['job']['file']['display_name'] = pf.original_filename
|
||||
else:
|
||||
job_data['job']['file']['display_name'] = internal_name
|
||||
return job_data
|
||||
|
||||
@printer_bp.route('/status')
|
||||
@login_required
|
||||
def status():
|
||||
@@ -27,37 +50,143 @@ def status():
|
||||
if client:
|
||||
try:
|
||||
status_data = client.get_printer_status()
|
||||
job_data = client.get_job_info()
|
||||
print(status_data)
|
||||
print(client.get_job_info())
|
||||
job_data = _enrich_job_data(client.get_job_info())
|
||||
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
print(error)
|
||||
else:
|
||||
error = "OctoPrint is not configured."
|
||||
|
||||
return render_template('printer/status.html', status=status_data, job=job_data, error=error)
|
||||
|
||||
@printer_bp.route('/api/status_data')
|
||||
@login_required
|
||||
def api_status_data():
|
||||
client = get_octo_client()
|
||||
if client:
|
||||
try:
|
||||
status_data = client.get_printer_status()
|
||||
job_data = _enrich_job_data(client.get_job_info())
|
||||
return jsonify({'success': True, 'status': status_data, 'job': job_data})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
return jsonify({'success': False, 'error': 'OctoPrint is not configured.'})
|
||||
|
||||
|
||||
def get_gcode_dir():
|
||||
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
|
||||
if conf and conf.value and os.path.exists(conf.value):
|
||||
return conf.value
|
||||
return current_app.config['UPLOAD_FOLDER']
|
||||
|
||||
@printer_bp.route('/prepare')
|
||||
@login_required
|
||||
def prepare():
|
||||
client = get_octo_client()
|
||||
|
||||
# Query only the sliced GCode files belonging to the current user
|
||||
user_files = PrintFile.query.filter_by(user_id=current_user.id, status='sliced').order_by(PrintFile.created_at.desc()).all()
|
||||
|
||||
files = []
|
||||
error = None
|
||||
gcode_dir = get_gcode_dir()
|
||||
|
||||
client = get_octo_client()
|
||||
octo_files_dict = {}
|
||||
if client:
|
||||
try:
|
||||
res = client.get_files()
|
||||
files = res.get('files', [])
|
||||
octo_resp = client.get_files()
|
||||
for item in octo_resp.get('files', []):
|
||||
octo_files_dict[item.get('name')] = item
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
else:
|
||||
pass
|
||||
|
||||
for f in user_files:
|
||||
gcode_filename = f.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
gcode_path = os.path.join(gcode_dir, gcode_filename)
|
||||
|
||||
size = 0
|
||||
f.meta_print_time = '-'
|
||||
f.meta_first_layer_time = '-'
|
||||
f.meta_filament_used = '-'
|
||||
|
||||
if os.path.exists(gcode_path):
|
||||
size = os.path.getsize(gcode_path)
|
||||
metadata = get_gcode_metadata(gcode_path)
|
||||
f.meta_print_time = metadata.get('print_time', '-')
|
||||
f.meta_first_layer_time = metadata.get('first_layer_time', '-')
|
||||
f.meta_filament_used = metadata.get('filament_used', '-')
|
||||
|
||||
# Upload to OctoPrint if not found but exists locally
|
||||
if client and gcode_filename not in octo_files_dict and size > 0:
|
||||
try:
|
||||
resp = client.upload_file('local', gcode_path, gcode_filename)
|
||||
uploaded_loc = resp.get('files', {}).get('local', {})
|
||||
if gcode_filename in uploaded_loc:
|
||||
octo_files_dict[gcode_filename] = uploaded_loc[gcode_filename]
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
octo_info = octo_files_dict.get(gcode_filename, {})
|
||||
analysis = octo_info.get('gcodeAnalysis', None)
|
||||
|
||||
files.append({
|
||||
'id': f.id,
|
||||
'name': f.original_filename.rsplit('.', 1)[0] + '.gcode',
|
||||
'type': 'machinecode',
|
||||
'size': size,
|
||||
'origin': 'local',
|
||||
'path': gcode_filename,
|
||||
'gcodeAnalysis': analysis,
|
||||
'meta_print_time': f.meta_print_time,
|
||||
'meta_first_layer_time': f.meta_first_layer_time,
|
||||
'meta_filament_used': f.meta_filament_used
|
||||
})
|
||||
|
||||
error = None
|
||||
if not get_octo_client():
|
||||
error = "OctoPrint is not configured."
|
||||
|
||||
return render_template('printer/prepare.html', files=files, error=error)
|
||||
|
||||
|
||||
def check_printer_control_permission(client):
|
||||
if current_user.is_admin:
|
||||
return True, None
|
||||
|
||||
try:
|
||||
status_data = client.get_printer_status()
|
||||
state = status_data.get('state', {}).get('text', '')
|
||||
active_states = ['Printing', 'Paused', 'Pausing', 'Resuming', 'Cancelling']
|
||||
if state not in active_states:
|
||||
return True, None
|
||||
|
||||
job_info = client.get_job_info()
|
||||
internal_name = job_info.get('job', {}).get('file', {}).get('name')
|
||||
if not internal_name:
|
||||
return False, "现在有任务正在运行,非管理员无法进行控制。"
|
||||
|
||||
pf = PrintFile.query.filter_by(filename=internal_name).first()
|
||||
if pf and pf.user_id == current_user.id:
|
||||
return True, None
|
||||
else:
|
||||
return False, "现在有任务正在运行,您无权进行此操作。只有管理员或任务发起者可以进行控制。"
|
||||
except Exception:
|
||||
pass
|
||||
return True, None
|
||||
|
||||
@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:
|
||||
allowed, err_msg = check_printer_control_permission(client)
|
||||
if not allowed:
|
||||
return jsonify({"success": False, "error": err_msg})
|
||||
try:
|
||||
client.select_file(location, path, print_after_select=True)
|
||||
return jsonify({"success": True})
|
||||
@@ -66,29 +195,73 @@ def api_print_file():
|
||||
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()
|
||||
raw_url = client.get_webcam_stream_url()
|
||||
# If it's an absolute url pointing to the base url, strip it or proxy it via octo_proxy
|
||||
parsed_raw = urlparse(raw_url)
|
||||
base_config = SystemConfig.query.filter_by(key='octoprint_url').first()
|
||||
if base_config and base_config.value:
|
||||
base_url = base_config.value.rstrip('/')
|
||||
parsed_base = urlparse(base_url)
|
||||
# If they share the same host, replace with proxy
|
||||
# Usually OctoPrint webcam streams are on the same host or relative
|
||||
path = parsed_raw.path
|
||||
if path.startswith('/'):
|
||||
path = path[1:]
|
||||
query = parsed_raw.query
|
||||
|
||||
# build proxy url
|
||||
if query:
|
||||
webcam_url = url_for('printer.octo_proxy', path=path) + '?' + query
|
||||
else:
|
||||
webcam_url = url_for('printer.octo_proxy', path=path)
|
||||
else:
|
||||
webcam_url = raw_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)
|
||||
|
||||
import re
|
||||
import markdown
|
||||
|
||||
@printer_bp.route('/helper_printer')
|
||||
def helper_printer():
|
||||
lang = request.cookies.get('lang', 'en')
|
||||
filepath = os.path.join(current_app.root_path, 'assets', 'doc', f'printer_helper_{lang}.md')
|
||||
if not os.path.exists(filepath):
|
||||
filepath = os.path.join(current_app.root_path, 'assets', 'doc', 'printer_helper_en.md')
|
||||
|
||||
content_html = ""
|
||||
if os.path.exists(filepath):
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
md_text = f.read()
|
||||
content_html = markdown.markdown(md_text, extensions=['fenced_code', 'tables'])
|
||||
# Rewrite relative image links to /assets/doc/
|
||||
content_html = re.sub(r'src="(?!http|/)([^"]+)"', r'src="/assets/doc/\1"', content_html)
|
||||
|
||||
return render_template('printer/helper_printer.html', content_html=content_html)
|
||||
|
||||
@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:
|
||||
allowed, err_msg = check_printer_control_permission(client)
|
||||
if not allowed:
|
||||
return jsonify({"success": False, "error": err_msg})
|
||||
try:
|
||||
if cmd == 'home':
|
||||
client.home_axes()
|
||||
elif cmd == 'auto_level':
|
||||
client.auto_leveling()
|
||||
elif cmd == 'pause':
|
||||
client.pause_print()
|
||||
elif cmd == 'cancel':
|
||||
@@ -98,6 +271,49 @@ def api_command():
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
return jsonify({"success": False, "error": "Invalid client or command"})
|
||||
|
||||
@printer_bp.route('/api/upload_gcode', methods=['POST'])
|
||||
@login_required
|
||||
def upload_gcode():
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"success": False, "error": "No file part"}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({"success": False, "error": "No selected file"}), 400
|
||||
|
||||
if not file.filename.lower().endswith(('.gcode', '.gco', '.g')):
|
||||
return jsonify({"success": False, "error": "Only standard .gcode files are supported."}), 400
|
||||
|
||||
sec_name = secure_filename(file.filename)
|
||||
random_prefix = uuid.uuid4().hex[:8]
|
||||
|
||||
# We save pseudo-STL filename so that our conventional parser works (replaces .ext with .gcode)
|
||||
# i.e., "some_file.gcode" -> pseudo .stl tracker
|
||||
pseudo_stl_filename = f"{random_prefix}_{sec_name.rsplit('.', 1)[0]}.stl"
|
||||
gcode_filename = f"{random_prefix}_{sec_name.rsplit('.', 1)[0]}.gcode"
|
||||
|
||||
gcode_path = os.path.join(get_gcode_dir(), gcode_filename)
|
||||
file.save(gcode_path)
|
||||
|
||||
print_file = PrintFile(
|
||||
user_id=current_user.id,
|
||||
original_filename=file.filename, # keep original GCode name
|
||||
filename=pseudo_stl_filename,
|
||||
file_type='stl',
|
||||
status='sliced'
|
||||
)
|
||||
db.session.add(print_file)
|
||||
db.session.commit()
|
||||
|
||||
client = get_octo_client()
|
||||
if client:
|
||||
try:
|
||||
client.upload_file('local', gcode_path, gcode_filename)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({"success": True})
|
||||
|
||||
@printer_bp.route('/octo_config', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def octo_config():
|
||||
@@ -141,13 +357,13 @@ def octo_embed():
|
||||
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': ''}, websocket=True)
|
||||
@printer_bp.route('/proxy', defaults={'path': ''}, websocket=True, strict_slashes=False)
|
||||
@printer_bp.route('/proxy/<path:path>', websocket=True)
|
||||
@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
|
||||
@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], strict_slashes=False)
|
||||
@printer_bp.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
|
||||
@login_required
|
||||
def octo_proxy(path):
|
||||
if not current_user.is_admin:
|
||||
if current_user.is_guest:
|
||||
return "Unauthorized", 403
|
||||
|
||||
url_config = SystemConfig.query.filter_by(key='octoprint_url').first()
|
||||
@@ -159,12 +375,10 @@ def octo_proxy(path):
|
||||
# print("----- REQUEST HEADERS -----")
|
||||
# for k, v in request.headers:
|
||||
# print(f"{k}: {v}")
|
||||
# print("----- END HEADERS -----")
|
||||
# print("----- END REQUEST HEADERS -----")
|
||||
|
||||
# --- WebSocket Proxy Logic ---
|
||||
if request.headers.get('Upgrade', '').lower() == 'websocket':
|
||||
from flask_sock import Server, ConnectionClosed
|
||||
|
||||
# Check if environment supports WebSockets
|
||||
try:
|
||||
ws = Server(request.environ)
|
||||
@@ -210,7 +424,6 @@ def octo_proxy(path):
|
||||
remote_ws = ws_connect(target_url, additional_headers=ws_headers)
|
||||
print("WS Proxy connected to remote.")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"Remote WS Connection Error: {e}")
|
||||
ws.close(1011, str(e))
|
||||
@@ -263,14 +476,15 @@ def octo_proxy(path):
|
||||
class WebSocketResponse(Response):
|
||||
def __call__(self, *args, **kwargs):
|
||||
print("WS Response __call__")
|
||||
if getattr(ws, 'mode', 'werkzeug') == 'werkzeug':
|
||||
if getattr(ws, 'mode', 'werkzeug') == 'gunicorn':
|
||||
raise StopIteration()
|
||||
elif getattr(ws, 'mode', 'werkzeug') == 'werkzeug':
|
||||
return super().__call__(*args, **kwargs)
|
||||
return []
|
||||
|
||||
return WebSocketResponse()
|
||||
|
||||
# --- Standard HTTP Proxy Logic ---
|
||||
# from urllib.parse import urlparse
|
||||
target_url = f"{base_url}/{path}"
|
||||
|
||||
if request.query_string:
|
||||
@@ -278,17 +492,26 @@ def octo_proxy(path):
|
||||
|
||||
# Build headers for reverse proxy based on nginx config reference
|
||||
parsed_base = urlparse(base_url)
|
||||
headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length']}
|
||||
headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length', 'origin', 'referer']}
|
||||
|
||||
# NGINX equivalent proxy headers
|
||||
# print(f"Proxying to: {target_url}")
|
||||
# Spoof Host, Origin, and Referer to match the backend URL completely
|
||||
# This prevents Tornado's strict Origin vs Host CSRF/CORS validation from failing
|
||||
# headers['Host'] = parsed_base.netloc
|
||||
headers['Host'] = request.host
|
||||
|
||||
if 'Origin' in request.headers:
|
||||
headers['Origin'] = base_url
|
||||
if 'Referer' in request.headers:
|
||||
headers['Referer'] = f"{base_url}/{path}"
|
||||
|
||||
headers['X-Real-IP'] = request.remote_addr
|
||||
headers['X-Real-Port'] = str(request.environ.get('REMOTE_PORT', ''))
|
||||
|
||||
forwarded_for = request.headers.get('X-Forwarded-For', '')
|
||||
headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
|
||||
|
||||
headers['X-Forwarded-Protocol'] = request.scheme
|
||||
headers['X-Forwarded-Proto'] = request.scheme
|
||||
headers['X-Script-Name'] = "/printer/proxy"
|
||||
headers['X-Forwarded-Host'] = request.host
|
||||
headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
|
||||
@@ -299,6 +522,27 @@ def octo_proxy(path):
|
||||
if request.headers.get('Connection'):
|
||||
headers['Connection'] = request.headers.get('Connection')
|
||||
|
||||
# OctoPrint requires an X-CSRF-Token header matching the csrf_token_* cookie for POST/PUT/DELETE
|
||||
if request.method not in ['GET', 'HEAD', 'OPTIONS'] and 'X-CSRF-Token' not in request.headers:
|
||||
for cookie_name, cookie_value in request.cookies.items():
|
||||
if cookie_name.startswith('csrf_token_'):
|
||||
headers['X-CSRF-Token'] = cookie_value
|
||||
break
|
||||
|
||||
# if path == 'api/login':
|
||||
# print("----- SEND HEADERS -----")
|
||||
# for k, v in headers.items():
|
||||
# if k in request.headers.keys():
|
||||
# print(f"{k} :(from request): {request.headers[k]} (to send): {v}")
|
||||
# else:
|
||||
# print(f"{k} :(from request): None (to send): {v}")
|
||||
|
||||
# for k,v in request.headers.items():
|
||||
# if k not in headers.keys():
|
||||
# print(f"{k} :(from request): {request.headers[k]} (to send): None")
|
||||
|
||||
# print("----- END SEND HEADERS -----")
|
||||
|
||||
try:
|
||||
# proxy_connect_timeout 60s, proxy_read_timeout 600s
|
||||
resp = requests.request(
|
||||
@@ -316,7 +560,15 @@ def octo_proxy(path):
|
||||
|
||||
# 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]
|
||||
|
||||
# We must use raw headers to prevent requests from joining multiple Set-Cookie headers with a comma
|
||||
# Joining Set-Cookie with a comma breaks standard cookie parsing in the browser due to commas in dates
|
||||
response_headers = [(name, value) for name, value in resp.raw.headers.items()
|
||||
if name.lower() not in excluded_headers and name.lower() != 'set-cookie']
|
||||
|
||||
for cookie in resp.raw.headers.get_all('set-cookie', []):
|
||||
# ensure we preserve the proxy path override
|
||||
response_headers.append(('Set-Cookie', cookie))
|
||||
|
||||
def generate():
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
|
||||
48
app/templates/admin/api_keys.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>{{ _('API Keys Management') }}</h2>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">{{ _('Create New API Key') }}</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('admin.add_api_key') }}" method="POST" class="form-inline">
|
||||
<input type="text" name="name" class="form-control mb-2 mr-sm-2" placeholder="{{ _('Key Name') }}" required>
|
||||
<button type="submit" class="btn btn-primary mb-2">{{ _('Generate Key') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('ID') }}</th>
|
||||
<th>{{ _('API Key Name') }}</th>
|
||||
<th>{{ _('API Key') }}</th>
|
||||
<th>{{ _('Created At') }}</th>
|
||||
<th>{{ _('Action') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key in keys %}
|
||||
<tr>
|
||||
<td>{{ key.id }}</td>
|
||||
<td>{{ key.name }}</td>
|
||||
<td><code>{{ key.key }}</code></td>
|
||||
<td>{{ key.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td>
|
||||
<form action="{{ url_for('admin.delete_api_key', key_id=key.id) }}" method="POST" style="display:inline;" onsubmit="event.preventDefault(); window.customConfirm('{{ _('Are you sure you want to delete this API Key?') }}', () => { this.submit(); });">
|
||||
<button type="submit" class="btn btn-danger btn-sm">{{ _('Delete') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">{{ _('No API keys found.') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -7,29 +7,47 @@
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5>{{ _('CuraEngine Configurations') }}</h5>
|
||||
<hr>
|
||||
<h5 class="card-title text-primary border-bottom pb-2 mt-4 mb-3">{{ _('SliceEngine Configurations') }}</h5>
|
||||
<form id="settingsForm" onsubmit="submitSettings(event)">
|
||||
<div class="mb-3">
|
||||
<label for="offset_x" class="form-label">{{ _('Plater Origin Offset X (mm)') }}</label>
|
||||
<input type="number" class="form-control" name="offset_x" id="offset_x" value="{{ configs.get('offset_x', '0') }}">
|
||||
<div class="form-text">{{ _('Adjust the X-axis compilation offset for combined files on the build plate.') }}</div>
|
||||
<div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Adjust the X-axis compilation offset for combined files on the build plate.') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="offset_y" class="form-label">{{ _('Plater Origin Offset Y (mm)') }}</label>
|
||||
<input type="number" class="form-control" name="offset_y" id="offset_y" value="{{ configs.get('offset_y', '0') }}">
|
||||
<div class="form-text">{{ _('Adjust the Y-axis compilation offset for combined files on the build plate.') }}</div>
|
||||
<div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Adjust the Y-axis compilation offset for combined files on the build plate.') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="proxy_skip_size_mb" class="form-label">{{ _('Proxy Skip Size (MB)') }}</label>
|
||||
<input type="number" class="form-control" name="proxy_skip_size_mb" id="proxy_skip_size_mb" value="{{ configs.get('proxy_skip_size_mb', '5.0') }}" step="0.1" min="0">
|
||||
<div class="form-text">{{ _('Files smaller than this will not generate a simplified proxy.') }}</div>
|
||||
<div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Files smaller than this will not generate a simplified proxy.') }}</div>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4">{{ _('Default Plater Settings') }}</h5>
|
||||
<hr>
|
||||
<div class="mb-3">
|
||||
<label for="gcode_upload_folder" class="form-label"><i class="bi bi-folder2-open me-2"></i>{{ _('Custom GCode Output Folder') }}</label>
|
||||
<input type="text" class="form-control" name="gcode_upload_folder" id="gcode_upload_folder" value="{{ configs.get('gcode_upload_folder', '') }}">
|
||||
<div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Absolute path to save locally sliced GCode files (e.g. OctoPrint uploads folder like "/home/pi/.octoprint/uploads"). Leave empty to use system default.') }}</div>
|
||||
</div>
|
||||
|
||||
<h5 class="card-title text-primary border-bottom pb-2 mt-4 mb-3"><i class="bi bi-grid-3x3 me-2"></i>{{ _('Default Plater Settings') }}</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="build_plate_model_path" class="form-label">{{ _('Build Plate Model Path (.stl)') }}</label>
|
||||
<input type="text" class="form-control" name="build_plate_model_path" id="build_plate_model_path" value="{{ configs.get('build_plate_model_path', '') }}">
|
||||
<div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Absolute path to a custom build plate STL model to show in the plater. Leave empty to use none.') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="default_printer" class="form-label">{{ _('Default Printer Profile') }}</label>
|
||||
<select class="form-select" name="default_printer" id="default_printer" data-selected="{{ configs.get('default_printer', '') }}">
|
||||
<!-- Loaded via JS -->
|
||||
</select>
|
||||
<div class="form-text"><i class="bi bi-info-circle me-1"></i>{{ _('Main configuration for the printer dimensions, limits and base profiles.') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="default_infill" class="form-label">{{ _('Default Infill Density (%)') }}</label>
|
||||
@@ -47,29 +65,64 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="default_support_pattern" class="form-label">{{ _('Default Support Type') }}</label>
|
||||
<select class="form-select" name="default_support_pattern" id="default_support_pattern">
|
||||
<option value="tree" {% if configs.get('default_support_pattern', 'tree') == 'tree' %}selected{% endif %}>{{ _('Tree') }}</option>
|
||||
<option value="lines" {% if configs.get('default_support_pattern', 'tree') == 'lines' %}selected{% endif %}>{{ _('Lines') }}</option>
|
||||
<option value="grid" {% if configs.get('default_support_pattern', 'tree') == 'grid' %}selected{% endif %}>{{ _('Grid') }}</option>
|
||||
<option value="triangles" {% if configs.get('default_support_pattern', 'tree') == 'triangles' %}selected{% endif %}>{{ _('Triangles') }}</option>
|
||||
<option value="concentric" {% if configs.get('default_support_pattern', 'tree') == 'concentric' %}selected{% endif %}>{{ _('Concentric') }}</option>
|
||||
<option value="zigzag" {% if configs.get('default_support_pattern', 'tree') == 'zigzag' %}selected{% endif %}>{{ _('Zig Zag') }}</option>
|
||||
<option value="cross" {% if configs.get('default_support_pattern', 'tree') == 'cross' %}selected{% endif %}>{{ _('Cross') }}</option>
|
||||
<option value="gyroid" {% if configs.get('default_support_pattern', 'tree') == 'gyroid' %}selected{% endif %}>{{ _('Gyroid') }}</option>
|
||||
<option value="honeycomb" {% if configs.get('default_support_pattern', 'tree') == 'honeycomb' %}selected{% endif %}>{{ _('Honeycomb') }}</option>
|
||||
<option value="octagon" {% if configs.get('default_support_pattern', 'tree') == 'octagon' %}selected{% endif %}>{{ _('Octagon') }}</option>
|
||||
<select class="form-select" name="default_support_pattern" id="default_support_pattern" data-selected="{{ configs.get('default_support_pattern', 'tree') }}">
|
||||
<!-- Loaded via JS -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="default_quality" class="form-label">{{ _('Default Quality Profile') }}</label>
|
||||
<select class="form-select" name="default_quality" id="default_quality" data-selected="{{ configs.get('default_quality', 'base_global_standard.inst.cfg') }}">
|
||||
<!-- Loaded via JS -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="default_quality" class="form-label">{{ _('Default Quality Profile') }}</label>
|
||||
<select class="form-select" name="default_quality" id="default_quality">
|
||||
{% for key, name in presets %}
|
||||
<option value="{{ key }}" {% if configs.get('default_quality', 'base_global_standard.inst.cfg') == key %}selected{% endif %}>{{ _(name) }}</option>
|
||||
{% endfor %}
|
||||
<label for="default_material" class="form-label">{{ _('Default Material Profile') }}</label>
|
||||
<select class="form-select" name="default_material" id="default_material" data-selected="{{ configs.get('default_material', '') }}">
|
||||
<!-- Loaded via JS -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h5 class="card-title text-primary border-bottom pb-2 mt-4 mb-3"><i class="bi bi-cpu me-2"></i>{{ _('Slicing Engine Configurations') }}</h5>
|
||||
<div class="mb-3">
|
||||
<label for="slicer_engine" class="form-label">{{ _('Slicing Engine') }}</label>
|
||||
<select class="form-select" name="slicer_engine" id="slicer_engine">
|
||||
{% for engine in engines %}
|
||||
<option value="{{ engine.name }}" {% if configs.get('slicer_engine', 'cura') == engine.name %}selected{% endif %} {% if not engine.is_available %}disabled{% endif %}>
|
||||
{{ engine.display_name }} {% if not engine.is_available %}({{ _('Not Available') }}){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text mt-2"><i class="bi bi-info-circle me-1"></i>{{ _('Select the engine to be used globally. Ensure the selected engine is installed and accessible on the server.') }}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h5 class="card-title text-primary border-bottom pb-2 mt-4 mb-3"><i class="bi bi-hdd-network me-2"></i>{{ _('Default Storage Quotas (MB)') }}</h5>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ _('Guest STL Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
|
||||
<input type="number" class="form-control" name="default_guest_stl_quota_mb" value="{{ configs.get('default_guest_stl_quota_mb', '0') }}" min="0">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ _('Guest GCode Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
|
||||
<input type="number" class="form-control" name="default_guest_gcode_quota_mb" value="{{ configs.get('default_guest_gcode_quota_mb', '0') }}" min="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ _('New User STL Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
|
||||
<input type="number" class="form-control" name="default_user_stl_quota_mb" value="{{ configs.get('default_user_stl_quota_mb', '0') }}" min="0">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ _('New User GCode Quota') }} <small class="text-muted">(0 = {{ _('Unlimited') }})</small></label>
|
||||
<input type="number" class="form-control" name="default_user_gcode_quota_mb" value="{{ configs.get('default_user_gcode_quota_mb', '0') }}" min="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="btn-save-settings">{{ _('Save Settings') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -109,5 +162,69 @@ function submitSettings(event) {
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const engineSelect = document.getElementById('slicer_engine');
|
||||
const qualitySelect = document.getElementById('default_quality');
|
||||
const materialSelect = document.getElementById('default_material');
|
||||
const patternSelect = document.getElementById('default_support_pattern');
|
||||
const printerSelect = document.getElementById('default_printer');
|
||||
|
||||
function updateOptions(engine) {
|
||||
fetch(`/api/engine_options/${engine}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const selPtr = printerSelect.getAttribute('data-selected');
|
||||
printerSelect.innerHTML = '';
|
||||
if(data.printers) {
|
||||
data.printers.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id; opt.textContent = p.name;
|
||||
printerSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if(selPtr) printerSelect.value = selPtr;
|
||||
const selQ = qualitySelect.getAttribute('data-selected');
|
||||
qualitySelect.innerHTML = '';
|
||||
data.presets.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id; opt.textContent = p.name;
|
||||
qualitySelect.appendChild(opt);
|
||||
});
|
||||
if(selQ) qualitySelect.value = selQ;
|
||||
|
||||
const selM = materialSelect.getAttribute('data-selected');
|
||||
materialSelect.innerHTML = '';
|
||||
// Add an empty option for material (optional fallback)
|
||||
const emptyOpt = document.createElement('option');
|
||||
emptyOpt.value = ''; emptyOpt.textContent = "{{ _('Auto / Default') }}";
|
||||
materialSelect.appendChild(emptyOpt);
|
||||
if(data.materials) {
|
||||
data.materials.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id; opt.textContent = p.name;
|
||||
materialSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if(selM) materialSelect.value = selM;
|
||||
|
||||
const selP = patternSelect.getAttribute('data-selected');
|
||||
patternSelect.innerHTML = '';
|
||||
data.support_patterns.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id; opt.textContent = p.name;
|
||||
patternSelect.appendChild(opt);
|
||||
});
|
||||
if(selP) patternSelect.value = selP;
|
||||
});
|
||||
}
|
||||
|
||||
engineSelect.addEventListener('change', function() {
|
||||
updateOptions(this.value);
|
||||
});
|
||||
|
||||
// Initial load
|
||||
updateOptions(engineSelect.value);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<h1 class="h2">{{ _('User Management') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 text-end">
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addUserModal"><i class="bi bi-person-plus me-1"></i>{{ _('Add User') }}</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
@@ -12,6 +15,7 @@
|
||||
<th>{{ _('ID') }}</th>
|
||||
<th>{{ _('Username') }}</th>
|
||||
<th>{{ _('Role') }}</th>
|
||||
<th>{{ _('Quotas') }}</th>
|
||||
<th>{{ _('Created At') }}</th>
|
||||
<th>{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
@@ -30,8 +34,33 @@
|
||||
<span class="badge bg-primary">{{ _('User') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<small class="text-muted d-block">STL: {{ _('Unlimited') }}</small>
|
||||
<small class="text-muted d-block">GCode: {{ _('Unlimited') }}</small>
|
||||
{% else %}
|
||||
<small class="text-muted d-block">STL:
|
||||
{% if user_quotas[user.id]['stl'] == '0' %}
|
||||
{{ _('Default') }} ({{ user_quotas[user.id]['eff_stl'] if user_quotas[user.id]['eff_stl'] != '0' else _('Unlimited') }} MB)
|
||||
{% else %}
|
||||
{{ user_quotas[user.id]['stl'] }} MB
|
||||
{% endif %}
|
||||
</small>
|
||||
<small class="text-muted d-block">GCode:
|
||||
{% if user_quotas[user.id]['gcode'] == '0' %}
|
||||
{{ _('Default') }} ({{ user_quotas[user.id]['eff_gcode'] if user_quotas[user.id]['eff_gcode'] != '0' else _('Unlimited') }} MB)
|
||||
{% else %}
|
||||
{{ user_quotas[user.id]['gcode'] }} MB
|
||||
{% endif %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editQuotaModal{{ user.id }}">{{ _('Edit Quota') }}</button>
|
||||
{% if not user.is_guest %}
|
||||
<button class="btn btn-sm btn-outline-info" data-bs-toggle="modal" data-bs-target="#resetPwdModal{{ user.id }}">{{ _('Reset Password') }}</button>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('admin.delete_user', user_id=user.id) }}" method="POST" class="d-inline" onsubmit="event.preventDefault(); window.customConfirm('{{ _('WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?') }}', () => { this.submit(); });">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" {% if user.id == current_user.id %}disabled{% endif %}>{{ _('Delete') }}</button>
|
||||
</form>
|
||||
@@ -41,4 +70,110 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% for user in users %}
|
||||
<!-- Edit Quota Modal -->
|
||||
<div class="modal fade" id="editQuotaModal{{ user.id }}" tabindex="-1" aria-labelledby="editQuotaModalLabel{{ user.id }}" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('admin.update_quota', user_id=user.id) }}" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editQuotaModalLabel{{ user.id }}">{{ _('Edit Quota for') }} {{ user.username }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ _('STL Quota') }} (MB) <small class="text-muted">(0 = {{ _('Default') }})</small></label>
|
||||
<input type="number" class="form-control" name="stl_quota_mb" value="{{ user_quotas[user.id]['stl'] }}" min="0">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ _('GCode Quota') }} (MB) <small class="text-muted">(0 = {{ _('Default') }})</small></label>
|
||||
<input type="number" class="form-control" name="gcode_quota_mb" value="{{ user_quotas[user.id]['gcode'] }}" min="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not user.is_guest %}
|
||||
<!-- Reset Password Modal -->
|
||||
<div class="modal fade" id="resetPwdModal{{ user.id }}" tabindex="-1" aria-labelledby="resetPwdModalLabel{{ user.id }}" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('admin.reset_password', user_id=user.id) }}" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="resetPwdModalLabel{{ user.id }}">{{ _('Reset Password for') }} {{ user.username }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ _('New Password') }}</label>
|
||||
<input type="password" class="form-control" name="password" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">{{ _('Save') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Add User Modal -->
|
||||
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('admin.add_user') }}" method="POST">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addUserModalLabel">{{ _('Add User') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ _('Username') }}</label>
|
||||
<input type="text" class="form-control" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{{ _('Password') }}</label>
|
||||
<input type="password" class="form-control" name="password" required>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" name="is_admin" id="isAdminCheck">
|
||||
<label class="form-check-label" for="isAdminCheck">{{ _('Is Admin') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">{{ _('Create User') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Bootstrap Modal Z-Index Fix -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.modal').forEach(function(modal) {
|
||||
document.body.appendChild(modal);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Bootstrap Modal Z-Index Fix -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.modal').forEach(function(modal) {
|
||||
document.body.appendChild(modal);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -5,19 +5,27 @@
|
||||
<div class="col-md-6 mt-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">Login</h4>
|
||||
<h4 class="mb-0">{{ _('Login') }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<label for="username" class="form-label">{{ _('Username') }}</label>
|
||||
<input type="text" class="form-control" name="username" id="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<label for="password" class="form-label">{{ _('Password') }}</label>
|
||||
<input type="password" class="form-control" name="password" id="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" name="remember" id="remember">
|
||||
<label class="form-check-label" for="remember">{{ _('Remember Me') }}</label>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" name="merge_data" id="merge_data" checked>
|
||||
<label class="form-check-label" for="merge_data">{{ _('Merge Guest Data') }}</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">{{ _('Login') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AIO 3D Slicer</title>
|
||||
<link href="{{ url_for('static', filename='img/favicon.ico') }}" rel="icon" type="image/x-icon">
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
@@ -22,7 +23,17 @@
|
||||
.card-header { border-bottom: 1px solid rgba(0,0,0,.05); background-color: transparent; }
|
||||
|
||||
.toast-container { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); z-index: 1055; width: auto; max-width: 90%; pointer-events: none; }
|
||||
.toast { border-radius: 0.5rem; box-shadow: 0 0.5rem 1rem rgba(0,0,0,.25); opacity: 1 !important; pointer-events: auto; }
|
||||
.toast {
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0,0,0,.25);
|
||||
pointer-events: auto;
|
||||
border: none;
|
||||
transform: translateY(-20px);
|
||||
transition: opacity 0.35s ease, transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important;
|
||||
}
|
||||
.toast.showing, .toast.show {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 页面切换动画 Page Transition */
|
||||
@keyframes pageFadeInSlide {
|
||||
@@ -33,10 +44,22 @@
|
||||
|
||||
/* 提升 Accordion 折叠栏动画更平滑 */
|
||||
.collapsing { transition: height 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important; }
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 767.98px) {
|
||||
body { padding-top: 126px !important; }
|
||||
.sidebar { top: 126px; width: 100%; border-bottom: 1px solid #ddd; box-shadow: 0 4px 6px rgba(0,0,0,.1); }
|
||||
.sidebar-sticky { height: calc(100vh - 126px); }
|
||||
.mobile-subnav { display: flex !important; }
|
||||
}
|
||||
@media (max-width: 454.98px) {
|
||||
.navbar-brand { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top shadow-sm">
|
||||
<div class="fixed-top">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm">
|
||||
<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>
|
||||
|
||||
@@ -55,6 +78,7 @@
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="langDropdown">
|
||||
<li><a class="dropdown-item {% if request.cookies.get('lang') == 'en' %}active{% endif %}" href="{{ url_for('main.set_language', lang='en') }}"><i class="bi bi-translate me-2"></i>English</a></li>
|
||||
<li><a class="dropdown-item {% if request.cookies.get('lang') == 'zh-cn' %}active{% endif %}" href="{{ url_for('main.set_language', lang='zh-cn') }}"><i class="bi bi-translate me-2"></i>中文 (简体)</a></li>
|
||||
<li><a class="dropdown-item {% if request.cookies.get('lang') == 'de' %}active{% endif %}" href="{{ url_for('main.set_language', lang='de') }}"><i class="bi bi-translate me-2"></i>Deutsch</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +95,18 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 移动端专属二级导航栏 -->
|
||||
<div class="d-none mobile-subnav align-items-center bg-white border-bottom px-3 py-2 shadow-sm d-md-none" style="justify-content: space-between;">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-expanded="false" aria-controls="sidebarMenu">
|
||||
<i class="bi bi-list fs-4"></i>
|
||||
</button>
|
||||
<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-3{% else %}btn-transparent text-secondary border-0 px-2{% 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-3{% else %}btn-transparent text-secondary border-0 px-2{% endif %}" style="transition: all 0.2s;"><i class="bi bi-printer-fill me-1"></i>{{ _('Printer') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-white sidebar collapse border-end">
|
||||
@@ -96,6 +132,18 @@
|
||||
<i class="bi bi-arrows-move me-2"></i>{{ _('Control') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item mb-1">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'printer.helper_printer' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.helper_printer') }}">
|
||||
<i class="bi bi-question-square me-2"></i>{{ _('Printer Helper') }}
|
||||
</a>
|
||||
</li>
|
||||
<!-- {% if current_user.is_authenticated and not current_user.is_guest %}
|
||||
<li class="nav-item mb-1">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'main.account' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.account') }}">
|
||||
<i class="bi bi-person-badge me-2"></i>{{ _('Account Management') }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %} -->
|
||||
</ul>
|
||||
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
@@ -117,6 +165,9 @@
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<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 == 'main.index' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.index') }}">
|
||||
@@ -133,6 +184,18 @@
|
||||
<i class="bi bi-grid-3x3 me-2"></i>{{ _('Plater') }}
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.is_authenticated and not current_user.is_guest %}
|
||||
<li class="nav-item mb-1">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'main.account' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.account') }}">
|
||||
<i class="bi bi-person-badge me-2"></i>{{ _('Account Management') }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item mb-1">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'main.helper_slice' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.helper_slice') }}">
|
||||
<i class="bi bi-question-square me-2"></i>{{ _('Slice Helper') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
@@ -150,6 +213,11 @@
|
||||
<i class="bi bi-people me-2"></i>{{ _('User Management') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark {% if request.endpoint == 'admin.api_keys' %}active text-white shadow-sm{% endif %}" href="{{ url_for('admin.api_keys') }}">
|
||||
<i class="bi bi-key me-2"></i>{{ _('API Keys') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -158,6 +226,10 @@
|
||||
</nav>
|
||||
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 mt-4 bg-light min-vh-100 pb-5">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification Container -->
|
||||
<div class="toast-container" id="global-toast-container">
|
||||
@@ -168,7 +240,7 @@
|
||||
<div class="toast align-items-center border-0 {{ toast_class }} mb-2" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body fw-medium">
|
||||
{{ message }}
|
||||
{{ _(message) if _ else message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
@@ -178,11 +250,6 @@
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Custom Alert Modal -->
|
||||
<div class="modal fade" id="globalAlertModal" tabindex="-1" aria-hidden="true" style="z-index: 1060;">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
|
||||
@@ -17,8 +17,15 @@
|
||||
<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">
|
||||
<div class="card-body p-0 ratio ratio-16x9 bg-secondary bg-opacity-25 d-flex align-items-center justify-content-center">
|
||||
{% if current_user.is_guest %}
|
||||
<div class="text-center text-dark">
|
||||
<i class="bi bi-lock-fill display-4 d-block mb-3"></i>
|
||||
<h5 class="mb-0">{{ _('Please login to view the webcam stream.') }}</h5>
|
||||
</div>
|
||||
{% else %}
|
||||
<img src="{{ webcam_url }}" alt="{{ _('Loading webcam stream...') }}" class="w-100 h-100 object-fit-cover">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,11 +37,23 @@
|
||||
<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">
|
||||
<!-- Motion Controls -->
|
||||
<div class="d-flex gap-4 justify-content-center mb-4 w-100">
|
||||
<!-- 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') }}">
|
||||
<div>
|
||||
<button class="btn btn-lg btn-primary rounded-circle shadow mb-2" 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>
|
||||
<div class="text-muted small">{{ _('Home All Axes') }}<br>(G28)</div>
|
||||
</div>
|
||||
<!-- Auto Level button -->
|
||||
<div>
|
||||
<button class="btn btn-lg btn-info rounded-circle shadow mb-2 text-white" style="width: 80px; height: 80px;" onclick="sendCommand('auto_level')" title="{{ _('Auto Leveling') }}">
|
||||
<i class="bi bi-grid-3x3 fs-2"></i>
|
||||
</button>
|
||||
<div class="text-muted small">{{ _('Auto Leveling') }}<br>(G29)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick macros -->
|
||||
<div class="d-flex gap-3 justify-content-center flex-wrap w-100">
|
||||
@@ -52,7 +71,7 @@
|
||||
|
||||
<script>
|
||||
function sendCommand(cmdName) {
|
||||
if (cmdName === 'cancel' || cmdName === 'home') {
|
||||
if (cmdName === 'cancel' || cmdName === 'home' || cmdName === 'auto_level') {
|
||||
window.customConfirm("{{ _('Are you sure you want to perform this action?') }}", () => doSendCommand(cmdName));
|
||||
} else {
|
||||
doSendCommand(cmdName);
|
||||
|
||||
56
app/templates/printer/helper_printer.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% 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">{{ _('Printer Helper') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Helper Content -->
|
||||
<div class="col-12 markdown-body p-4 bg-white rounded shadow-sm">
|
||||
{% if content_html %}
|
||||
{{ content_html|safe }}
|
||||
{% else %}
|
||||
<p class="text-muted">{{ _('Documentation not available.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 简单的 Markdown 样式优化 */
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-body h1 {
|
||||
font-size: 2em;
|
||||
padding-bottom: .3em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
}
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.markdown-body pre {
|
||||
background-color: #f6f8fa;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.markdown-body code {
|
||||
font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;
|
||||
}
|
||||
.markdown-body table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
color: #212529;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.markdown-body table th, .markdown-body table td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,21 +1,39 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
/* Prevent the outer page from scrolling */
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Make the main container take exactly the viewport height and act as a flex column */
|
||||
main {
|
||||
height: 100vh;
|
||||
padding-bottom: 0 !important;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Let the iframe container fill all the remaining height automatically */
|
||||
.octo-panel-container {
|
||||
flex-grow: 1;
|
||||
margin-bottom: 0 !important;
|
||||
border-radius: 0px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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">
|
||||
<div class="card shadow overflow-hidden octo-panel-container 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>
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
@keyframes blink-fade {
|
||||
0% { background-color: rgba(255, 193, 7, 0.4); box-shadow: 0 0 15px rgba(255, 193, 7, 0.6); }
|
||||
50% { background-color: rgba(255, 193, 7, 0); box-shadow: 0 0 0 rgba(255, 193, 7, 0); }
|
||||
100% { background-color: rgba(255, 193, 7, 0.4); box-shadow: 0 0 15px rgba(255, 193, 7, 0.6); }
|
||||
}
|
||||
.animate-blink {
|
||||
animation: blink-fade 0.8s ease-in-out 4;
|
||||
border: 2px solid #ffc107 !important;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.transition-style {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
<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>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm rounded shadow-sm fw-bold" onclick="document.getElementById('gcodeUploadInput').click();">
|
||||
<i class="bi bi-upload me-1"></i>{{ _('Upload External GCode') }}
|
||||
</button>
|
||||
<input type="file" id="gcodeUploadInput" style="display: none;" accept=".gcode,.gco,.g" onchange="uploadExternalGcode(this)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
@@ -17,12 +38,13 @@
|
||||
<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 id="file-{{ f.id }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3 transition-style">
|
||||
<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>
|
||||
<small class="text-muted d-block pb-1">{{ _('Size:') }} {{ f.size | filesizeformat }}, {% if f.meta_print_time and f.meta_print_time != '-' %}{{ _('Estimated Time:') }} {{ f.meta_print_time }}{% else %}{{ _('Time:') }} {{ f.gcodeAnalysis.estimatedPrintTime if f.gcodeAnalysis else 'Unknown' }}s{% endif %}</small>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('main.preview_gcode', file_id=f.id) }}" class="btn btn-sm btn-outline-info rounded-pill px-3 shadow-sm me-2"><i class="bi bi-eye me-1"></i>{{ _('Preview') }}</a>
|
||||
<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>
|
||||
@@ -59,6 +81,54 @@ function printFile(origin, path) {
|
||||
.catch(err => window.customAlert("Error: " + err));
|
||||
});
|
||||
}
|
||||
|
||||
function uploadExternalGcode(input) {
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
let file = input.files[0];
|
||||
let formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
window.showToast("{{ _('Uploading and linking GCode...') }}", "info");
|
||||
let btn = document.querySelector('button[onclick="document.getElementById(\'gcodeUploadInput\').click();"]');
|
||||
let oldBtnHtml = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Upload/Sync...';
|
||||
btn.disabled = true;
|
||||
|
||||
fetch("{{ url_for('printer.upload_gcode') }}", {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
window.customAlert("Upload failed: " + data.error);
|
||||
btn.innerHTML = oldBtnHtml;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}).catch(e => {
|
||||
window.customAlert("Error: " + e);
|
||||
btn.innerHTML = oldBtnHtml;
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', (event) => {
|
||||
if(window.location.hash) {
|
||||
let el = document.querySelector(window.location.hash);
|
||||
if(el) {
|
||||
setTimeout(() => {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('animate-blink');
|
||||
|
||||
// fallback to remove class later
|
||||
setTimeout(() => el.classList.remove('animate-blink'), 3500);
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -21,7 +21,7 @@
|
||||
<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>
|
||||
<h3 class="display-6 mt-3 text-primary" id="printer-state-text">{{ status.get('state', {}).get('text', 'Unknown') if status else 'Unknown' }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,43 +33,42 @@
|
||||
<i class="bi bi-thermometer-half me-1"></i>{{ _('Temperatures') }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% set temps = status.get('temperature', {}) %}
|
||||
{% set temps = status.get('temperature', {}) if status else {} %}
|
||||
|
||||
<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>
|
||||
<span id="tool-actual">{{ temps.get('tool0', {}).get('actual', 0) }}</span> °C
|
||||
<small class="text-muted fs-6">/ <span id="tool-target">{{ temps.get('tool0', {}).get('target', 0) }}</span> °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>
|
||||
<span id="bed-actual">{{ temps.get('bed', {}).get('actual', 0) }}</span> °C
|
||||
<small class="text-muted fs-6">/ <span id="bed-target">{{ temps.get('bed', {}).get('target', 0) }}</span> °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 shadow-sm mt-4 border-success" id="active-job-card" style="display: {% if job and job.get('job', {}).get('file', {}).get('name') %}block{% else %}none{% endif %};">
|
||||
<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>
|
||||
<h5 id="job-file-name">{{ job.get('job', {}).get('file', {}).get('display_name') or job.get('job', {}).get('file', {}).get('name') if job else '' }}</h5>
|
||||
|
||||
{% set progress = job.get('progress', {}).get('completion', 0) %}
|
||||
{% set progress = job.get('progress', {}).get('completion', 0) if job else 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 id="job-progress-bar" 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">
|
||||
<span id="job-progress-text">{{ "%.1f"|format(progress) }}%</span>
|
||||
</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>
|
||||
<span><strong>{{ _('Print Time:') }}</strong> <span id="job-print-time"></span></span>
|
||||
<span><strong>{{ _('Time Left:') }}</strong> <span id="job-time-left"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 gap-2 d-flex">
|
||||
@@ -78,11 +77,65 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function formatTime(seconds) {
|
||||
if (!seconds && seconds !== 0) return '0{{ _("s") }}';
|
||||
seconds = Math.round(seconds);
|
||||
let d = Math.floor(seconds / 86400);
|
||||
let h = Math.floor((seconds % 86400) / 3600);
|
||||
let m = Math.floor((seconds % 3600) / 60);
|
||||
let s = Math.floor(seconds % 60);
|
||||
|
||||
let res = [];
|
||||
if (d > 0) res.push(d + '{{ _("d") }}');
|
||||
if (h > 0 || d > 0) res.push(h + '{{ _("h") }}');
|
||||
if (m > 0 || h > 0 || d > 0) res.push(m + '{{ _("m") }}');
|
||||
if (s > 0 || res.length === 0) res.push(s + '{{ _("s") }}');
|
||||
return res.join(' ');
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
fetch('{{ url_for("printer.api_status_data") }}')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
if(data.status && data.status.state) {
|
||||
document.getElementById('printer-state-text').innerText = data.status.state.text || 'Unknown';
|
||||
}
|
||||
if(data.status && data.status.temperature) {
|
||||
const tool = data.status.temperature.tool0 || {};
|
||||
const bed = data.status.temperature.bed || {};
|
||||
document.getElementById('tool-actual').innerText = tool.actual !== undefined ? tool.actual : 0;
|
||||
document.getElementById('tool-target').innerText = tool.target !== undefined ? tool.target : 0;
|
||||
document.getElementById('bed-actual').innerText = bed.actual !== undefined ? bed.actual : 0;
|
||||
document.getElementById('bed-target').innerText = bed.target !== undefined ? bed.target : 0;
|
||||
}
|
||||
const jobCard = document.getElementById('active-job-card');
|
||||
if(data.job && data.job.job && data.job.job.file && data.job.job.file.name) {
|
||||
jobCard.style.display = 'block';
|
||||
document.getElementById('job-file-name').innerText = data.job.job.file.display_name || data.job.job.file.name;
|
||||
let progress = data.job.progress && data.job.progress.completion ? data.job.progress.completion : 0;
|
||||
document.getElementById('job-progress-bar').style.width = progress + '%';
|
||||
document.getElementById('job-progress-bar').setAttribute('aria-valuenow', progress);
|
||||
document.getElementById('job-progress-text').innerText = progress.toFixed(1) + '%';
|
||||
document.getElementById('job-print-time').innerText = formatTime(data.job.progress ? data.job.progress.printTime : 0);
|
||||
document.getElementById('job-time-left').innerText = formatTime(data.job.progress ? data.job.progress.printTimeLeft : 0);
|
||||
} else {
|
||||
jobCard.style.display = 'none';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Error fetching status:", err));
|
||||
}
|
||||
{% if not error %}
|
||||
// Run once immediately to populate initial data consistently
|
||||
updateStatus();
|
||||
setInterval(updateStatus, 1000);
|
||||
{% endif %}
|
||||
|
||||
function sendCmd(cmd) {
|
||||
if(cmd === 'cancel') {
|
||||
window.customConfirm("{{ _('Are you sure you want to cancel the print?') }}", () => doSendCmd(cmd));
|
||||
@@ -99,12 +152,11 @@ function doSendCmd(cmd) {
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
window.location.reload();
|
||||
updateStatus();
|
||||
} else {
|
||||
window.customAlert("Error: " + data.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
setTimeout(() => { if (!window.pauseRefresh) window.location.reload(); }, 15000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
134
app/templates/slice/account.html
Normal file
@@ -0,0 +1,134 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row g-4 mt-1">
|
||||
<div class="col-12 d-flex justify-content-between align-items-center">
|
||||
<h4 class="mb-0 fw-bold"><i class="bi bi-person-badge me-2"></i>{{ _('Account Management') }}</h4>
|
||||
</div>
|
||||
|
||||
<!-- Password Change Section -->
|
||||
<div class="col-lg-5">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-header bg-white pt-3 pb-2 border-bottom-0">
|
||||
<h5 class="card-title fw-bold text-primary mb-0"><i class="bi bi-shield-lock me-2"></i>{{ _('Change Password') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('main.account') }}" method="POST">
|
||||
<input type="hidden" name="action" value="change_password">
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small fw-bold">{{ _('Current Password') }}</label>
|
||||
<input type="password" name="current_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-muted small fw-bold">{{ _('New Password') }}</label>
|
||||
<input type="password" name="new_password" class="form-control" required minlength="6">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label text-muted small fw-bold">{{ _('Confirm New Password') }}</label>
|
||||
<input type="password" name="confirm_password" class="form-control" required minlength="6">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 fw-bold rounded-pill"><i class="bi bi-check-circle me-2"></i>{{ _('Update Password') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions Section -->
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-header bg-white pt-3 pb-2 border-bottom-0 d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title fw-bold text-success mb-0"><i class="bi bi-laptop me-2"></i>{{ _('Active Sessions') }}</h5>
|
||||
<span class="badge bg-success rounded-pill">{{ sessions|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-borderless align-middle mb-0">
|
||||
<thead class="table-light text-muted small">
|
||||
<tr>
|
||||
<th class="ps-4 fw-normal">{{ _('Device') }}</th>
|
||||
<th class="fw-normal">{{ _('IP Address') }}</th>
|
||||
<th class="fw-normal">{{ _('Last Active') }}</th>
|
||||
<th class="text-end pe-4 fw-normal">{{ _('Action') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in sessions %}
|
||||
<tr class="{% if s.session_token == current_token %}bg-light{% endif %}">
|
||||
<td class="ps-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-display me-2 text-secondary fs-5"></i>
|
||||
<div>
|
||||
{% set device_name = _('Unknown Device') %}
|
||||
{% if s.user_agent %}
|
||||
{% set ua = s.user_agent|lower %}
|
||||
{% if 'windows' in ua %}
|
||||
{% set device_name = 'Windows' %}
|
||||
{% elif 'android' in ua %}
|
||||
{% set device_name = 'Android' %}
|
||||
{% elif 'iphone' in ua or 'ipad' in ua %}
|
||||
{% set device_name = 'iOS' %}
|
||||
{% elif 'mac' in ua or 'darwin' in ua %}
|
||||
{% set device_name = 'macOS' %}
|
||||
{% elif 'linux' in ua %}
|
||||
{% set device_name = 'Linux' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="text-truncate" style="max-width: 150px; cursor: help;" title="{{ s.user_agent or _('Unknown Device') }}">{{ device_name }}</div>
|
||||
{% if s.session_token == current_token %}
|
||||
<span class="badge bg-primary mt-1">{{ _('This Device') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary font-monospace">{{ s.ip_address }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted local-time" data-utc="{{ s.last_active.isoformat() }}Z">{{ s.last_active.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
{% if s.session_token != current_token %}
|
||||
<form action="{{ url_for('main.account') }}" method="POST" class="d-inline" id="form-{{ s.id }}">
|
||||
<input type="hidden" name="action" value="terminate_session">
|
||||
<input type="hidden" name="session_id" value="{{ s.id }}">
|
||||
<input type="hidden" name="session_token" value="{{ s.session_token }}">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger px-3 rounded-pill" onclick="customConfirm('{{ _('Are you sure you want to terminate this session?') }}', () => document.getElementById('form-{{ s.id }}').submit());"><i class="bi bi-x-octagon me-1"></i>{{ _('Logout') }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-outline-secondary px-3 rounded-pill" onclick="customConfirm('{{ _('Logout from this device?') }}', () => window.location.href='{{ url_for('auth.logout') }}');"><i class="bi bi-box-arrow-right me-1"></i>{{ _('Logout') }}</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-4 text-muted">{{ _('No active sessions found.') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
document.querySelectorAll('.local-time').forEach(function(el) {
|
||||
const utcDate = new Date(el.getAttribute('data-utc'));
|
||||
if (!isNaN(utcDate)) {
|
||||
|
||||
const pad = (n) => n.toString().padStart(2, '0');
|
||||
const yyyy = utcDate.getFullYear();
|
||||
const MM = pad(utcDate.getMonth() + 1);
|
||||
const dd = pad(utcDate.getDate());
|
||||
const HH = pad(utcDate.getHours());
|
||||
const mm = pad(utcDate.getMinutes());
|
||||
const ss = pad(utcDate.getSeconds());
|
||||
|
||||
el.textContent = `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -39,12 +39,15 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for file in files %}
|
||||
<tr id="file-row-{{ file.id }}" data-status="{{ file.status }}">
|
||||
{% set is_gcode = file.original_filename.lower().endswith('.gcode') or file.original_filename.lower().endswith('.gco') or file.original_filename.lower().endswith('.g') %}
|
||||
<tr id="file-row-{{ file.id }}" data-status="{{ file.status }}" data-is-gcode="{{ 'true' if is_gcode else 'false' }}">
|
||||
<td class="ps-4 text-muted">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
<span class="local-time" data-utc="{{ file.created_at.isoformat() }}">{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||
</td>
|
||||
<td class="fw-medium">{{ file.original_filename }}</td>
|
||||
<td class="fw-medium">
|
||||
<i class="bi {{ 'bi-file-earmark-code text-success' if is_gcode else 'bi-box text-primary' }} me-2"></i>{{ file.original_filename }}
|
||||
</td>
|
||||
<td id="status-{{ file.id }}">
|
||||
{% if file.status == 'waiting' %}
|
||||
<span class="badge bg-info text-dark rounded-pill fw-normal px-2" title="{{ _('Waiting in queue for slicing') }}"><i class="bi bi-hourglass-split me-1"></i>{{ _('Waiting') }}...</span>
|
||||
@@ -64,7 +67,9 @@
|
||||
</td>
|
||||
<td class="pe-4">
|
||||
<div class="d-flex gap-2" id="actions-container-{{ file.id }}">
|
||||
{% if not is_gcode %}
|
||||
<a href="{{ url_for('main.plater') }}?add={{ file.id }}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>
|
||||
{% endif %}
|
||||
{% if file.status == 'sliced' %}
|
||||
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-primary shadow-sm" title="{{ _('Download GCode') }}"><i class="bi bi-download"></i></a>
|
||||
<a href="{{ url_for('main.preview_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-info shadow-sm" title="{{ _('GCode Preview') }}"><i class="bi bi-eye"></i></a>
|
||||
@@ -138,7 +143,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
let actionsHtml = '';
|
||||
const platerUrl = `{{ url_for('main.plater') }}?add=${id}`;
|
||||
const isGcode = tr.getAttribute('data-is-gcode') === 'true';
|
||||
if (!isGcode) {
|
||||
actionsHtml += `<a href="${platerUrl}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>\n`;
|
||||
}
|
||||
|
||||
if (status === 'sliced') {
|
||||
const downloadUrl = `{{ url_for('main.download_gcode', file_id=999999999) }}`.replace('999999999', id);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2"><i class="bi bi-eye text-primary me-2"></i>{{ _('GCode Preview') }}: {{ file.original_filename }}</h1>
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-2 border-bottom">
|
||||
<div>
|
||||
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-primary btn-sm rounded shadow-sm"><i class="bi bi-download"></i> {{ _('Download GCode') }}</a>
|
||||
<h1 class="h2 mb-1"><i class="bi bi-eye text-primary me-2"></i>{{ _('GCode Preview') }}: {{ file.original_filename }}</h1>
|
||||
<div class="text-muted small">
|
||||
<span class="me-3"><i class="bi bi-clock-history me-1"></i>{{ _('Estimated Time:') }} <span class="fw-bold">{{ time_info }}</span></span>
|
||||
<span class="me-3"><i class="bi bi-layers me-1"></i>{{ _('First Layer Time:') }} <span class="fw-bold">{{ layer1_time }}</span></span>
|
||||
<span><i class="bi bi-rulers me-1"></i>{{ _('Filament Used [mm]:') }} <span class="fw-bold">{{ filament_used }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 mt-md-0">
|
||||
<a href="{{ url_for('printer.prepare') }}#file-{{ file.id }}" class="btn btn-warning btn-sm rounded shadow-sm fw-bold"><i class="bi bi-printer"></i> {{ _('Go to Print') }}</a>
|
||||
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-primary btn-sm rounded shadow-sm ms-2"><i class="bi bi-download"></i> {{ _('Download GCode') }}</a>
|
||||
<a href="{{ url_for('main.files') }}" class="btn btn-outline-secondary btn-sm rounded ms-2 shadow-sm"><i class="bi bi-arrow-left"></i> {{ _('Back') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,15 +28,7 @@
|
||||
<div id="canvas-container" class="w-100 h-100 d-block overflow-hidden"></div>
|
||||
|
||||
<!-- Legend Overlay -->
|
||||
<div class="position-absolute top-0 start-0 m-3 p-2 rounded shadow bg-dark bg-opacity-75 border border-secondary" style="color: #eee; font-size: 0.85rem; pointer-events: auto; z-index: 10;">
|
||||
<div class="mb-1 legend-item user-select-none" data-type="WALL-OUTER" 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: #eb8b38;"></span>{{ _('Outer Wall') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="WALL-INNER" 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: #4080cf;"></span>{{ _('Inner Wall') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="FILL" 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: #ccc04b;"></span>{{ _('Infill') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="SKIN" 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: #9e60b3;"></span>{{ _('Skin/TopBottom') }}</div>
|
||||
<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; 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 id="legend-overlay" class="position-absolute top-0 start-0 m-3 p-2 rounded shadow bg-dark bg-opacity-75 border border-secondary" style="color: #eee; font-size: 0.85rem; pointer-events: auto; z-index: 10;">
|
||||
</div>
|
||||
|
||||
<!-- Bottom Slider (Intra-Layer Progress) -->
|
||||
@@ -56,17 +56,6 @@
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const COLORS = {
|
||||
'WALL-OUTER': new THREE.Color(0xeb8b38),
|
||||
'WALL-INNER': new THREE.Color(0x4080cf),
|
||||
'FILL': new THREE.Color(0xccc04b),
|
||||
'SKIN': new THREE.Color(0x9e60b3),
|
||||
'SUPPORT': new THREE.Color(0x57b357),
|
||||
'SKIRT': new THREE.Color(0x00ffff),
|
||||
'SUPPORT-INTERFACE': new THREE.Color(0x2b6b2b),
|
||||
'TRAVEL': new THREE.Color(0x405060),
|
||||
'DEFAULT': new THREE.Color(0xaaaaaa),
|
||||
};
|
||||
|
||||
// Inject printer machine dimensions via Jinja
|
||||
const bedWidth = {{ machine_width | default(220) }};
|
||||
@@ -76,10 +65,37 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
const offsetY = {{ offset_y | default(0.0) }};
|
||||
|
||||
// Type indices for shader visibility filtering
|
||||
const TYPE_INDEX = {
|
||||
'TRAVEL': 0, 'WALL-OUTER': 1, 'WALL-INNER': 2,
|
||||
'FILL': 3, 'SKIN': 4, 'SUPPORT': 5, 'DEFAULT': 6,
|
||||
'SKIRT': 7, 'SUPPORT-INTERFACE': 8
|
||||
let COLORS = {};
|
||||
let TYPE_INDEX = {};
|
||||
let gcodeMat = null;
|
||||
|
||||
const SLICER_CONFIGS = {
|
||||
'Cura': [
|
||||
{ id: 'TRAVEL', label: '{{ _("Travel (Move)") }}', color: 0x405060, defaultShow: false },
|
||||
{ id: 'WALL-OUTER', label: '{{ _("Outer Wall") }}', color: 0xeb8b38, defaultShow: true },
|
||||
{ id: 'WALL-INNER', label: '{{ _("Inner Wall") }}', color: 0x4080cf, defaultShow: true },
|
||||
{ id: 'FILL', label: '{{ _("Infill") }}', color: 0xccc04b, defaultShow: true },
|
||||
{ id: 'SKIN', label: '{{ _("Skin/TopBottom") }}', color: 0x9e60b3, defaultShow: true },
|
||||
{ id: 'SUPPORT', label: '{{ _("Support") }}', color: 0x57b357, defaultShow: true },
|
||||
{ id: 'SKIRT', label: '{{ _("Skirt") }}', color: 0x00ffff, defaultShow: true },
|
||||
{ id: 'SUPPORT-INTERFACE', label: '{{ _("Support Interface") }}', color: 0x2b6b2b, defaultShow: true },
|
||||
{ id: 'DEFAULT', label: '{{ _("Others") }}', color: 0xaaaaaa, defaultShow: true }
|
||||
],
|
||||
'Prusa': [
|
||||
{ id: 'TRAVEL', label: '{{ _("Travel (Move)") }}', color: 0x405060, defaultShow: false },
|
||||
{ id: 'Custom', label: '{{ _("Custom") }}', color: 0xd0e0ff, defaultShow: true },
|
||||
{ id: 'Skirt/Brim', label: '{{ _("Skirt/Brim") }}', color: 0x00FFFF, defaultShow: true },
|
||||
{ id: 'Support material', label: '{{ _("Support material") }}', color: 0x90EE90, defaultShow: true },
|
||||
{ id: 'Perimeter', label: '{{ _("Perimeter") }}', color: 0xFFFFE0, defaultShow: true },
|
||||
{ id: 'External perimeter', label: '{{ _("External perimeter") }}', color: 0xFFA500, defaultShow: true },
|
||||
{ id: 'Solid infill', label: '{{ _("Solid infill") }}', color: 0x800080, defaultShow: true },
|
||||
{ id: 'Overhang perimeter', label: '{{ _("Overhang perimeter") }}', color: 0x00008B, defaultShow: true },
|
||||
{ id: 'Internal infill', label: '{{ _("Internal infill") }}', color: 0x8B0000, defaultShow: true },
|
||||
{ id: 'Bridge infill', label: '{{ _("Bridge infill") }}', color: 0x0000FF, defaultShow: true },
|
||||
{ id: 'Top solid infill', label: '{{ _("Top solid infill") }}', color: 0xFF0000, defaultShow: true },
|
||||
{ id: 'Support material interface', label: '{{ _("Support Interface") }}', color: 0x2b6b2b, defaultShow: true },
|
||||
{ id: 'DEFAULT', label: '{{ _("Others") }}', color: 0xaaaaaa, defaultShow: true }
|
||||
]
|
||||
};
|
||||
|
||||
let layers = [];
|
||||
@@ -90,19 +106,50 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
const layerDisplay = document.getElementById('layer-display');
|
||||
const progressSlider = document.getElementById('progress-slider');
|
||||
|
||||
// Shader material for high-speed dynamic feature visibility
|
||||
const gcodeMat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uShowOuter: { value: 1.0 },
|
||||
uShowInner: { value: 1.0 },
|
||||
uShowInfill: { value: 1.0 },
|
||||
uShowSkin: { value: 1.0 },
|
||||
uShowSupport: { value: 1.0 },
|
||||
uShowSkirt: { value: 1.0 },
|
||||
uShowSupportInterface: { value: 1.0 },
|
||||
uShowTravel: { value: 0.0 },
|
||||
uShowDefault: { value: 1.0 }
|
||||
},
|
||||
function setupSlicerConfig(text) {
|
||||
let slicerType = 'Cura'; // default
|
||||
if (text.substring(0, 500).includes('generated by PrusaSlicer')) {
|
||||
slicerType = 'Prusa';
|
||||
}
|
||||
|
||||
const config = SLICER_CONFIGS[slicerType];
|
||||
|
||||
// 1. Build uniforms & shader strings dynamically
|
||||
let uniformsObj = {};
|
||||
let fragmentUniformsDecl = '';
|
||||
let fragmentUniformsLogic = '';
|
||||
|
||||
let overlayHTML = '';
|
||||
|
||||
config.forEach((c, idx) => {
|
||||
COLORS[c.id] = new THREE.Color(c.color);
|
||||
TYPE_INDEX[c.id] = idx;
|
||||
|
||||
const uniformName = 'uShow' + idx;
|
||||
uniformsObj[uniformName] = { value: c.defaultShow ? 1.0 : 0.0 };
|
||||
|
||||
fragmentUniformsDecl += `uniform float ${uniformName};\n`;
|
||||
|
||||
if (idx === 0) {
|
||||
fragmentUniformsLogic += `if (t == 0) show = ${uniformName};\n`;
|
||||
} else {
|
||||
fragmentUniformsLogic += ` else if (t == ${idx}) show = ${uniformName};\n`;
|
||||
}
|
||||
|
||||
// Build Legend UI
|
||||
const hexColor = '#' + c.color.toString(16).padStart(6, '0');
|
||||
const opacityStyle = c.defaultShow ? '1.0' : '0.4';
|
||||
overlayHTML += `
|
||||
<div class="mb-1 legend-item user-select-none" data-id="${c.id}" data-uniform="${uniformName}" style="cursor: pointer; transition: opacity 0.2s; opacity: ${opacityStyle};"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: ${hexColor};"></span>${c.label}</div>`;
|
||||
});
|
||||
|
||||
// Add fallback condition
|
||||
fragmentUniformsLogic += ` else show = 1.0;\n`;
|
||||
|
||||
document.getElementById('legend-overlay').innerHTML = overlayHTML;
|
||||
|
||||
gcodeMat = new THREE.ShaderMaterial({
|
||||
uniforms: uniformsObj,
|
||||
vertexShader: `
|
||||
attribute float pType;
|
||||
varying vec3 vColor;
|
||||
@@ -116,28 +163,11 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
fragmentShader: `
|
||||
varying vec3 vColor;
|
||||
varying float vType;
|
||||
uniform float uShowOuter;
|
||||
uniform float uShowInner;
|
||||
uniform float uShowInfill;
|
||||
uniform float uShowSkin;
|
||||
uniform float uShowSupport;
|
||||
uniform float uShowSkirt;
|
||||
uniform float uShowSupportInterface;
|
||||
uniform float uShowTravel;
|
||||
uniform float uShowDefault;
|
||||
${fragmentUniformsDecl}
|
||||
void main() {
|
||||
float show = 1.0;
|
||||
int t = int(vType + 0.5);
|
||||
if (t == 0) show = uShowTravel;
|
||||
else if (t == 1) show = uShowOuter;
|
||||
else if (t == 2) show = uShowInner;
|
||||
else if (t == 3) show = uShowInfill;
|
||||
else if (t == 4) show = uShowSkin;
|
||||
else if (t == 5) show = uShowSupport;
|
||||
else if (t == 7) show = uShowSkirt;
|
||||
else if (t == 8) show = uShowSupportInterface;
|
||||
else show = uShowDefault;
|
||||
|
||||
${fragmentUniformsLogic}
|
||||
if (show < 0.5) discard;
|
||||
gl_FragColor = vec4(vColor, 1.0);
|
||||
}
|
||||
@@ -147,23 +177,11 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
linewidth: 1
|
||||
});
|
||||
|
||||
// Binding the Legend Buttons
|
||||
const uniformMap = {
|
||||
'WALL-OUTER': 'uShowOuter',
|
||||
'WALL-INNER': 'uShowInner',
|
||||
'FILL': 'uShowInfill',
|
||||
'SKIN': 'uShowSkin',
|
||||
'SUPPORT': 'uShowSupport',
|
||||
'SKIRT': 'uShowSkirt',
|
||||
'SUPPORT-INTERFACE': 'uShowSupportInterface',
|
||||
'TRAVEL': 'uShowTravel'
|
||||
};
|
||||
|
||||
// Legend binding
|
||||
document.querySelectorAll('.legend-item').forEach(el => {
|
||||
el.addEventListener('click', function() {
|
||||
const t = this.dataset.type;
|
||||
const uniformName = uniformMap[t];
|
||||
if (uniformName) {
|
||||
const uniformName = this.dataset.uniform;
|
||||
if (uniformName && gcodeMat.uniforms[uniformName]) {
|
||||
const currentVal = gcodeMat.uniforms[uniformName].value;
|
||||
const newVal = currentVal > 0.5 ? 0.0 : 1.0;
|
||||
gcodeMat.uniforms[uniformName].value = newVal;
|
||||
@@ -171,6 +189,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function init3D() {
|
||||
const container = document.getElementById('canvas-container');
|
||||
@@ -218,6 +237,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
document.getElementById('loading-overlay').classList.add('d-none');
|
||||
document.getElementById('preview-container').classList.remove('d-none');
|
||||
|
||||
setupSlicerConfig(gcodeText);
|
||||
init3D();
|
||||
parseGCode(gcodeText);
|
||||
|
||||
@@ -249,6 +269,13 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
const lines = text.split('\n');
|
||||
|
||||
let current = { x: 0, y: 0, z: 0, e: 0 };
|
||||
let relativeE = false; // Track M83 (relative) vs M82 (absolute)
|
||||
|
||||
// Dynamically compute width and layer height based on gcode info if possible
|
||||
let extWidth = 0.4;
|
||||
let layerHeight = 0.2;
|
||||
let pWidth = extWidth;
|
||||
|
||||
let currentTypeStr = 'DEFAULT';
|
||||
|
||||
let currentExtrudePoints = [];
|
||||
@@ -291,41 +318,84 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let chunk = lines[i].trim().toUpperCase();
|
||||
let chunk = lines[i].trim();
|
||||
if (!chunk) continue;
|
||||
let upperChunk = chunk.toUpperCase();
|
||||
|
||||
if (chunk.startsWith(';LAYER:')) {
|
||||
if (upperChunk.startsWith('M82')) relativeE = false;
|
||||
else if (upperChunk.startsWith('M83')) relativeE = true;
|
||||
|
||||
if (upperChunk.startsWith(';LAYER:') || upperChunk.startsWith(';LAYER_CHANGE')) {
|
||||
flushLayer();
|
||||
} else if (chunk.startsWith(';TYPE:')) {
|
||||
} else if (upperChunk.startsWith(';LAYER_HEIGHT:')) {
|
||||
let lh = parseFloat(chunk.substring(14));
|
||||
if (!isNaN(lh) && lh > 0) layerHeight = lh;
|
||||
} else if (upperChunk.startsWith(';HEIGHT:')) {
|
||||
let lh = parseFloat(chunk.substring(8));
|
||||
if (!isNaN(lh) && lh > 0) layerHeight = lh;
|
||||
} else if (upperChunk.startsWith(';WIDTH:')) {
|
||||
let w = parseFloat(chunk.substring(7));
|
||||
if (!isNaN(w) && w > 0) pWidth = w;
|
||||
} else if (upperChunk.startsWith(';TYPE:')) {
|
||||
currentTypeStr = chunk.substring(6).trim();
|
||||
} else if (chunk.startsWith('G0') || chunk.startsWith('G1')) {
|
||||
} else if (chunk.startsWith(';') && COLORS[chunk.substring(1).trim()] !== undefined) {
|
||||
currentTypeStr = chunk.substring(1).trim();
|
||||
} else if (upperChunk.startsWith(';') && chunk.includes(' perimeter')) {
|
||||
currentTypeStr = chunk.substring(1).trim();
|
||||
} else if (upperChunk.startsWith(';') && chunk.includes(' infill')) {
|
||||
// Heuristics for Prusa/Slic3r specific comments like `; Internal infill`
|
||||
currentTypeStr = chunk.substring(1).trim();
|
||||
} else if (upperChunk.startsWith(';') && chunk.includes(' material')) {
|
||||
// Support material
|
||||
currentTypeStr = chunk.substring(1).trim();
|
||||
} else if (upperChunk.startsWith(';') && chunk.includes('Skirt/Brim')) {
|
||||
// Skirt/Brim
|
||||
currentTypeStr = 'Skirt/Brim';
|
||||
} else if (upperChunk.startsWith('G0') || upperChunk.startsWith('G1')) {
|
||||
let next = { x: current.x, y: current.y, z: current.z, e: current.e };
|
||||
let parts = chunk.split(/\s+/);
|
||||
let parts = upperChunk.split(/\s+/);
|
||||
let hasMove = false;
|
||||
let hasE = false;
|
||||
let eVal = 0;
|
||||
|
||||
for (let p of parts) {
|
||||
if (p.startsWith('X')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.x = v; hasMove = true; } }
|
||||
if (p.startsWith('Y')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.y = v; hasMove = true; } }
|
||||
if (p.startsWith('Z')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.z = v; hasMove = true; } }
|
||||
if (p.startsWith('E')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.e = v; } }
|
||||
if (p.startsWith('E')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { eVal = v; hasE = true; } }
|
||||
}
|
||||
|
||||
if (hasMove && !isNaN(next.x) && !isNaN(next.y) && !isNaN(next.z)) {
|
||||
let isExtrude = (next.e > current.e);
|
||||
let isExtrude = false;
|
||||
if (hasE) {
|
||||
if (relativeE) {
|
||||
next.e = current.e + eVal;
|
||||
isExtrude = eVal > 0;
|
||||
} else {
|
||||
next.e = eVal;
|
||||
isExtrude = next.e > current.e;
|
||||
}
|
||||
}
|
||||
// Cura uses G0 for travel generally
|
||||
if (chunk.startsWith('G0') && !chunk.includes('E')) isExtrude = false;
|
||||
if (upperChunk.startsWith('G0') && !upperChunk.includes('E')) isExtrude = false;
|
||||
|
||||
let activeType = isExtrude ? currentTypeStr : 'TRAVEL';
|
||||
let col = COLORS[activeType] || COLORS['DEFAULT'];
|
||||
let tIdx = TYPE_INDEX[activeType] !== undefined ? TYPE_INDEX[activeType] : TYPE_INDEX['DEFAULT'];
|
||||
let resolvedType = activeType;
|
||||
|
||||
if (isExtrude && COLORS[activeType] === undefined) {
|
||||
resolvedType = 'DEFAULT';
|
||||
}
|
||||
|
||||
let col = COLORS[resolvedType] || COLORS['DEFAULT'];
|
||||
let tIdx = TYPE_INDEX[resolvedType] !== undefined ? TYPE_INDEX[resolvedType] : TYPE_INDEX['DEFAULT'];
|
||||
|
||||
if (isExtrude) {
|
||||
let dx = next.x - current.x;
|
||||
let dy = next.y - current.y;
|
||||
let dist = Math.sqrt(dx*dx + dy*dy);
|
||||
if (dist > 0.0001) {
|
||||
let hw = 0.4 / 2.0; // 0.4mm wire width
|
||||
let hh = 0.2 / 2.0; // 0.2mm layer height roughly
|
||||
let hw = pWidth / 2.0;
|
||||
let hh = layerHeight / 2.0;
|
||||
|
||||
let nx = -(dy / dist) * hw;
|
||||
let ny = (dx / dist) * hw;
|
||||
@@ -381,15 +451,23 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
for(let k=0; k<6; k++) { currentExtrudeColors.push(col.r*0.8, col.g*0.8, col.b*0.8); currentExtrudeTypes.push(tIdx); }
|
||||
}
|
||||
} else {
|
||||
currentTravelPoints.push(current.x, current.y, current.z);
|
||||
currentTravelPoints.push(next.x, next.y, next.z);
|
||||
// Travel lines get slight vertical offset for visibility
|
||||
let zOff = 0.05;
|
||||
currentTravelPoints.push(current.x, current.y, current.z + zOff);
|
||||
currentTravelPoints.push(next.x, next.y, next.z + zOff);
|
||||
currentTravelColors.push(col.r, col.g, col.b, col.r, col.g, col.b);
|
||||
currentTravelTypes.push(tIdx, tIdx);
|
||||
}
|
||||
|
||||
current.x = next.x; current.y = next.y; current.z = next.z; current.e = next.e;
|
||||
// Update E based on parsed G-code execution type
|
||||
if (hasE) {
|
||||
if (relativeE) current.e += eVal;
|
||||
else current.e = eVal;
|
||||
}
|
||||
} else if (chunk.startsWith('G92')) {
|
||||
|
||||
current.x = next.x; current.y = next.y; current.z = next.z;
|
||||
}
|
||||
} else if (upperChunk.startsWith('G92')) {
|
||||
let parts = chunk.split(/\s+/);
|
||||
for (let p of parts) {
|
||||
if (p.startsWith('E')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) current.e = v; }
|
||||
|
||||
56
app/templates/slice/helper_slice.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% 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">{{ _('Slice Helper') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Helper Content -->
|
||||
<div class="col-12 markdown-body p-4 bg-white rounded shadow-sm">
|
||||
{% if content_html %}
|
||||
{{ content_html|safe }}
|
||||
{% else %}
|
||||
<p class="text-muted">{{ _('Documentation not available.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 简单的 Markdown 样式优化 */
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-body h1 {
|
||||
font-size: 2em;
|
||||
padding-bottom: .3em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
}
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.markdown-body pre {
|
||||
background-color: #f6f8fa;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.markdown-body code {
|
||||
font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;
|
||||
}
|
||||
.markdown-body table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
color: #212529;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.markdown-body table th, .markdown-body table td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -6,11 +6,50 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<!-- STL Files Stats -->
|
||||
<div class="col-md-6">
|
||||
<div class="card text-white bg-primary mb-3 shadow-sm border-0">
|
||||
<div class="card-header border-0 fs-5 fw-medium"><i class="bi bi-bar-chart-fill me-2"></i>{{ _('Total Prints') }}</div>
|
||||
<div class="card-header border-0 fs-5 fw-medium">
|
||||
<i class="bi bi-box me-2"></i>{{ _('3D Model Files (STL)') }}
|
||||
</div>
|
||||
<div class="card-body mt-2">
|
||||
<h5 class="card-title">{{ _('You have sliced') }} <b class="fs-1 mx-2">{{ current_user.print_files|length }}</b> {{ _('files') }}</h5>
|
||||
<h5 class="card-title mb-3">
|
||||
{{ _('You have uploaded') }} <b class="fs-1 mx-2">{{ stl_count }}</b> {{ _('files') }}
|
||||
</h5>
|
||||
<p class="card-text mb-2">
|
||||
<i class="bi bi-hdd-fill me-1"></i>{{ _('Total Space Used') }}: <strong>{{ format_size(stl_used_bytes) }}</strong>
|
||||
{% if stl_quota_mb > 0 %} / <strong>{{ stl_quota_mb }} MB</strong> <small class="opacity-75">({{ _('Quota') }})</small>{% else %} <small class="opacity-75">({{ _('Unlimited') }})</small>{% endif %}
|
||||
</p>
|
||||
{% if stl_quota_mb > 0 %}
|
||||
{% set stl_percent = (stl_used_bytes / (stl_quota_mb * 1024 * 1024) * 100)|round(1) %}
|
||||
<div class="progress bg-white bg-opacity-25" style="height: 8px;">
|
||||
<div class="progress-bar {% if stl_percent > 90 %}bg-danger{% elif stl_percent > 75 %}bg-warning{% else %}bg-info{% endif %}" role="progressbar" style="width: {{ stl_percent if stl_percent <= 100 else 100 }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GCode Files Stats -->
|
||||
<div class="col-md-6">
|
||||
<div class="card text-white bg-success mb-3 shadow-sm border-0">
|
||||
<div class="card-header border-0 fs-5 fw-medium">
|
||||
<i class="bi bi-file-earmark-code me-2"></i>{{ _('Sliced Files (GCode)') }}
|
||||
</div>
|
||||
<div class="card-body mt-2">
|
||||
<h5 class="card-title mb-3">
|
||||
{{ _('You have sliced or uploaded') }} <b class="fs-1 mx-2">{{ gcode_count }}</b> {{ _('files') }}
|
||||
</h5>
|
||||
<p class="card-text mb-2">
|
||||
<i class="bi bi-hdd-network-fill me-1"></i>{{ _('Total Space Used') }}: <strong>{{ format_size(gcode_used_bytes) }}</strong>
|
||||
{% if gcode_quota_mb > 0 %} / <strong>{{ gcode_quota_mb }} MB</strong> <small class="opacity-75">({{ _('Quota') }})</small>{% else %} <small class="opacity-75">({{ _('Unlimited') }})</small>{% endif %}
|
||||
</p>
|
||||
{% if gcode_quota_mb > 0 %}
|
||||
{% set gc_percent = (gcode_used_bytes / (gcode_quota_mb * 1024 * 1024) * 100)|round(1) %}
|
||||
<div class="progress bg-white bg-opacity-25" style="height: 8px;">
|
||||
<div class="progress-bar {% if gc_percent > 90 %}bg-danger{% elif gc_percent > 75 %}bg-warning{% else %}bg-info{% endif %}" role="progressbar" style="width: {{ gc_percent if gc_percent <= 100 else 100 }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,18 +95,21 @@
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label for="support-pattern" class="form-label text-secondary small mb-1">{{ _('Support Type') }}</label>
|
||||
<select class="form-select form-select-sm" id="support-pattern" {% if default_support == 'false' %}disabled{% endif %}>
|
||||
<option value="tree" {% if default_support_pattern == 'tree' %}selected{% endif %}>{{ _('Tree') }}</option>
|
||||
<option value="lines" {% if default_support_pattern == 'lines' %}selected{% endif %}>{{ _('Lines') }}</option>
|
||||
<option value="grid" {% if default_support_pattern == 'grid' %}selected{% endif %}>{{ _('Grid') }}</option>
|
||||
<option value="triangles" {% if default_support_pattern == 'triangles' %}selected{% endif %}>{{ _('Triangles') }}</option>
|
||||
<option value="concentric" {% if default_support_pattern == 'concentric' %}selected{% endif %}>{{ _('Concentric') }}</option>
|
||||
<option value="zigzag" {% if default_support_pattern == 'zigzag' %}selected{% endif %}>{{ _('Zig Zag') }}</option>
|
||||
<option value="cross" {% if default_support_pattern == 'cross' %}selected{% endif %}>{{ _('Cross') }}</option>
|
||||
<option value="gyroid" {% if default_support_pattern == 'gyroid' %}selected{% endif %}>{{ _('Gyroid') }}</option>
|
||||
<option value="honeycomb" {% if default_support_pattern == 'honeycomb' %}selected{% endif %}>{{ _('Honeycomb') }}</option>
|
||||
<option value="octagon" {% if default_support_pattern == 'octagon' %}selected{% endif %}>{{ _('Octagon') }}</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm" id="support-pattern" data-selected="{{ default_support_pattern }}" {% if default_support == 'false' %}disabled{% endif %}></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm flex-shrink-0 mb-3">
|
||||
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center collapsed" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapseMaterial" aria-expanded="false">
|
||||
<span><i class="bi bi-box me-2"></i>{{ _('Material Profile') }}</span>
|
||||
<i class="bi bi-chevron-bar-contract"></i>
|
||||
</div>
|
||||
<div id="collapseMaterial" class="collapse" data-bs-parent="#platerSidebarAccordion">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<select class="form-select bg-light" id="material" data-selected="{{ last_material }}"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,11 +123,7 @@
|
||||
<div id="collapseQuality" class="collapse" data-bs-parent="#platerSidebarAccordion">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<select class="form-select bg-light" id="quality">
|
||||
{% for key, name in presets %}
|
||||
<option value="{{ key }}" {% if key == last_quality %}selected{% endif %}>{{ _(name) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select class="form-select bg-light" id="quality" data-selected="{{ last_quality }}"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,7 +131,7 @@
|
||||
</div> <!-- End of accordion wrapper -->
|
||||
|
||||
<div class="mt-auto pt-3 border-top d-flex flex-column gap-2 mb-1">
|
||||
<button class="btn btn-outline-danger w-100" onclick="clearPlate()"><i class="bi bi-trash me-2"></i>{{ _('Clear Board') }}</button>
|
||||
<button class="btn btn-outline-danger w-100" onclick="customConfirm('{{ _('Are you sure you want to clear the board?') }}', clearPlate)"><i class="bi bi-trash me-2"></i>{{ _('Clear Board') }}</button>
|
||||
<button class="btn btn-primary w-100 py-2 fs-5 shadow-sm" onclick="mergeAndSlice()" id="btn-merge"><i class="bi bi-gear-fill me-2" id="merge-icon"></i><span id="merge-text">{{ _('Merge & Slice') }}</span></button>
|
||||
</div>
|
||||
|
||||
@@ -148,6 +147,11 @@
|
||||
<script>
|
||||
// Toggle icons on collapse
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% if quota_exceeded %}
|
||||
window.customConfirm("{{ _('GCode Storage Quota Exceeded. Please delete some files first.') }}",()=>{window.location.href = "{{ url_for('main.files') }}"});
|
||||
return;
|
||||
{% endif %}
|
||||
|
||||
const cards = document.querySelectorAll('.collapse');
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('show.bs.collapse', function () {
|
||||
@@ -221,6 +225,18 @@ function initPlater() {
|
||||
axesHelper.position.set(-bedWidth / 2, -bedDepth / 2, 0.2);
|
||||
scene.add(axesHelper);
|
||||
|
||||
{% if configs.get('build_plate_model_path') %}
|
||||
const bpLoader = new THREE.STLLoader();
|
||||
bpLoader.load("{{ url_for('main.build_plate_model') }}", function (geometry) {
|
||||
const material = new THREE.MeshPhongMaterial({ color: 0x999999, specular: 0x111111, shininess: 200 });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
<!-- mesh.rotation.set(-Math.PI / 2, 0, 0); -->
|
||||
mesh.rotation.set(0, 0, 0);
|
||||
mesh.position.set(0, 0, -0.1); // Slightly below the grid
|
||||
scene.add(mesh);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
// Show Bed Box outline
|
||||
const boxGeo = new THREE.BoxGeometry(bedWidth, bedDepth, bedHeight);
|
||||
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||
@@ -666,8 +682,22 @@ function addModelToPlate(btnElement, fileId, url, name, status) {
|
||||
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;
|
||||
|
||||
if (data.settings.support_pattern) {
|
||||
const sSelect = document.getElementById('support-pattern');
|
||||
sSelect.setAttribute('data-selected', data.settings.support_pattern);
|
||||
sSelect.value = data.settings.support_pattern;
|
||||
}
|
||||
if (data.settings.quality) {
|
||||
const qSelect = document.getElementById('quality');
|
||||
qSelect.setAttribute('data-selected', data.settings.quality);
|
||||
qSelect.value = data.settings.quality;
|
||||
}
|
||||
if (data.settings.material) {
|
||||
const mSelect = document.getElementById('material');
|
||||
mSelect.setAttribute('data-selected', data.settings.material);
|
||||
mSelect.value = data.settings.material;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -861,7 +891,7 @@ function mergeAndSlice() {
|
||||
if (m.userData.geomTrans) {
|
||||
mat.multiply(m.userData.geomTrans);
|
||||
}
|
||||
const translation = new THREE.Matrix4().makeTranslation((bedWidth / 2) + offsetX, (bedDepth / 2) + offsetY, 0);
|
||||
const translation = new THREE.Matrix4().makeTranslation(offsetX,offsetY, 0);
|
||||
mat.premultiply(translation);
|
||||
return {
|
||||
file_id: m.userData.fileId,
|
||||
@@ -871,6 +901,7 @@ function mergeAndSlice() {
|
||||
});
|
||||
|
||||
const quality = document.getElementById('quality').value;
|
||||
const material = document.getElementById('material').value;
|
||||
const infill = document.getElementById('infill-density').value;
|
||||
const support = document.getElementById('support-type').value;
|
||||
const supportPattern = document.getElementById('support-pattern').value;
|
||||
@@ -889,14 +920,18 @@ function mergeAndSlice() {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ pieces: pieces, quality: quality, infill: infill, support: support, support_pattern: supportPattern, is_edit: isEdit, target_file_id: targetFileId })
|
||||
body: JSON.stringify({ pieces: pieces, quality: quality, material: material, infill: infill, support: support, support_pattern: supportPattern, is_edit: isEdit, target_file_id: targetFileId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
window.location.href = "{{ url_for('main.files') }}";
|
||||
} else {
|
||||
window.customAlert("{{ _('Error:') }} " + data.error);
|
||||
let errorMsg = data.error;
|
||||
if (errorMsg === 'GCode Storage Quota Exceeded. Please delete some files first.') {
|
||||
errorMsg = "{{ _('GCode Storage Quota Exceeded. Please delete some files first.') }}";
|
||||
}
|
||||
window.customAlert("{{ _('Error:') }} " + errorMsg);
|
||||
btn.disabled = false;
|
||||
icon.className = 'bi bi-gear-fill me-2';
|
||||
text.innerText = '{{ _("Merge & Slice") }}';
|
||||
@@ -939,4 +974,48 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const engine = "{{ configs.get('slicer_engine', 'cura') }}";
|
||||
const qualitySelect = document.getElementById('quality');
|
||||
const patternSelect = document.getElementById('support-pattern');
|
||||
const materialSelect = document.getElementById('material');
|
||||
|
||||
fetch(`/api/engine_options/${engine}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const selQ = qualitySelect.getAttribute('data-selected');
|
||||
qualitySelect.innerHTML = '';
|
||||
data.presets.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id; opt.textContent = p.name;
|
||||
qualitySelect.appendChild(opt);
|
||||
});
|
||||
if(selQ) qualitySelect.value = selQ;
|
||||
|
||||
const selM = materialSelect.getAttribute('data-selected');
|
||||
materialSelect.innerHTML = '';
|
||||
const emptyOpt = document.createElement('option');
|
||||
emptyOpt.value = ''; emptyOpt.textContent = "{{ _('Auto / Default') }}";
|
||||
materialSelect.appendChild(emptyOpt);
|
||||
if(data.materials) {
|
||||
data.materials.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id; opt.textContent = p.name;
|
||||
materialSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if(selM) materialSelect.value = selM;
|
||||
|
||||
const selP = patternSelect.getAttribute('data-selected');
|
||||
patternSelect.innerHTML = '';
|
||||
data.support_patterns.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id; opt.textContent = p.name;
|
||||
patternSelect.appendChild(opt);
|
||||
});
|
||||
if(selP) patternSelect.value = selP;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
88
app/utils/api_handle.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import functools
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.models import ApiKey, PrintFile, SystemConfig
|
||||
from app.utils.octoprint_client import OctoPrintClient
|
||||
|
||||
api_bp = Blueprint('api_handle', __name__, url_prefix='/api/v1')
|
||||
|
||||
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
|
||||
|
||||
def _enrich_job_data(job_data):
|
||||
if job_data and job_data.get('job', {}).get('file', {}).get('name'):
|
||||
internal_name = job_data['job']['file']['name']
|
||||
internal_stl_name = str(internal_name)[:-5]+"stl"
|
||||
pf = PrintFile.query.filter_by(filename=internal_stl_name).first()
|
||||
if pf:
|
||||
job_data['job']['file']['display_name'] = pf.original_filename
|
||||
else:
|
||||
job_data['job']['file']['display_name'] = internal_name
|
||||
return job_data
|
||||
|
||||
def require_api_key(f):
|
||||
@functools.wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
api_key_header = request.headers.get('X-Api-Key')
|
||||
if not api_key_header:
|
||||
return jsonify({'error': 'Missing API Key in headers (X-Api-Key)'}), 401
|
||||
|
||||
key_record = ApiKey.query.filter_by(key=api_key_header).first()
|
||||
if not key_record:
|
||||
return jsonify({'error': 'Invalid API Key'}), 401
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
@api_bp.route('/status', methods=['GET'])
|
||||
@require_api_key
|
||||
def get_status():
|
||||
client = get_octo_client()
|
||||
if not client:
|
||||
return jsonify({'error': 'Printer not configured'}), 503
|
||||
try:
|
||||
status_data = client.get_printer_status()
|
||||
job_data = client.get_job_info()
|
||||
job_data = _enrich_job_data(job_data)
|
||||
return jsonify({'status': status_data, 'job': job_data})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@api_bp.route('/octoprint_client', methods=['POST'])
|
||||
@require_api_key
|
||||
def invoke_octoprint_client():
|
||||
"""
|
||||
Expects JSON payload like:
|
||||
{
|
||||
"method": "pause_print",
|
||||
"kwargs": {"action": "pause"}
|
||||
}
|
||||
"""
|
||||
client = get_octo_client()
|
||||
if not client:
|
||||
return jsonify({'error': 'Printer not configured'}), 503
|
||||
|
||||
data = request.get_json()
|
||||
if not data or 'method' not in data:
|
||||
return jsonify({'error': 'Missing method in JSON payload'}), 400
|
||||
|
||||
method_name = data['method']
|
||||
kwargs = data.get('kwargs', {})
|
||||
args = data.get('args', [])
|
||||
|
||||
if not hasattr(client, method_name):
|
||||
return jsonify({'error': f'Method {method_name} not found on OctoPrintClient'}), 400
|
||||
|
||||
func = getattr(client, method_name)
|
||||
if not callable(func) or method_name.startswith('_'):
|
||||
return jsonify({'error': f'Method {method_name} is not allowed'}), 403
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return jsonify({'success': True, 'result': result})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@@ -143,7 +143,6 @@ class ConfParse:
|
||||
if evaluated != field_val and not isinstance(evaluated, type):
|
||||
if val_dict.get("type") == "str" and not isinstance(evaluated, str):
|
||||
if isinstance(evaluated, (list, dict)):
|
||||
import json
|
||||
val_dict[field] = json.dumps(evaluated).replace(" ", "")
|
||||
else:
|
||||
val_dict[field] = str(evaluated)
|
||||
|
||||
32
app/utils/gcode_parser.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
|
||||
def get_gcode_metadata(filepath):
|
||||
metadata = {
|
||||
'print_time': '-',
|
||||
'first_layer_time': '-',
|
||||
'filament_used': '-'
|
||||
}
|
||||
if not os.path.exists(filepath):
|
||||
return metadata
|
||||
|
||||
try:
|
||||
# Read the last few KB to find estimated time and filament used
|
||||
with open(filepath, 'rb') as f:
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
chunk_size = min(65536, file_size) # read last 64KB
|
||||
f.seek(file_size - chunk_size)
|
||||
chunk = f.read().decode('utf-8', errors='ignore')
|
||||
|
||||
lines = chunk.splitlines()
|
||||
for line in reversed(lines):
|
||||
if line.startswith('; estimated printing time (normal mode) ='):
|
||||
metadata['print_time'] = line.split('=')[1].strip()
|
||||
elif line.startswith('; estimated first layer printing time (normal mode) ='):
|
||||
metadata['first_layer_time'] = line.split('=')[1].strip()
|
||||
elif line.startswith('; filament used [mm] ='):
|
||||
metadata['filament_used'] = line.split('=')[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return metadata
|
||||
@@ -73,6 +73,24 @@ class OctoPrintClient:
|
||||
payload = {"command": "select", "print": print_after_select}
|
||||
return self._request("POST", f"/api/files/{location}/{path}", json=payload)
|
||||
|
||||
def upload_file(self, location, path_to_file, filename_override=None):
|
||||
"""Upload a file to OctoPrint"""
|
||||
with open(path_to_file, 'rb') as f:
|
||||
files = {'file': (filename_override or path_to_file.split('/')[-1], f, 'application/octet-stream')}
|
||||
url = urljoin(self.base_url, f"/api/files/{location}")
|
||||
response = self.session.post(url, files=files)
|
||||
response.raise_for_status()
|
||||
if response.status_code == 204:
|
||||
return True
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
|
||||
def delete_file(self, location, path):
|
||||
"""Delete a file from OctoPrint"""
|
||||
return self._request("DELETE", f"/api/files/{location}/{path}")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Printer Status
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -92,6 +110,10 @@ class OctoPrintClient:
|
||||
"""Get information about the current print job and progress."""
|
||||
return self._request("GET", "/api/job")
|
||||
|
||||
def get_printer_err_log(self):
|
||||
"""Fetch the printer error log, if available."""
|
||||
return self._request("GET", "/api/printer/error")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Printer Control
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -123,6 +145,9 @@ class OctoPrintClient:
|
||||
"""Convenience method to home the printer axes."""
|
||||
return self._request("POST", "/api/printer/printhead", json={"command": "home", "axes": axes})
|
||||
|
||||
def auto_leveling(self):
|
||||
return self.send_gcode("G29")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Webcam / Video
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
24
app/utils/slice_engines/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from .cura_engine import CuraEngine
|
||||
from .prusa_slicer_engine import PrusaSlicerEngine
|
||||
|
||||
def get_all_engines():
|
||||
"""Returns a list of instantiated engines."""
|
||||
return [
|
||||
CuraEngine(),
|
||||
PrusaSlicerEngine()
|
||||
]
|
||||
|
||||
def get_slicer_engine(engine_name="prusa", print_config_folder=None, config_slice_bin_path=None):
|
||||
"""
|
||||
Factory function to retrieve the requested slicing engine instance.
|
||||
Valid names: 'cura', 'prusa_slicer'
|
||||
"""
|
||||
engine_name = engine_name.lower().strip()
|
||||
|
||||
if engine_name in ['cura', 'cura_engine', 'curaengine']:
|
||||
return CuraEngine(print_config_folder)
|
||||
elif engine_name in ['prusa', 'prusa_slicer', 'prusaslicer']:
|
||||
return PrusaSlicerEngine(print_config_folder, config_slice_bin_path)
|
||||
else:
|
||||
# Default fallback
|
||||
return PrusaSlicerEngine(print_config_folder, config_slice_bin_path)
|
||||
257
app/utils/slice_engines/cura_engine.py
Normal file
@@ -0,0 +1,257 @@
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import uuid
|
||||
import configparser
|
||||
from app.utils.conf_parse import ConfParse
|
||||
from app.models import SystemConfig
|
||||
|
||||
class CuraEngine:
|
||||
def __init__(self, print_config_folder=None):
|
||||
self.name = "cura"
|
||||
self.display_name = "UltiMaker Cura"
|
||||
self.is_available = self._check_available()
|
||||
self.print_config_folder = os.path.join(print_config_folder, "cura_engine") if print_config_folder else None
|
||||
|
||||
def _check_available(self):
|
||||
try:
|
||||
# check if CuraEngine is available in PATH
|
||||
result = subprocess.run(["CuraEngine", "help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return result.returncode == 0 or b"Usage:" in result.stdout or b"Usage:" in result.stderr
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def slice(self, app, stl_filepath, gcode_filepath, **kwargs):
|
||||
"""
|
||||
Slices via CuraEngine.
|
||||
Returns (success_bool, error_msg_if_any)
|
||||
"""
|
||||
quality_preset = kwargs.get('quality_preset')
|
||||
infill_density = kwargs.get('infill_density')
|
||||
support_enable = kwargs.get('support_enable')
|
||||
support_pattern = kwargs.get('support_pattern')
|
||||
|
||||
tmp_def_path = None
|
||||
try:
|
||||
printers_path = os.path.join(self.print_config_folder, 'printers') if self.print_config_folder else None
|
||||
extruders_path = os.path.join(self.print_config_folder, 'extruders') if self.print_config_folder else None
|
||||
materials_path = os.path.join(self.print_config_folder, 'materials') if self.print_config_folder else None
|
||||
presets_path = os.path.join(self.print_config_folder, 'quality') if self.print_config_folder else None
|
||||
variants_path = os.path.join(self.print_config_folder, 'variants') if self.print_config_folder else None
|
||||
|
||||
env = os.environ.copy()
|
||||
env["CURA_ENGINE_SEARCH_PATH"] = f"{printers_path}:{extruders_path}:{materials_path}:{presets_path}:{variants_path}"
|
||||
|
||||
db_printer = SystemConfig.query.filter_by(key='default_printer').first()
|
||||
p_val = db_printer.value if db_printer and db_printer.value else 'creality_ender3v3se.def.json'
|
||||
if not p_val.endswith('.def.json'): p_val += '.def.json'
|
||||
|
||||
def_files = [
|
||||
os.path.join(printers_path, "fdmprinter.def.json"),
|
||||
os.path.join(printers_path, "fdmextruder.def.json"),
|
||||
os.path.join(printers_path, "creality_base.def.json"),
|
||||
os.path.join(printers_path, p_val)
|
||||
]
|
||||
|
||||
|
||||
inst_files_list = []
|
||||
quality_type = None
|
||||
preset_path = None
|
||||
|
||||
if quality_preset:
|
||||
config = configparser.ConfigParser()
|
||||
preset_path = os.path.join(presets_path, 'creality', 'presets', quality_preset)
|
||||
if os.path.exists(preset_path):
|
||||
config.read(preset_path)
|
||||
material_type_from_preset = config.get('metadata', 'material', fallback=None)
|
||||
variant_type = config.get('metadata', 'variant', fallback=None)
|
||||
quality_type = config.get('metadata', 'quality_type', fallback=None)
|
||||
|
||||
# Use explicit material if provided, otherwise fallback to preset's material
|
||||
material_type = kwargs.get('material_preset') or material_type_from_preset
|
||||
|
||||
if material_type:
|
||||
m_path = os.path.join(materials_path, f"{material_type}.inst.cfg")
|
||||
if not os.path.exists(m_path) and kwargs.get('material_preset'):
|
||||
m_path = os.path.join(materials_path, f"{kwargs.get('material_preset')}")
|
||||
if os.path.exists(m_path): inst_files_list.append(m_path)
|
||||
if variant_type:
|
||||
variant_d = variant_type.split("mm")[0]
|
||||
v_path = os.path.join(variants_path, "creality", f"{p_val.replace('.def.json', '')}_{variant_d}.inst.cfg")
|
||||
if os.path.exists(v_path): inst_files_list.append(v_path)
|
||||
|
||||
if support_pattern == 'tree':
|
||||
t_path = os.path.join(self.print_config_folder, 'supports', 'tree.inst.cfg') if self.print_config_folder else None
|
||||
if t_path and os.path.exists(t_path): inst_files_list.append(t_path)
|
||||
elif support_pattern and support_pattern != 'false':
|
||||
n_path = os.path.join(self.print_config_folder, 'supports', 'normal.inst.cfg') if self.print_config_folder else None
|
||||
if n_path and os.path.exists(n_path): inst_files_list.append(n_path)
|
||||
|
||||
if quality_preset and quality_type:
|
||||
g_path = os.path.join(self.print_config_folder, 'creality', 'globals', f"{quality_type}.inst.cfg") if self.print_config_folder else None
|
||||
if g_path and os.path.exists(g_path): inst_files_list.append(g_path)
|
||||
|
||||
if quality_preset and preset_path and os.path.exists(preset_path):
|
||||
inst_files_list.append(preset_path)
|
||||
|
||||
p = ConfParse(def_files)
|
||||
settings_with_inst = p.add_inst_cfg(inst_files_list)
|
||||
|
||||
if infill_density is not None:
|
||||
if "infill_sparse_density" not in settings_with_inst: settings_with_inst["infill_sparse_density"] = {}
|
||||
settings_with_inst["infill_sparse_density"]["value"] = str(infill_density)
|
||||
if "infill_line_distance" not in settings_with_inst: settings_with_inst["infill_line_distance"] = {}
|
||||
settings_with_inst["infill_line_distance"]["value"] = str(100 / int(infill_density)) if int(infill_density) > 0 else "9999"
|
||||
|
||||
if support_enable is not None:
|
||||
if "support_enable" not in settings_with_inst: settings_with_inst["support_enable"] = {}
|
||||
settings_with_inst["support_enable"]["value"] = True if support_enable in ['true', 'buildplate'] else False
|
||||
if "support_type" not in settings_with_inst: settings_with_inst["support_type"] = {}
|
||||
settings_with_inst["support_type"]["value"] = "'buildplate'" if support_enable == 'buildplate' else "'everywhere'"
|
||||
|
||||
if support_pattern == 'tree':
|
||||
if "support_structure" not in settings_with_inst: settings_with_inst["support_structure"] = {}
|
||||
settings_with_inst["support_structure"]["value"] = "'tree'"
|
||||
elif support_pattern and "support_pattern" in settings_with_inst and "options" in settings_with_inst["support_pattern"] and support_pattern in settings_with_inst["support_pattern"]["options"].keys():
|
||||
if "support_structure" not in settings_with_inst: settings_with_inst["support_structure"] = {}
|
||||
settings_with_inst["support_structure"]["value"] = "'normal'"
|
||||
if "support_pattern" not in settings_with_inst: settings_with_inst["support_pattern"] = {}
|
||||
settings_with_inst["support_pattern"]["value"] = f"'{support_pattern}'"
|
||||
|
||||
res = p.parse_configs(settings_with_inst)
|
||||
|
||||
override_dict = {}
|
||||
for k, v in res.items():
|
||||
if v.get("enabled", True):
|
||||
val = v.get("value", None)
|
||||
if val is not None:
|
||||
override_dict[k] = {"value": val, "default_value": val}
|
||||
elif "default_value" in v:
|
||||
override_dict[k] = {"default_value": v["default_value"], "value": v["default_value"]}
|
||||
|
||||
tmp_def_filename = f"tmp_{uuid.uuid4().hex}.def.json"
|
||||
tmp_def_path = os.path.join(app.config['UPLOAD_FOLDER'], tmp_def_filename)
|
||||
|
||||
tmp_def_obj = {
|
||||
"version": 2,
|
||||
"name": "TempProfile",
|
||||
"inherits": "fdmprinter",
|
||||
"metadata": {
|
||||
"visible": True,
|
||||
"author": "System",
|
||||
"manufacturer": "System",
|
||||
"file_formats": "text/x-gcode",
|
||||
"first_start_actions": ["MachineSettingsAction"],
|
||||
"has_materials": True,
|
||||
"has_variants": True,
|
||||
"has_machine_quality": True,
|
||||
"variants_name": "Nozzle Size",
|
||||
"preferred_variant_name": "0.4mm Nozzle",
|
||||
"preferred_quality_type": "standard",
|
||||
"preferred_material": "generic_pla",
|
||||
},
|
||||
"overrides": override_dict
|
||||
}
|
||||
|
||||
pretty_json = json.dumps(tmp_def_obj, indent=4)
|
||||
|
||||
with open(tmp_def_path, "w") as f:
|
||||
f.write(pretty_json)
|
||||
|
||||
command = [
|
||||
"CuraEngine", "slice",
|
||||
"-j", tmp_def_path,
|
||||
"-l", stl_filepath,
|
||||
"-o", gcode_filepath
|
||||
]
|
||||
|
||||
app.logger.info(f"Running command: {' '.join(command)}")
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
return True, None
|
||||
else:
|
||||
err_msg = stderr.decode() if stderr else "Unknown CuraEngine error"
|
||||
app.logger.error(f"CuraEngine Error: {err_msg}")
|
||||
return False, err_msg
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"CuraEngine Exception: {e}")
|
||||
return False, str(e)
|
||||
|
||||
finally:
|
||||
if tmp_def_path and os.path.exists(tmp_def_path):
|
||||
try:
|
||||
os.remove(tmp_def_path)
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to delete temp JSON config {tmp_def_path}: {e}")
|
||||
|
||||
def get_quality_presets(self):
|
||||
try:
|
||||
path = os.path.join(self.print_config_folder, 'quality', 'creality', 'presets') if self.print_config_folder else None
|
||||
if not path or not os.path.exists(path): return []
|
||||
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
|
||||
presets = []
|
||||
for f in files:
|
||||
presets.append({'id': f, 'name': f.replace('.inst.cfg', '')})
|
||||
presets.sort(key=lambda x: x['name'])
|
||||
return presets
|
||||
except:
|
||||
return []
|
||||
|
||||
def get_support_patterns(self):
|
||||
return [
|
||||
{'id': 'tree', 'name': 'Tree'},
|
||||
{'id': 'lines', 'name': 'Lines'},
|
||||
{'id': 'grid', 'name': 'Grid'},
|
||||
{'id': 'triangles', 'name': 'Triangles'},
|
||||
{'id': 'concentric', 'name': 'Concentric'},
|
||||
{'id': 'zigzag', 'name': 'Zig Zag'},
|
||||
{'id': 'cross', 'name': 'Cross'},
|
||||
{'id': 'gyroid', 'name': 'Gyroid'},
|
||||
{'id': 'honeycomb', 'name': 'Honeycomb'},
|
||||
{'id': 'octagon', 'name': 'Octagon'}
|
||||
]
|
||||
|
||||
def get_materials(self):
|
||||
try:
|
||||
path = os.path.join(self.print_config_folder, 'materials') if self.print_config_folder else None
|
||||
if not path or not os.path.exists(path): return []
|
||||
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
|
||||
materials = []
|
||||
for f in files:
|
||||
materials.append({'id': f, 'name': f.replace('.inst.cfg', '').replace('generic_', 'Generic ').replace('_', ' ').title()})
|
||||
materials.sort(key=lambda x: x['name'])
|
||||
return materials
|
||||
except:
|
||||
return []
|
||||
|
||||
def get_bed_dimensions(self):
|
||||
try:
|
||||
db_printer = SystemConfig.query.filter_by(key='default_printer').first()
|
||||
p_val = db_printer.value if db_printer and db_printer.value else 'creality_ender3v3se.def.json'
|
||||
if not p_val.endswith('.def.json'): p_val += '.def.json'
|
||||
path = os.path.join(self.print_config_folder, 'printers', p_val)
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
w = data['overrides']['machine_width']['default_value']
|
||||
h = data['overrides']['machine_depth']['default_value']
|
||||
hd = data['overrides']['machine_height']['default_value']
|
||||
return w, h, hd
|
||||
except:
|
||||
pass
|
||||
return 220, 220, 250
|
||||
def get_all_printers(self):
|
||||
try:
|
||||
path = os.path.join(self.print_config_folder, 'printers') if self.print_config_folder else None
|
||||
if not path or not os.path.exists(path): return []
|
||||
files = [f for f in os.listdir(path) if f.endswith('.inst.cfg')]
|
||||
printers = []
|
||||
for f in files:
|
||||
printers.append({'id': f, 'name': f.replace('..def.json', '').replace('generic_', 'Generic ').replace('_', ' ').title()})
|
||||
printers.sort(key=lambda x: x['name'])
|
||||
return printers
|
||||
except:
|
||||
return []
|
||||
195
app/utils/slice_engines/prusa_slicer_engine.py
Normal file
@@ -0,0 +1,195 @@
|
||||
import os
|
||||
import subprocess
|
||||
import configparser
|
||||
import uuid
|
||||
import shutil
|
||||
from app.models import SystemConfig
|
||||
|
||||
|
||||
class PrusaSlicerEngine:
|
||||
def __init__(self, print_config_folder=None, config_slice_bin_path=None):
|
||||
self.name = "prusa_slicer"
|
||||
self.display_name = "PrusaSlicer"
|
||||
self.config_slice_bin_path = config_slice_bin_path
|
||||
self.is_available = self._check_available()
|
||||
self.print_config_folder = os.path.join(print_config_folder, 'prusa_slicer') if print_config_folder else None
|
||||
|
||||
def _check_available(self):
|
||||
try:
|
||||
# Prefer explicit environment variable, then PATH, then a bundled AppImage under the repo
|
||||
prusa_bin = self.config_slice_bin_path or shutil.which('prusa-slicer') or shutil.which('prusa-slicer.exe')
|
||||
if not prusa_bin:
|
||||
return False
|
||||
result = subprocess.run([prusa_bin, "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return b"Usage:" in result.stdout or b"Slic3r" in result.stdout or b"PrusaSlicer" in result.stdout or result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def add_ini_keys(self, config_path, target_section, all_configs):
|
||||
config = configparser.ConfigParser(interpolation=None)
|
||||
config.read(config_path)
|
||||
if target_section not in config:
|
||||
config[target_section] = {}
|
||||
for k, v in config[target_section].items():
|
||||
all_configs[k] = v
|
||||
|
||||
def slice(self, app, stl_filepath, gcode_filepath, **kwargs):
|
||||
"""
|
||||
Slices via prusa-slicer CLI mapping standard kwargs to PRUSA parameters where possible.
|
||||
"""
|
||||
try:
|
||||
prusa_bin = self.config_slice_bin_path or shutil.which('prusa-slicer') or shutil.which('prusa-slicer.exe')
|
||||
|
||||
# Base command
|
||||
command = [prusa_bin, '-g', stl_filepath, '--output', gcode_filepath]
|
||||
|
||||
# Map quality, infill, supports to PrusaSlicer CLI arguments.
|
||||
# Example defaults, normally these would load from an .ini or be dynamically matched.
|
||||
quality_preset = kwargs.get('quality_preset')
|
||||
material_preset = kwargs.get('material_preset')
|
||||
infill_density = kwargs.get('infill_density')
|
||||
support_enable = kwargs.get('support_enable')
|
||||
support_pattern = kwargs.get('support_pattern')
|
||||
|
||||
# print(support_pattern)
|
||||
all_configs = {}
|
||||
db_printer = SystemConfig.query.filter_by(key='default_printer').first()
|
||||
p_val = db_printer.value if db_printer and db_printer.value else 'Ender3_V3_SE'
|
||||
if not p_val.endswith('.ini'): p_val += '.ini'
|
||||
printer_ini = os.path.join(self.print_config_folder, 'printers', p_val) if self.print_config_folder else None
|
||||
if printer_ini and os.path.exists(printer_ini):
|
||||
self.add_ini_keys(printer_ini, 'settings', all_configs)
|
||||
|
||||
if quality_preset:
|
||||
q_ini = os.path.join(self.print_config_folder, 'quality', f"{quality_preset}.ini") if self.print_config_folder else None
|
||||
if q_ini and os.path.exists(q_ini):
|
||||
self.add_ini_keys(q_ini, 'settings', all_configs)
|
||||
|
||||
if material_preset:
|
||||
m_ini = os.path.join(self.print_config_folder, 'materials', f"{material_preset}.ini") if self.print_config_folder else None
|
||||
if m_ini and os.path.exists(m_ini):
|
||||
self.add_ini_keys(m_ini, 'settings', all_configs)
|
||||
|
||||
if infill_density is not None:
|
||||
command.extend([f"--fill-density={infill_density}%"])
|
||||
|
||||
if support_enable and support_enable != 'false':
|
||||
# command.append("--support-material")
|
||||
if support_enable == 'buildplate':
|
||||
command.append("--support-material-buildplate-only")
|
||||
# PrusaSlicer equivalent for tree supports => organic
|
||||
support_pattern_ini = os.path.join(self.print_config_folder, 'supports', f'{support_pattern}.ini') if self.print_config_folder else None
|
||||
if support_pattern_ini and os.path.exists(support_pattern_ini):
|
||||
self.add_ini_keys(support_pattern_ini, 'settings', all_configs)
|
||||
else:
|
||||
# Load the default no_support.ini if no support is enabled
|
||||
no_support_ini = os.path.join(self.print_config_folder, 'supports', 'no_support.ini') if self.print_config_folder else None
|
||||
if no_support_ini and os.path.exists(no_support_ini):
|
||||
self.add_ini_keys(no_support_ini, 'settings', all_configs)
|
||||
else:
|
||||
all_configs['support_material'] = '0'
|
||||
|
||||
tmp_ini_filename = f"tmp_{uuid.uuid4().hex}.ini"
|
||||
tmp_ini_path = os.path.join(app.config['UPLOAD_FOLDER'], tmp_ini_filename)
|
||||
with open(tmp_ini_path, 'w') as f:
|
||||
for key, value in all_configs.items():
|
||||
f.write(f"{key} = {value}\n")
|
||||
|
||||
command.extend(["--load", tmp_ini_path])
|
||||
|
||||
env = os.environ.copy()
|
||||
app.logger.info(f"Running command: {' '.join(command)}")
|
||||
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
# Clean up the temporary .ini file
|
||||
os.remove(tmp_ini_path)
|
||||
return True, None
|
||||
else:
|
||||
err_msg = stderr.decode() if stderr else "Unknown prusa-slicer error"
|
||||
app.logger.error(f"PrusaSlicer Error: {err_msg}")
|
||||
return False, err_msg
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"PrusaSlicer Exception: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def get_quality_presets(self):
|
||||
all_files = [f for f in os.listdir(os.path.join(self.print_config_folder, "quality")) if f.endswith('.ini')] if self.print_config_folder else []
|
||||
quality_presets = []
|
||||
for file in all_files:
|
||||
with open(os.path.join(self.print_config_folder, "quality", file), 'r') as f:
|
||||
config = configparser.ConfigParser()
|
||||
config.read_file(f)
|
||||
if 'metadata' in config:
|
||||
quality_presets.append({
|
||||
'id': file.replace('.ini', ''),
|
||||
'name': config['metadata'].get('show_name', file.replace('.ini', '').replace('_', ' '))
|
||||
})
|
||||
return quality_presets
|
||||
|
||||
def get_support_patterns(self):
|
||||
all_files = [f for f in os.listdir(os.path.join(self.print_config_folder,"supports")) if f.endswith('.ini')] if self.print_config_folder else []
|
||||
support_presets = []
|
||||
for file in all_files:
|
||||
with open(os.path.join(self.print_config_folder, "supports", file), 'r') as f:
|
||||
config = configparser.ConfigParser()
|
||||
config.read_file(f)
|
||||
if 'metadata' in config:
|
||||
support_presets.append({
|
||||
'id': file.replace('.ini', ''),
|
||||
'name': config['metadata'].get('show_name', file.replace('.ini', '').replace('_', ' '))
|
||||
})
|
||||
return support_presets
|
||||
|
||||
def get_materials(self):
|
||||
all_files = [f for f in os.listdir(os.path.join(self.print_config_folder, "materials")) if f.endswith('.ini')] if self.print_config_folder else []
|
||||
materials = []
|
||||
for file in all_files:
|
||||
with open(os.path.join(self.print_config_folder, "materials", file), 'r') as f:
|
||||
config = configparser.ConfigParser()
|
||||
config.read_file(f)
|
||||
if 'metadata' in config:
|
||||
materials.append({
|
||||
'id': file.replace('.ini', ''),
|
||||
'name': config['metadata'].get('show_name', file.replace('.ini', '').replace('_', ' '))
|
||||
})
|
||||
return materials
|
||||
|
||||
|
||||
def get_bed_dimensions(self):
|
||||
try:
|
||||
db_printer = SystemConfig.query.filter_by(key='default_printer').first()
|
||||
p_val = db_printer.value if db_printer and db_printer.value else 'Ender3_V3_SE.ini'
|
||||
if not p_val.endswith('.ini'): p_val += '.ini'
|
||||
path = os.path.join(self.print_config_folder, 'printers', p_val)
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
if 'settings' in config and 'bed_shape' in config['settings']:
|
||||
# format is usually like 0x0,220x0,220x220,0x220
|
||||
coords = config['settings']['bed_shape'].split(',')
|
||||
max_x = max([float(c.split('x')[0]) for c in coords])
|
||||
max_y = max([float(c.split('x')[1]) for c in coords])
|
||||
# height
|
||||
h = 250
|
||||
if 'max_print_height' in config['settings']:
|
||||
h = float(config['settings']['max_print_height'])
|
||||
return max_x, max_y, h
|
||||
except:
|
||||
pass
|
||||
return 220, 220, 250
|
||||
def get_all_printers(self):
|
||||
all_files = [f for f in os.listdir(os.path.join(self.print_config_folder, "printers")) if f.endswith('.ini')] if self.print_config_folder else []
|
||||
printers = []
|
||||
for file in all_files:
|
||||
with open(os.path.join(self.print_config_folder, "printers", file), 'r') as f:
|
||||
config = configparser.ConfigParser()
|
||||
config.read_file(f)
|
||||
if 'metadata' in config:
|
||||
printers.append({
|
||||
'id': file.replace('.ini', ''),
|
||||
'name': config['metadata'].get('show_name', file.replace('.ini', '').replace('_', ' '))
|
||||
})
|
||||
return printers
|
||||
@@ -1,15 +1,17 @@
|
||||
from huey import SqliteHuey
|
||||
import subprocess
|
||||
import os
|
||||
from app.models import db, PrintFile, SystemConfig
|
||||
from app.utils.conf_parse import ConfParse
|
||||
import json
|
||||
import uuid
|
||||
import configparser
|
||||
from huey import SqliteHuey
|
||||
from app import create_app
|
||||
from app.models import db, PrintFile, SystemConfig
|
||||
from app.utils.conf_parse import ConfParse
|
||||
from app.utils.slice_engines import get_slicer_engine
|
||||
from app.utils.stl_merger import merge_stls
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
|
||||
|
||||
import os
|
||||
|
||||
# Ensure instance directory exists
|
||||
instance_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'instance')
|
||||
os.makedirs(instance_dir, exist_ok=True)
|
||||
@@ -17,12 +19,18 @@ huey_db_path = os.path.join(instance_dir, 'huey_queue.db')
|
||||
|
||||
huey = SqliteHuey(filename=huey_db_path)
|
||||
|
||||
def get_gcode_dir(app):
|
||||
with app.app_context():
|
||||
conf = SystemConfig.query.filter_by(key='gcode_upload_folder').first()
|
||||
if conf and conf.value and os.path.exists(conf.value):
|
||||
return conf.value
|
||||
return app.config['UPLOAD_FOLDER']
|
||||
|
||||
|
||||
@huey.task()
|
||||
def slice_stl_task(file_id, stl_filepath, quality_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False):
|
||||
def slice_stl_task(file_id, stl_filepath, quality_preset=None, material_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False):
|
||||
# This is run by the Huey worker
|
||||
# We need to create an app context to interact with the database
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
@@ -31,199 +39,65 @@ def slice_stl_task(file_id, stl_filepath, quality_preset=None, infill_density=No
|
||||
|
||||
# Cache variables and commit slicing status
|
||||
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
|
||||
gcode_filepath = os.path.join(app.config['UPLOAD_FOLDER'], gcode_filename)
|
||||
gcode_filepath = os.path.join(get_gcode_dir(app), gcode_filename)
|
||||
print_file.status = 'slicing'
|
||||
db.session.commit()
|
||||
|
||||
# Remove DB session to avoid locking the sqlite db during long slicing operations
|
||||
db.session.remove()
|
||||
|
||||
tmp_def_path = None
|
||||
|
||||
try:
|
||||
# Create Cura engine options
|
||||
# use our local minimal configurations detached from the entire Cura framework
|
||||
print_config_path = os.path.abspath(os.path.join(app.root_path, '..', 'print_config'))
|
||||
printers_path = os.path.join(print_config_path, 'printers')
|
||||
extruders_path = os.path.join(print_config_path, 'extruders')
|
||||
materials_path = os.path.join(print_config_path, 'materials')
|
||||
presets_path = os.path.join(print_config_path, 'quality')
|
||||
variants_path = os.path.join(print_config_path, 'variants')
|
||||
# Optionally fetch the preferred engine from db conf or just default to prusa
|
||||
# For now default to prusa or whichever is passed via kwargs if implemented later
|
||||
conf_engine = SystemConfig.query.filter_by(key='slicer_engine').first()
|
||||
engine_name = conf_engine.value if conf_engine and conf_engine.value else "prusa"
|
||||
db.session.remove()
|
||||
|
||||
env = os.environ.copy()
|
||||
env["CURA_ENGINE_SEARCH_PATH"] = f"{printers_path}:{extruders_path}:{materials_path}:{presets_path}:{variants_path}"
|
||||
slicer = get_slicer_engine(engine_name,app.config['PRINT_CONFIG_FOLDER'])
|
||||
|
||||
def_files = [
|
||||
os.path.join(printers_path, "fdmprinter.def.json"),
|
||||
os.path.join(printers_path, "fdmextruder.def.json"),
|
||||
os.path.join(printers_path, "creality_base.def.json"),
|
||||
os.path.join(printers_path, "creality_ender3v3se.def.json")
|
||||
]
|
||||
|
||||
inst_files_list = []
|
||||
|
||||
if quality_preset:
|
||||
config = configparser.ConfigParser()
|
||||
preset_path = os.path.join(presets_path, 'creality', 'presets', quality_preset)
|
||||
if os.path.exists(preset_path):
|
||||
config.read(preset_path)
|
||||
material_type = config.get('metadata', 'material', fallback=None)
|
||||
variant_type = config.get('metadata', 'variant', fallback=None)
|
||||
quality_type = config.get('metadata', 'quality_type', fallback=None)
|
||||
|
||||
if material_type:
|
||||
m_path = os.path.join(materials_path, f"{material_type}.inst.cfg")
|
||||
if os.path.exists(m_path): inst_files_list.append(m_path)
|
||||
if variant_type:
|
||||
variant_d = variant_type.split("mm")[0]
|
||||
v_path = os.path.join(variants_path, "creality", f"creality_ender3v3se_{variant_d}.inst.cfg")
|
||||
if os.path.exists(v_path): inst_files_list.append(v_path)
|
||||
|
||||
if support_pattern == 'tree':
|
||||
t_path = os.path.join(print_config_path, 'supports', 'tree.inst.cfg')
|
||||
if os.path.exists(t_path): inst_files_list.append(t_path)
|
||||
elif support_pattern and support_pattern != 'false':
|
||||
n_path = os.path.join(print_config_path, 'supports', 'normal.inst.cfg')
|
||||
if os.path.exists(n_path): inst_files_list.append(n_path)
|
||||
|
||||
if quality_preset and quality_type:
|
||||
g_path = os.path.join(presets_path, 'creality', 'globals', f"{quality_type}.inst.cfg")
|
||||
if os.path.exists(g_path): inst_files_list.append(g_path)
|
||||
|
||||
if quality_preset and os.path.exists(preset_path):
|
||||
inst_files_list.append(preset_path)
|
||||
|
||||
|
||||
p = ConfParse(def_files)
|
||||
settings_with_inst = p.add_inst_cfg(inst_files_list)
|
||||
|
||||
if infill_density is not None:
|
||||
if "infill_sparse_density" not in settings_with_inst: settings_with_inst["infill_sparse_density"] = {}
|
||||
settings_with_inst["infill_sparse_density"]["value"] = str(infill_density)
|
||||
if "infill_line_distance" not in settings_with_inst: settings_with_inst["infill_line_distance"] = {}
|
||||
settings_with_inst["infill_line_distance"]["value"] = str(100 / int(infill_density)) if int(infill_density) > 0 else "9999"
|
||||
|
||||
if support_enable is not None:
|
||||
if "support_enable" not in settings_with_inst: settings_with_inst["support_enable"] = {}
|
||||
settings_with_inst["support_enable"]["value"] = True if support_enable in ['true', 'buildplate'] else False
|
||||
if "support_type" not in settings_with_inst: settings_with_inst["support_type"] = {}
|
||||
settings_with_inst["support_type"]["value"] = "'buildplate'" if support_enable == 'buildplate' else "'everywhere'"
|
||||
|
||||
if support_pattern == 'tree':
|
||||
if "support_structure" not in settings_with_inst: settings_with_inst["support_structure"] = {}
|
||||
settings_with_inst["support_structure"]["value"] = "'tree'"
|
||||
elif support_pattern in settings_with_inst["support_pattern"]["options"].keys():
|
||||
if "support_structure" not in settings_with_inst: settings_with_inst["support_structure"] = {}
|
||||
settings_with_inst["support_structure"]["value"] = "'normal'"
|
||||
if "support_pattern" not in settings_with_inst: settings_with_inst["support_pattern"] = {}
|
||||
settings_with_inst["support_pattern"]["value"] = f"'{support_pattern}'"
|
||||
|
||||
# Parse to exact values
|
||||
res = p.parse_configs(settings_with_inst)
|
||||
|
||||
override_dict = {}
|
||||
for k, v in res.items():
|
||||
if v.get("enabled", True):
|
||||
val = v.get("value", None)
|
||||
if val is not None:
|
||||
# Filter out our protective ConfigStr wrappers
|
||||
# if type(val).__name__ == "ConfigStr": pass
|
||||
# else: override_dict[k] = {"default_value": val}
|
||||
override_dict[k] = {"value": val,"default_value": val}
|
||||
elif "default_value" in v:
|
||||
override_dict[k] = {"default_value": v["default_value"], "value": v["default_value"]}
|
||||
|
||||
|
||||
|
||||
tmp_def_filename = f"tmp_{uuid.uuid4().hex}.def.json"
|
||||
tmp_def_path = os.path.join(app.config['UPLOAD_FOLDER'], tmp_def_filename)
|
||||
|
||||
tmp_def_obj = {
|
||||
"version": 2,
|
||||
"name": "TempProfile",
|
||||
"inherits": "fdmprinter",
|
||||
"metadata": {
|
||||
"visible": True,
|
||||
"author": "System",
|
||||
"manufacturer": "System",
|
||||
"file_formats": "text/x-gcode",
|
||||
"first_start_actions": ["MachineSettingsAction"],
|
||||
"has_materials": True,
|
||||
"has_variants": True,
|
||||
"has_machine_quality": True,
|
||||
"variants_name": "Nozzle Size",
|
||||
|
||||
"preferred_variant_name": "0.4mm Nozzle",
|
||||
"preferred_quality_type": "standard",
|
||||
"preferred_material": "generic_pla",
|
||||
|
||||
},
|
||||
"overrides": override_dict
|
||||
}
|
||||
|
||||
pretty_json = json.dumps(tmp_def_obj, indent=4)
|
||||
|
||||
with open(tmp_def_path, "w") as f:
|
||||
f.write(pretty_json)
|
||||
|
||||
command = [
|
||||
"CuraEngine", "slice",
|
||||
"-j", tmp_def_path,
|
||||
"-l", stl_filepath,
|
||||
"-o", gcode_filepath
|
||||
]
|
||||
|
||||
app.logger.info(f"Running command: {' '.join(command)}")
|
||||
# print(f"Running command: {' '.join(command)}")
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
# if stdout:
|
||||
# print(f"[CuraEngine STDOUT]\n{stdout.decode('utf-8', errors='ignore')}")
|
||||
# if stderr:
|
||||
# print(f"[CuraEngine STDERR]\n{stderr.decode('utf-8', errors='ignore')}", flush=True)
|
||||
success, err_msg = slicer.slice(
|
||||
app=app,
|
||||
stl_filepath=stl_filepath,
|
||||
gcode_filepath=gcode_filepath,
|
||||
quality_preset=quality_preset,
|
||||
material_preset=material_preset,
|
||||
infill_density=infill_density,
|
||||
support_enable=support_enable,
|
||||
support_pattern=support_pattern
|
||||
)
|
||||
|
||||
# Re-fetch print_file and update status
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if not print_file:
|
||||
return
|
||||
|
||||
if process.returncode == 0:
|
||||
if success:
|
||||
print_file.status = 'sliced'
|
||||
else:
|
||||
print_file.status = 'failed'
|
||||
app.logger.error(f"CuraEngine Error: {stderr.decode()}")
|
||||
app.logger.error(f"Slicing Task Failed: {err_msg}")
|
||||
|
||||
except Exception as e:
|
||||
# Re-fetch in case of exception
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if print_file:
|
||||
print_file.status = 'failed'
|
||||
app.logger.error(f"Subprocess Exception: {e}")
|
||||
|
||||
finally:
|
||||
if delete_stl and os.path.exists(stl_filepath):
|
||||
try:
|
||||
os.remove(stl_filepath)
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to delete temp STL {stl_filepath}: {e}")
|
||||
|
||||
if tmp_def_path and os.path.exists(tmp_def_path):
|
||||
try:
|
||||
os.remove(tmp_def_path)
|
||||
# pass
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to delete temp JSON config {tmp_def_path}: {e}")
|
||||
|
||||
db.session.commit()
|
||||
db.session.remove()
|
||||
|
||||
|
||||
@huey.task()
|
||||
def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False):
|
||||
from app import create_app
|
||||
def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None, material_preset=None, infill_density=None, support_enable=None, support_pattern=None, delete_stl=False):
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
from app.models import PrintFile, db
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if not print_file:
|
||||
return
|
||||
@@ -231,12 +105,11 @@ def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None,
|
||||
db.session.remove()
|
||||
|
||||
try:
|
||||
from app.utils.stl_merger import merge_stls
|
||||
merge_stls(inputs, merged_filepath)
|
||||
|
||||
# Now trigger the regular slicing task
|
||||
# We can just call the slicing logic or enqueue it
|
||||
slice_stl_task(file_id, merged_filepath, quality_preset, infill_density, support_enable, support_pattern, delete_stl=delete_stl)
|
||||
slice_stl_task(file_id, merged_filepath, quality_preset, material_preset, infill_density, support_enable, support_pattern, delete_stl=delete_stl)
|
||||
except Exception as e:
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if print_file:
|
||||
@@ -248,13 +121,8 @@ def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None,
|
||||
|
||||
@huey.task()
|
||||
def simplify_stl_task(file_id, filepath):
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
from app.models import PrintFile, SystemConfig, db
|
||||
import os
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if not print_file:
|
||||
return
|
||||
|
||||
196
install.sh
Executable file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# 安装目录确认(默认使用脚本所在目录)
|
||||
DEFAULT_INSTALL_DIR="$REPO_DIR"
|
||||
echo "默认安装目录: $DEFAULT_INSTALL_DIR"
|
||||
read -r -p "请输入安装目录(回车使用默认): " INSTALL_DIR_INPUT
|
||||
if [ -z "$INSTALL_DIR_INPUT" ]; then
|
||||
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
|
||||
else
|
||||
INSTALL_DIR="$INSTALL_DIR_INPUT"
|
||||
fi
|
||||
|
||||
# 创建并解析目标路径为绝对路径
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)"
|
||||
|
||||
if [ "$INSTALL_DIR" != "$REPO_DIR" ]; then
|
||||
echo "选择的安装目录 ($INSTALL_DIR) 与脚本所在目录 ($REPO_DIR) 不同。"
|
||||
if [ -f "$INSTALL_DIR/run_main.sh" ] || [ -f "$INSTALL_DIR/install.sh" ]; then
|
||||
echo "目标目录已包含仓库文件;将在该目录继续安装。"
|
||||
REPO_DIR="$INSTALL_DIR"
|
||||
else
|
||||
read -r -p "目标目录不包含本仓库。是否将当前仓库复制到 $INSTALL_DIR 并在其下继续安装?输入 'yes' 或 'y' 表示同意(默认 no): " COPY_REPLY
|
||||
COPY_REPLY="${COPY_REPLY:-no}"
|
||||
case "${COPY_REPLY,,}" in
|
||||
y|yes|是|1)
|
||||
COPY_CONFIRM=1
|
||||
;;
|
||||
*)
|
||||
COPY_CONFIRM=0
|
||||
;;
|
||||
esac
|
||||
if [ "$COPY_CONFIRM" -eq 1 ]; then
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --exclude='.git' "$REPO_DIR/" "$INSTALL_DIR/"
|
||||
else
|
||||
cp -a "$REPO_DIR/." "$INSTALL_DIR/"
|
||||
fi
|
||||
REPO_DIR="$INSTALL_DIR"
|
||||
echo "仓库已复制到 $REPO_DIR"
|
||||
else
|
||||
echo "将继续使用脚本所在目录作为仓库路径:$REPO_DIR"
|
||||
echo "(注意:systemd 服务和脚本仍将引用 $REPO_DIR)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# 依赖于 REPO_DIR 的路径和变量
|
||||
VENV_DIR="$REPO_DIR/venv"
|
||||
PYTHON_BIN="${PYTHON:-python3}"
|
||||
PRUSA_URL="https://github.com/davidk/PrusaSlicer-ARM.AppImage/releases/download/version_2.9.4/PrusaSlicer-2.9.4-aarch64-full.AppImage"
|
||||
PRUSA_DIR="$REPO_DIR/prusaslicer"
|
||||
PRUSA_FILE="$PRUSA_DIR/$(basename "$PRUSA_URL")"
|
||||
PRUSA_SKIP_DOWNLOAD="${PRUSA_SKIP_DOWNLOAD:-0}"
|
||||
PRUSA_AGPL_ACCEPT="${PRUSA_AGPL_ACCEPT:-0}"
|
||||
|
||||
echo "正在将 AIO_3D_Print_Web_Platform 安装到:$REPO_DIR"
|
||||
echo "使用的 Python: $PYTHON_BIN"
|
||||
|
||||
echo "如果服务正在运行,将尝试停止它们(可能需要 sudo)"
|
||||
sudo systemctl stop aio-3d-main.service 2>/dev/null || true
|
||||
sudo systemctl stop aio-3d-huey.service 2>/dev/null || true
|
||||
|
||||
echo "正在创建虚拟环境(如果不存在):$VENV_DIR"
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
$PYTHON_BIN -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "正在激活虚拟环境并安装 Python 依赖"
|
||||
# shellcheck disable=SC1091
|
||||
source "$VENV_DIR/bin/activate"
|
||||
# 检测 http(s) 代理(支持大小写环境变量)
|
||||
PROXY=""
|
||||
if [ -n "${HTTPS_PROXY:-}" ]; then
|
||||
PROXY="$HTTPS_PROXY"
|
||||
elif [ -n "${https_proxy:-}" ]; then
|
||||
PROXY="$https_proxy"
|
||||
elif [ -n "${HTTP_PROXY:-}" ]; then
|
||||
PROXY="$HTTP_PROXY"
|
||||
elif [ -n "${http_proxy:-}" ]; then
|
||||
PROXY="$http_proxy"
|
||||
fi
|
||||
|
||||
pip_with_proxy() {
|
||||
# 用法: pip_with_proxy install [参数...]
|
||||
if [ -n "$PROXY" ] && [ "$1" = "install" ]; then
|
||||
shift
|
||||
pip install --proxy "$PROXY" "$@"
|
||||
else
|
||||
pip "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
pip_with_proxy install --upgrade pip setuptools wheel
|
||||
if [ -f "$REPO_DIR/requirements.txt" ]; then
|
||||
pip_with_proxy install -r "$REPO_DIR/requirements.txt"
|
||||
else
|
||||
echo "警告:在 $REPO_DIR 未找到 requirements.txt"
|
||||
fi
|
||||
|
||||
echo "确保运行脚本具有可执行权限"
|
||||
chmod +x "$REPO_DIR/run_main.sh" "$REPO_DIR/run_huey.sh"
|
||||
|
||||
echo "正在检查 PrusaSlicer AppImage(可选)"
|
||||
mkdir -p "$PRUSA_DIR"
|
||||
if [ ! -f "$PRUSA_FILE" ]; then
|
||||
if [ "$PRUSA_SKIP_DOWNLOAD" = "1" ]; then
|
||||
echo "检测到 PRUSA_SKIP_DOWNLOAD=1,跳过 PrusaSlicer 下载。"
|
||||
else
|
||||
cat <<'AGPL_NOTICE'
|
||||
PrusaSlicer 使用 GNU Affero General Public License v3 (AGPLv3) 授权。
|
||||
源码仓库: https://github.com/prusa3d/PrusaSlicer
|
||||
本安装器引用的二进制仓库: https://github.com/davidk/PrusaSlicer-ARM.AppImage
|
||||
下载并运行 PrusaSlicer 即表示您同意 AGPLv3 的许可条款。
|
||||
如果您通过网络向用户提供基于该软件的服务,AGPLv3 可能要求您向用户提供相应源码。
|
||||
详情请参见 third_party/PRUSASLICER.md 获取源码与合规说明。
|
||||
AGPL_NOTICE
|
||||
|
||||
if [ "$PRUSA_AGPL_ACCEPT" != "1" ]; then
|
||||
read -r -p "是否接受 AGPLv3 许可并允许下载 PrusaSlicer 二进制?输入 'yes' 或 'y' 表示同意(或设置 PRUSA_AGPL_ACCEPT=1 自动同意): " PRUSA_REPLY
|
||||
else
|
||||
PRUSA_REPLY="yes"
|
||||
fi
|
||||
|
||||
case "${PRUSA_REPLY,,}" in
|
||||
y|yes|是|1)
|
||||
PRUSA_APPROVED=1
|
||||
;;
|
||||
*)
|
||||
PRUSA_APPROVED=0
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$PRUSA_APPROVED" -eq 1 ]; then
|
||||
echo "正在下载 PrusaSlicer AppImage 到 $PRUSA_FILE"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if [ -n "$PROXY" ]; then
|
||||
curl -x "$PROXY" -L -o "$PRUSA_FILE" "$PRUSA_URL"
|
||||
else
|
||||
curl -L -o "$PRUSA_FILE" "$PRUSA_URL"
|
||||
fi
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
if [ -n "$PROXY" ]; then
|
||||
env HTTP_PROXY="$PROXY" HTTPS_PROXY="$PROXY" wget -O "$PRUSA_FILE" "$PRUSA_URL"
|
||||
else
|
||||
wget -O "$PRUSA_FILE" "$PRUSA_URL"
|
||||
fi
|
||||
else
|
||||
echo "警告:未检测到 curl 或 wget,无法自动下载 PrusaSlicer AppImage。"
|
||||
fi
|
||||
if [ -f "$PRUSA_FILE" ]; then
|
||||
chmod +x "$PRUSA_FILE"
|
||||
fi
|
||||
else
|
||||
echo "用户未接受 AGPL,已跳过 PrusaSlicer 下载。"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "已存在 PrusaSlicer AppImage:$PRUSA_FILE"
|
||||
fi
|
||||
|
||||
echo "准备并安装 systemd 服务文件(需要 sudo)"
|
||||
for svc in "aio-3d-main.service" "aio-3d-huey.service"; do
|
||||
SRC="$REPO_DIR/$svc"
|
||||
if [ ! -f "$SRC" ]; then
|
||||
echo "警告:未找到 $SRC,跳过"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$svc" = "aio-3d-main.service" ]; then
|
||||
EXEC="$REPO_DIR/run_main.sh"
|
||||
else
|
||||
EXEC="$REPO_DIR/run_huey.sh"
|
||||
fi
|
||||
|
||||
TMPFILE="/tmp/$svc"
|
||||
awk -v wd="$REPO_DIR" -v exec="$EXEC" '
|
||||
{ if ($0 ~ /^WorkingDirectory=/) { print "WorkingDirectory=" wd; next } \
|
||||
if ($0 ~ /^ExecStart=/) { print "ExecStart=" exec; next } \
|
||||
print $0 }' "$SRC" > "$TMPFILE"
|
||||
|
||||
echo "正在安装 $svc -> /etc/systemd/system/$svc"
|
||||
sudo cp "$TMPFILE" "/etc/systemd/system/$svc"
|
||||
done
|
||||
|
||||
echo "重新加载 systemd 守护进程并启用服务"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable aio-3d-main.service aio-3d-huey.service || true
|
||||
sudo systemctl restart aio-3d-huey.service || true
|
||||
sudo systemctl restart aio-3d-main.service || true
|
||||
|
||||
echo "安装完成"
|
||||
|
||||
178
patch_proxy.py
@@ -1,178 +0,0 @@
|
||||
import re
|
||||
|
||||
with open('app/routes/printer_routes.py', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find everything between @sock.route('/proxy', bp=printer_bp) and def octo_proxy(path):
|
||||
start_str = "@sock.route('/proxy', bp=printer_bp)"
|
||||
end_str = "@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])"
|
||||
|
||||
pre_content = content[:content.find(start_str)]
|
||||
post_content = content[content.find(end_str):]
|
||||
|
||||
new_proxy = """@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
|
||||
|
||||
base_url = url_config.value.rstrip('/')
|
||||
|
||||
# --- WebSocket Proxy Logic ---
|
||||
if request.headers.get('Upgrade', '').lower() == 'websocket':
|
||||
from flask_sock import Server, ConnectionClosed
|
||||
|
||||
# Check if environment supports WebSockets
|
||||
try:
|
||||
ws = Server(request.environ)
|
||||
except Exception as e:
|
||||
return "WebSocket Upgrade Failed", 400
|
||||
|
||||
def handle_ws():
|
||||
if base_url.startswith('https://'):
|
||||
ws_base = base_url.replace('https://', 'wss://', 1)
|
||||
else:
|
||||
ws_base = base_url.replace('http://', 'ws://', 1)
|
||||
|
||||
target_url = f"{ws_base}/{path}"
|
||||
if request.query_string:
|
||||
target_url = f"{target_url}?{request.query_string.decode('utf-8')}"
|
||||
|
||||
# Forward essential headers like NGINX Proxy
|
||||
headers = {
|
||||
'Host': request.host,
|
||||
'X-Real-IP': request.remote_addr,
|
||||
}
|
||||
forwarded_for = request.headers.get('X-Forwarded-For', '')
|
||||
headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
|
||||
headers['X-Forwarded-Proto'] = request.scheme
|
||||
headers['X-Forwarded-Host'] = request.host
|
||||
headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
|
||||
|
||||
if request.headers.get('Cookie'):
|
||||
headers['Cookie'] = request.headers.get('Cookie')
|
||||
|
||||
try:
|
||||
remote_ws = ws_connect(target_url, additional_headers=headers)
|
||||
except Exception as e:
|
||||
ws.close(1011, str(e))
|
||||
return
|
||||
|
||||
def recv_loop():
|
||||
try:
|
||||
for message in remote_ws:
|
||||
ws.send(message)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try: remote_ws.close()
|
||||
except: pass
|
||||
try: ws.close()
|
||||
except: pass
|
||||
|
||||
t = threading.Thread(target=recv_loop)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = ws.receive()
|
||||
if data is None:
|
||||
break
|
||||
remote_ws.send(data)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try: remote_ws.close()
|
||||
except: pass
|
||||
try: ws.close()
|
||||
except: pass
|
||||
|
||||
try:
|
||||
handle_ws()
|
||||
except ConnectionClosed:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try: ws.close()
|
||||
except: pass
|
||||
|
||||
class WebSocketResponse(Response):
|
||||
def __call__(self, *args, **kwargs):
|
||||
if getattr(ws, 'mode', 'werkzeug') == 'werkzeug':
|
||||
return super().__call__(*args, **kwargs)
|
||||
return []
|
||||
|
||||
return WebSocketResponse()
|
||||
|
||||
# --- Standard HTTP Proxy Logic ---
|
||||
from urllib.parse import urlparse
|
||||
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 based on nginx config reference
|
||||
parsed_base = urlparse(base_url)
|
||||
headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length']}
|
||||
|
||||
# NGINX equivalent proxy headers
|
||||
headers['Host'] = request.host
|
||||
headers['X-Real-IP'] = request.remote_addr
|
||||
headers['X-Real-Port'] = str(request.environ.get('REMOTE_PORT', ''))
|
||||
|
||||
forwarded_for = request.headers.get('X-Forwarded-For', '')
|
||||
headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
|
||||
|
||||
headers['X-Forwarded-Protocol'] = request.scheme
|
||||
headers['X-Script-Name'] = "/printer/proxy"
|
||||
headers['X-Forwarded-Host'] = request.host
|
||||
headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
|
||||
headers['REMOTE-HOST'] = request.remote_addr
|
||||
|
||||
if request.headers.get('Upgrade'):
|
||||
headers['Upgrade'] = request.headers.get('Upgrade')
|
||||
if request.headers.get('Connection'):
|
||||
headers['Connection'] = request.headers.get('Connection')
|
||||
|
||||
try:
|
||||
# proxy_connect_timeout 60s, proxy_read_timeout 600s
|
||||
resp = requests.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
data=request.get_data(),
|
||||
cookies=request.cookies,
|
||||
allow_redirects=False,
|
||||
stream=True,
|
||||
timeout=(60, 600)
|
||||
)
|
||||
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)
|
||||
"""
|
||||
|
||||
post_end_str = " return Response(generate(), resp.status_code, response_headers)"
|
||||
post_end_idx = post_content.find(post_end_str) + len(post_end_str)
|
||||
final_content = pre_content + new_proxy + post_content[post_end_idx:]
|
||||
|
||||
with open('app/routes/printer_routes.py', 'w', encoding='utf-8') as f:
|
||||
f.write(final_content)
|
||||
|
||||
print("Patched!")
|
||||
45
patch_ws.py
@@ -1,45 +0,0 @@
|
||||
import re
|
||||
|
||||
with open('app/routes/printer_routes.py', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Replace the WS Proxy header logic
|
||||
target_str = """ ws_headers['Host'] = request.host
|
||||
ws_headers['X-Real-IP'] = request.remote_addr
|
||||
forwarded_for = request.headers.get('X-Forwarded-For', '')
|
||||
ws_headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
|
||||
ws_headers['X-Forwarded-Proto'] = request.scheme
|
||||
ws_headers['X-Forwarded-Host'] = request.host
|
||||
ws_headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))"""
|
||||
|
||||
replacement_str = """ ws_headers['Host'] = request.host
|
||||
ws_headers['X-Real-IP'] = request.remote_addr
|
||||
forwarded_for = request.headers.get('X-Forwarded-For', '')
|
||||
ws_headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
|
||||
ws_headers['X-Forwarded-Proto'] = request.scheme
|
||||
ws_headers['X-Forwarded-Host'] = request.host
|
||||
ws_headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
|
||||
ws_headers['X-Script-Name'] = '/printer/proxy'"""
|
||||
|
||||
new_content = content.replace(target_str, replacement_str)
|
||||
|
||||
origin_str = """ # Mask Origin/Referer to bypass Octoprint CSRF if needed
|
||||
if 'Origin' in ws_headers:
|
||||
ws_headers['Origin'] = base_url
|
||||
if 'Referer' in ws_headers:
|
||||
ws_headers['Referer'] = f"{base_url}/{path}\""""
|
||||
|
||||
replacement_origin = """ # Match Tornado's expectations for Origin to avoid 400 Bad Request
|
||||
parsed_base = urlparse(base_url)
|
||||
ws_headers['Host'] = parsed_base.netloc
|
||||
if 'Origin' in ws_headers:
|
||||
ws_headers['Origin'] = base_url
|
||||
if 'Referer' in ws_headers:
|
||||
ws_headers['Referer'] = f"{base_url}/{path}\""""
|
||||
|
||||
new_content = new_content.replace(origin_str, replacement_origin)
|
||||
|
||||
with open('app/routes/printer_routes.py', 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
|
||||
print('Patched!')
|
||||