Compare commits

...

20 Commits

Author SHA1 Message Date
9c8de5e664 暂存-说明文档(部分) 2026-05-16 00:45:51 +08:00
91bedce2d7 暂存-安装脚本 2026-05-15 12:08:02 +08:00
6ccd3eb9c1 暂存-安装服务 2026-05-12 15:56:56 +08:00
42e3050fa2 删除遗漏的api test数据 2026-05-09 16:44:28 +08:00
75ceec0798 补充遗漏翻译,新增启动脚本,整理import 2026-05-09 16:42:17 +08:00
e542c482d7 添加api调用接口
Co-authored-by: Copilot <copilot@github.com>
2026-05-08 22:24:33 +08:00
a26f7214f9 修改部分参数
Co-authored-by: Copilot <copilot@github.com>
2026-05-08 01:16:40 +08:00
40b8cc8023 切片引擎解耦,打印机页面优化 2026-05-08 01:16:08 +08:00
ced6c67e83 lhye200 2026-05-01 02:01:30 +08:00
0b2199ec49 修改了质量参数、添加了账户管理功能
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 02:01:19 +08:00
72e3a165ac 大修参数,改prusa版本到2.9.4
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 01:01:24 +08:00
2dbecfe0d4 准备大修参数
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 00:07:27 +08:00
366372da6e 能用prusa切片和预览了,添加了缺失的翻译
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 01:51:08 +08:00
22a6493e24 tmp prusa配置文件集
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 00:47:32 +08:00
0416922a94 分离cura,加入prusa,未测试 2026-04-18 23:40:03 +08:00
6981553101 修复偏移问题,修复代理问题 2026-04-15 00:22:38 +08:00
f0f9d658eb 修复偏移问题,修复代理问题 2026-04-15 00:22:12 +08:00
570af7c225 整理文件夹及架构,加入打印机页面,octo反代有问题 2026-04-14 00:11:00 +08:00
1de35f21d7 tmp 2026-04-13 16:32:30 +08:00
dad17dbadd 缩放后修复,合并有问题 2026-04-13 08:20:00 +08:00
232 changed files with 10950 additions and 2993 deletions

7
.gitignore vendored
View File

@@ -1,3 +1,8 @@
__pycache__
uploads/*
tmp/*
tmp/*
venv
instance
huey_queue.*
*.AppImage
frpc/*

79
502_err.html Normal file
View 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
View File

@@ -0,0 +1,65 @@
# AIO 3D Print Web Platform
简介
----
这是一个基于 Python 的 Web 打印管理平台,通过调用 OctoPrint 的 API 来控制支持 Klipper 的打印机,集成切片、文件管理和打印机操作等功能。前端资源位于 `app/assets`,包含若干界面截图与帮助文档(见下方示例图片)。
示例图片
---------
![Logo](app/assets/img/logo.jpg)
切片助手示例:
![Slice Helper](app/assets/img/slice_helper/slice-helper_zh-cn.png)
快速开始
--------
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
View 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
View 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

View File

@@ -22,7 +22,7 @@ i18n_dict = {}
def load_i18n(app):
global i18n_dict
i18n_dir = os.path.join(app.root_path, '..', 'assets', 'i18n')
i18n_dir = os.path.join(app.root_path, 'assets', 'i18n')
if os.path.exists(i18n_dir):
for f in os.listdir(i18n_dir):
if f.endswith('.json'):
@@ -50,11 +50,16 @@ def _t(key):
return key # fallback to the key itself
def create_app():
app = Flask(__name__, static_url_path='/assets', static_folder='../assets')
app = Flask(__name__, static_url_path='/assets', static_folder='assets')
app.config['SECRET_KEY'] = 'your-secret-key-change-it-in-production'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///aio_3d.db'
app.config['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)
@@ -74,9 +79,15 @@ def create_app():
from . import models
db.create_all()
from .routes import main_bp, auth_bp, admin_bp
from .routes.main_routes import main_bp
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

View File

@@ -0,0 +1,23 @@
# Third-Party Licenses
This project uses the following third-party libraries, bundled in the `assets` folder.
## Frontend Libraries
1. **Bootstrap v5.3** (CSS/JS)
- License: MIT
- Source: https://getbootstrap.com/
2. **Bootstrap Icons**
- License: MIT
- Source: https://icons.getbootstrap.com/
3. **Three.js** (including Extra Controls / Loaders)
- License: MIT
- Source: https://threejs.org/
4. **GCode Preview**
- License: MIT
- Source: https://github.com/remcoder/gcode-preview
These libraries and their copyright notices belong to their respective creators. See individual source files or official repos for exact license texts.

View 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.
![Druckerstatus Platzhalterbild](assets/doc/images/printer_status_de.png)
---
## Druck vorbereiten
GCode an den Drucker senden, Temperaturen setzen und mit `Druck vorbereiten` bzw. `Jetzt drucken` starten.
![Druck vorbereiten Platzhalterbild](assets/doc/images/printer_prepare_de.png)
---
## Steuerung
Manuelle Grundsteuerungen: Achsen homing, Düsen/Betten bewegen, Pause/Fortsetzen, Druck abbrechen.
![Steuerung Platzhalterbild](assets/doc/images/printer_control_de.png)
---
## Drucker-Helfer (diese Seite)
Tipps zur Fehlerbehebung (Netzwerk, Filament, Bettleveling) und Checkliste vor dem Drucken.
![Drucker Helfer Platzhalterbild](assets/doc/images/printer_helper_de.png)
---
## Systemkonfiguration (Admin)
Admin-Einstellungen für Druckerabmessungen, Limits, Basisprofile und Verbindungsdaten.
![Systemkonfiguration Platzhalterbild](assets/doc/images/printer_system_de.png)
---
## OctoPrint-Panel (Admin)
Eingebettetes OctoPrint-Panel: `OctoPrint Basis-URL` und API-Key konfigurieren, Live-Panel verwenden.
![OctoPrint Platzhalterbild](assets/doc/images/printer_octoprint_de.png)

View 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.
![Printer Status placeholder image](assets/doc/images/printer_status_en.png)
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`.
![Prepare Print placeholder image](assets/doc/images/printer_prepare_en.png)
---
## Control
Basic manual controls: home axes, move nozzle/bed, pause/resume and cancel print.
![Control placeholder image](assets/doc/images/printer_control_en.png)
---
## Printer Helper (this page)
Guides common troubleshooting steps (connectivity, filament, bed leveling) and quick checks before printing.
![Printer Helper placeholder image](assets/doc/images/printer_helper_en.png)
---
## System Configuration (Admin)
Admin-only settings for printer dimensions, limits, shared profiles and connection settings.
![System Config placeholder image](assets/doc/images/printer_system_en.png)
---
## OctoPrint Panel (Admin)
Embedded OctoPrint panel: configure `OctoPrint Base URL`, API key and use the live panel when available.
![OctoPrint Panel placeholder image](assets/doc/images/printer_octoprint_en.png)
---
If you want I can add annotated screenshots for specific printer models.

View File

@@ -0,0 +1,60 @@
# 打印助手 — 快速指南
## 目录
- 打印机状态
- 准备打印
- 控制
- 打印助手(本页)
- 系统配置(管理员)
- OctoPrint 面板(管理员)
---
## 打印机状态
显示当前打印机状态、温度和任务信息。
![打印机状态 占位图](assets/doc/images/printer_status_zh-cn.png)
可在此查看 `打印机状态``当前打印任务`
---
## 准备打印
将准备好的 GCode 发送到打印机,设置温度并使用 `准备打印``立即打印` 开始。
![准备打印 占位图](assets/doc/images/printer_prepare_zh-cn.png)
---
## 控制
手动控制:回原点、移动喷嘴/平台、暂停/恢复与取消打印。
![控制 占位图](assets/doc/images/printer_control_zh-cn.png)
---
## 打印助手(本页)
提供常见故障排查步骤(网络、挤出机、床平整)和打印前检查清单。
![打印助手 占位图](assets/doc/images/printer_helper_zh-cn.png)
---
## 系统配置(管理员)
管理员设置打印机尺寸、限制、基础配置和连接信息。
![系统配置 占位图](assets/doc/images/printer_system_zh-cn.png)
---
## OctoPrint 面板(管理员)
内嵌 OctoPrint 面板:配置 `OctoPrint 基础 URL`、API 密钥并使用可用的实时面板。
![OctoPrint 占位图](assets/doc/images/printer_octoprint_zh-cn.png)

View 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.
![Startseite Bild](../../assets/img/slice_helper/home_de.png)
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.
![Meine Dateien Bild](../../assets/img/slice_helper/my-files_de.png)
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`.
![Plater Bild](../../assets/img/slice_helper/plater_de.png)
---
## Konto-Verwaltung
Für angemeldete Benutzer: Profil, Passwort ändern und aktive Sitzungen verwalten.
![Konto Bild](../../assets/img/slice_helper/account-management_de.png)
---
## Slice-Helfer (diese Seite)
Erklärung der empfohlenen Slicing-Schritte: `Qualitätsprofil` wählen, `Support` und `Fülldichte` konfigurieren, dann `Hochladen & Slicen`.
![Slice Helfer Bild](../../assets/img/slice_helper/slice-helper_de.png)
Statusmeldungen: `Wartend`, `Slicen`, `Gesliced`, `Fehlgeschlagen`.
---
## Systemeinstellungen (Admin)
Admins konfigurieren globale Slicer-Engines und Standardprofile.
![Systemeinstellungen Bild](../../assets/img/slice_helper/system-settings_de.png)
---
## Benutzerverwaltung (Admin)
Admins können Benutzer hinzufügen/ändern und Quoten sowie Rollen setzen (`Benutzer`, `Admin`).
![Benutzerverwaltung Bild](../../assets/img/slice_helper/user-management_de.png)
---
## API-Schlüssel (Admin)
Verwalten Sie API-Schlüssel für externe Integrationen: `Neuen API-Schlüssel erstellen` und `Schlüssel generieren`.
![API Schlüssel Bild](../../assets/img/slice_helper/api-keys_de.png)

View 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.
![Home image](../../assets/img/slice_helper/home_en.png)
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.
![My Files image](../../assets/img/slice_helper/my-files_en.png)
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`.
![Plater image](../../assets/img/slice_helper/plater_en.png)
Tip: ensure all models fit the printable area before slicing.
---
## Account Management
Available when logged in. Update profile, change password, and manage active sessions.
![Account image](../../assets/img/slice_helper/account-management_en.png)
---
## Slice Helper (this page)
This page explains slice workflows and recommended settings. Choose a `Quality Profile`, set `Support` and `Infill Density` then `Upload & Slice`.
![Slice Helper image](../../assets/img/slice_helper/slice-helper_en.png)
Status messages: `Waiting`, `Slicing`, `Sliced`, `Failed`.
---
## System Settings (Admin)
Admins can configure global slicer engines and default profiles under `System Settings`.
![System Settings image](../../assets/img/slice_helper/system-settings_en.png)
---
## User Management (Admin)
Admins can add/edit users, set quotas and roles (`User`, `Admin`).
![User Management image](../../assets/img/slice_helper/user-management_en.png)
---
## API Keys (Admin)
Manage API keys used by external tools. `Create New API Key`, name it and `Generate Key`.
![API Keys image](../../assets/img/slice_helper/api-keys_en.png)
---
If you need example workflows or screenshots, tell me which page to expand.

View File

@@ -0,0 +1,84 @@
# 切片助手 — 快速指南
## 目录
- 主页
- 我的文件
- 构建板 (Plater)
- 账号管理
- 切片助手(本页)
- 系统设置(管理员)
- 用户管理(管理员)
- API 密钥(管理员)
---
## 主页
切片仪表盘概览与快速操作入口。
![主页 图片](../../assets/img/slice_helper/home_zh-cn.png)
使用导航栏进入“主页”,通过 `上传并切片 STL` 开始新切片。
---
## 我的文件
管理已上传的 STL 与 GCode可上传、下载或删除文件。
![我的文件 图片](../../assets/img/slice_helper/my-files_zh-cn.png)
常用操作:`上传STL``下载 GCode``删除`
---
## 构建板 (Plater)
在构建板上放置与调整模型(平移/旋转/缩放),确认位置后使用 `合并并切片`
![构建板 图片](../../assets/img/slice_helper/plater_zh-cn.png)
提示:切片前确保模型均在可打印范围内。
---
## 账号管理
登录用户可在此更新资料、修改密码并管理活跃会话。
![账号管理 图片](../../assets/img/slice_helper/account-management_zh-cn.png)
---
## 切片助手(本页)
本页说明推荐的切片流程与设置:选择 `质量配置`、设置 `支撑``填充密度`,然后 `上传 & 切片`
![切片助手 图片](../../assets/img/slice_helper/slice-helper_zh-cn.png)
状态提示:`等待中``切片中``已切片``失败`
---
## 系统设置(管理员)
管理员可在此配置全局切片引擎与默认配置文件。
![系统设置 图片](../../assets/img/slice_helper/system-settings_zh-cn.png)
---
## 用户管理(管理员)
管理员可添加/编辑用户并设置配额与角色(`普通用户``管理员`)。
![用户管理 图片](../../assets/img/slice_helper/user-management_zh-cn.png)
---
## API 密钥(管理员)
管理外部工具使用的 API 密钥;点击 `创建新的 API 密钥`,输入名称并 `生成密钥`
![API 密钥 图片](../../assets/img/slice_helper/api-keys_zh-cn.png)

281
app/assets/i18n/de.json Normal file
View 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."
}

281
app/assets/i18n/en.json Normal file
View File

@@ -0,0 +1,281 @@
{
"Language": "Language",
"English": "English",
"Chinese": "中文",
"Guest": "Guest",
"Login": "Login",
"Logout": "Logout",
"Home": "Home",
"New Slice": "New Slice",
"My Files": "My Files",
"Admin Options": "Admin Options",
"System Settings": "System Settings",
"User Management": "User Management",
"Dashboard": "Dashboard",
"Total Prints": "Total Prints",
"You have sliced": "You have sliced",
"files": "files.",
"Upload & Slice STL": "Upload & Slice STL",
"Select STL File": "Select STL File",
"Quality Profile": "Quality Profile",
"Upload & Slice": "Upload & Slice",
"3D Preview Area": "3D Preview Area",
"Upload a file to display": "Upload a file to display",
"Date Uploaded": "Date Uploaded",
"Original Name": "Original Name",
"Status": "Status",
"Actions": "Actions",
"Uploaded": "Uploaded",
"Waiting": "Waiting",
"Other Settings": "Other Settings",
"Infill Density": "Infill Density",
"Support": "Support",
"None": "None",
"Touching Buildplate": "Touching Buildplate",
"Everywhere": "Everywhere",
"Merging": "Merging",
"Waiting in queue for slicing": "Waiting in queue for slicing",
"Slicing": "Slicing",
"Sliced": "Sliced",
"Failed": "Failed",
"Download GCode": "Download GCode",
"GCode Preview": "GCode Preview",
"Delete": "Delete",
"No files uploaded yet.": "No files uploaded yet.",
"Drag & Drop STL files here or Click to Select": "Drag & Drop STL files here or Click to Select",
"Uploading...": "Uploading...",
"Simplifying": "Simplifying",
"Simplifying...": "Simplifying...",
"Proxy Skip Size (MB)": "Proxy Skip Size (MB)",
"Files smaller than this will not generate a simplified proxy.": "Files smaller than this will not generate a simplified proxy.",
"Slicing queued!": "Slicing queued!",
"Draft Quality": "Draft Quality",
"Standard Quality": "Standard Quality",
"High Quality": "High Quality",
"Dynamic Quality": "Dynamic Quality",
"Low Quality": "Low Quality",
"Super Quality": "Super Quality",
"Ultra Quality": "Ultra Quality",
"Plater": "Plater",
"Layer Progress:": "Layer Progress:",
"Loading and Parsing GCode Data...": "Loading and Parsing GCode Data...",
"Failed to load GCode preview.": "Failed to load GCode preview.",
"Outer Wall": "Outer Wall",
"Inner Wall": "Inner Wall",
"Infill": "Infill",
"Skin/TopBottom": "Skin/TopBottom",
"Travel (Move)": "Travel (Move)",
"Skirt": "Skirt",
"Support Interface": "Support Interface",
"Back": "Back",
"Layer": "Layer",
"Plater / Build Plate": "Plater / Build Plate",
"Translate (W)": "Translate (W)",
"Rotate (E)": "Rotate (E)",
"Scale (R)": "Scale (R)",
"Scale": "Scale",
"Uniform Scale": "Uniform Scale",
"Lay Flat": "Lay Flat",
"Remove Selected (Del)": "Remove Selected (Del)",
"Available Models": "Available Models",
"No STL models uploaded yet. Go upload some first.": "No STL models uploaded yet. Go upload some first.",
"Support Type": "Support Type",
"Tree": "Tree",
"Lines": "Lines",
"Grid": "Grid",
"Triangles": "Triangles",
"Concentric": "Concentric",
"Zig Zag": "Zig Zag",
"Cross": "Cross",
"Gyroid": "Gyroid",
"Honeycomb": "Honeycomb",
"Octagon": "Octagon",
"Clear Board": "Clear Board",
"Merge & Slice": "Merge & Slice",
"Error loading STL model file.": "Error loading STL model file.",
"Please add at least one model to the build plate.": "Please add at least one model to the build plate.",
"One or more models are outside the print area. Please adjust them before slicing.": "One or more models are outside the print area. Please adjust them before slicing.",
"Error:": "Error:",
"ID": "ID",
"Username": "Username",
"Role": "Role",
"Created At": "Created At",
"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?",
"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)",
"Adjust the Y-axis compilation offset for combined files on the build plate.": "Adjust the Y-axis compilation offset for combined files on the build plate.",
"Default Plater Settings": "Default Plater Settings",
"Default Infill Density (%)": "Default Infill Density (%)",
"Default Support": "Default Support",
"Default Support Type": "Default Support Type",
"Default Quality Profile": "Default Quality Profile",
"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."
}

281
app/assets/i18n/zh-cn.json Normal file
View File

@@ -0,0 +1,281 @@
{
"Language": "语言",
"English": "English",
"Chinese": "中文",
"Guest": "访客",
"Login": "登录",
"Logout": "退出",
"Home": "主页",
"New Slice": "新建切片",
"My Files": "我的文件",
"Admin Options": "管理员选项",
"System Settings": "系统设置",
"User Management": "用户管理",
"Dashboard": "仪表盘",
"Total Prints": "总打印数",
"You have sliced": "您已切片",
"files": "个文件。",
"Upload & Slice STL": "上传并切片 STL",
"Select STL File": "选择 STL 文件",
"Quality Profile": "质量配置",
"Upload & Slice": "上传 & 切片",
"3D Preview Area": "3D预览区",
"Upload a file to display": "上传文件以显示",
"Date Uploaded": "上传日期",
"Original Name": "原始名称",
"Status": "状态",
"Actions": "操作",
"Waiting": "等待中",
"Merging": "合并中",
"Waiting in queue for slicing": "在队列中排队等待切片",
"Slicing": "切片中",
"Sliced": "已切片",
"Uploaded": "已上传",
"Failed": "失败",
"This model has already been sliced. The existing GCode will be overwritten. Continue?": "该模型已经生成过切片重新切片会覆盖原有GCode文件是否继续",
"Upload STL": "上传STL",
"Download GCode": "下载 GCode",
"GCode Preview": "GCode 预览",
"Delete": "删除",
"No files uploaded yet.": "还没有上传文件。",
"Drag & Drop STL files here or Click to Select": "将 STL 文件拖放到此处或点击选择",
"Uploading...": "上传中...",
"Simplifying": "简化中",
"Simplifying...": "正在简化...",
"Proxy Skip Size (MB)": "代理免简化大小 (MB)",
"Files smaller than this will not generate a simplified proxy.": "极小体积的文件无需降维生成加速展现的代理文件。",
"Upload Complete!": "上传完成!",
"Upload error.": "上传出错。",
"Upload failed.": "上传失败。",
"Please upload a valid .stl file!": "请上传有效的 .stl 文件!",
"Slicing queued!": "切片已排队!",
"Draft Quality": "草稿质量",
"Standard Quality": "标准质量",
"High Quality": "高质量",
"Dynamic Quality": "动态质量",
"Low Quality": "低质量",
"Super Quality": "超高质量",
"Ultra Quality": "极高质量",
"Plater": "构建板",
"Layer Progress:": "单层打印进度:",
"Loading and Parsing GCode Data...": "正在加载和解析 GCode 数据...",
"Failed to load GCode preview.": "加载 GCode 预览失败。",
"Outer Wall": "外墙",
"Inner Wall": "内墙",
"Infill": "填充",
"Skin/TopBottom": "顶层/底层",
"Travel (Move)": "空驶",
"Skirt": " 裙边",
"Support Interface": "支撑界面",
"Back": "返回",
"Layer": "层数",
"Plater / Build Plate": "构建板",
"Translate (W)": "平移 (W)",
"Rotate (E)": "旋转 (E)",
"Scale (R)": "缩放 (R)",
"Scale": "缩放",
"Uniform Scale": "均匀缩放",
"Lay Flat": "平放",
"Remove Selected (Del)": "删除选中 (Del)",
"Available Models": "可用模型",
"No STL models uploaded yet. Go upload some first.": "还没有上传 STL 模型。请先上传。",
"Other Settings": "其它设置",
"Infill Density": "填充密度",
"Support": "支撑",
"None": "无",
"Touching Buildplate": "仅接触构建板",
"Everywhere": "无处不在",
"Support Type": "支撑类型",
"Tree": "树状",
"Lines": "直线",
"Grid": "网格",
"Triangles": "三角形",
"Concentric": "同心",
"Zig Zag": "之字形",
"Cross": "交叉",
"Gyroid": "螺旋",
"Honeycomb": "蜂窝",
"Octagon": "八边形",
"Clear Board": "清空画板",
"Merge & Slice": "合并并切片",
"Error loading STL model file.": "加载 STL 模型文件出错。",
"Please add at least one model to the build plate.": "请在构建板上至少放置一个模型。",
"One or more models are outside the print area. Please adjust them before slicing.": "有一个或多个模型超出了打印范围。切片前请调整它们的位置。",
"Error:": "错误:",
"ID": "ID",
"Username": "用户名",
"Role": "角色",
"Created At": "创建时间",
"Admin": "管理员",
"User": "普通用户",
"WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?": "警告确定要永久删除该用户以及TA上传的所有文件和切片吗",
"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)",
"Adjust the Y-axis compilation offset for combined files on the build plate.": "调整多文件在构建板合并切片时的Y坐标偏移。",
"Default Plater Settings": "默认构建板设置",
"Default Infill Density (%)": "默认填充密度 (%)",
"Default Support": "默认支撑类型",
"Default Support Type": "默认支撑图案",
"Default Quality Profile": "默认质量配置",
"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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
app/assets/img/favicon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
app/assets/img/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

View File

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

View File

@@ -1,458 +0,0 @@
import json
import trimesh
import uuid
import os
import configparser
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 .models import db, User, PrintFile, SystemConfig
from .tasks import merge_and_slice_task, slice_stl_task
from app import i18n_dict
# import trimesh.repair
from stl_simplifier import simplify_stl
main_bp = Blueprint('main', __name__)
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
# Guest User Middleware
@main_bp.before_app_request
def assign_guest_cookie():
if not current_user.is_authenticated:
guest_id = request.cookies.get('guest_id')
if not guest_id:
guest_id = str(uuid.uuid4())
user = User(username=f'guest_{guest_id[:8]}', is_guest=True, guest_cookie_id=guest_id)
db.session.add(user)
db.session.commit()
login_user(user)
# We will set the cookie in the response after request, see below
request.guest_id_to_set = guest_id
else:
user = User.query.filter_by(guest_cookie_id=guest_id).first()
if user:
login_user(user)
@main_bp.after_app_request
def set_guest_cookie(response):
if hasattr(request, 'guest_id_to_set'):
response.set_cookie('guest_id', request.guest_id_to_set, max_age=60*60*24*365) # 1 year
return response
# --- Main Routes ---
@main_bp.route('/')
def index():
return render_template('index.html')
@main_bp.route('/set_language/<lang>')
def set_language(lang):
if lang not in i18n_dict:
lang = 'en'
# return to previous page
response = make_response(redirect(request.referrer or url_for('main.index')))
response.set_cookie('lang', lang, max_age=60*60*24*365)
return response
@main_bp.route('/files', methods=['GET', 'POST'])
@login_required
def files():
if request.method == 'POST':
if 'file' not in request.files:
flash('No file part', 'danger')
return redirect(request.url)
file = request.files['file']
if file.filename == '':
flash('No selected file', 'danger')
return redirect(request.url)
if file and file.filename.lower().endswith('.stl'):
original_filename = file.filename # Do not use secure_filename to keep Chinese characters
ext = os.path.splitext(original_filename)[1].lower()
if not ext:
ext = '.stl'
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
unique_filename = f"{timestamp}_{uuid.uuid4().hex}{ext}"
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename)
file.save(filepath)
# try:
# mesh = trimesh.load(filepath)
# # Check for overlapping faces or if the mesh is not watertight
# # which can cause issues in CuraEngine
# needs_repair = False
# if not mesh.is_watertight or (len(mesh.faces) > 0 and len(mesh.intersecting_faces[0]) > 0):
# # needs_repair = True
# pass
# if needs_repair:
# # Attempt automatic repair
# trimesh.repair.fix_normals(mesh)
# trimesh.repair.fix_inversion(mesh)
# trimesh.repair.fix_winding(mesh)
# trimesh.repair.fill_holes(mesh)
# # Re-check after repair
# if not mesh.is_watertight or (len(mesh.faces) > 0 and len(mesh.intersecting_faces[0]) > 0):
# if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
# return jsonify({'success': False, 'error': 'Mesh has overlapping faces or is not watertight, and automatic repair failed! Please repair the model manually before slicing.'}), 400
# else:
# flash('Mesh has overlapping faces or is not watertight, and automatic repair failed! Please repair the model manually.', 'danger')
# os.remove(filepath)
# return redirect(request.url)
# else:
# # Repair succeeded, rewrite file
# mesh.export(filepath)
# except Exception as e:
# pass
print_file = PrintFile(
filename=unique_filename,
original_filename=original_filename,
file_type='stl',
user_id=current_user.id,
status='uploaded' # Only display as uploaded, no automatic slicing
)
db.session.add(print_file)
db.session.commit()
flash('File uploaded successfully!', 'success')
return redirect(url_for('main.files'))
# Order by newest first
user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all()
return render_template('files.html', files=user_files)
@main_bp.route('/api/files_status')
@login_required
def files_status():
files = PrintFile.query.filter_by(user_id=current_user.id).all()
return jsonify({str(f.id): f.status for f in files})
@main_bp.route('/download/<int:file_id>')
@login_required
def download_gcode(file_id):
print_file = PrintFile.query.get_or_404(file_id)
if print_file.user_id != current_user.id and not current_user.is_admin:
abort(403)
if print_file.status != 'sliced':
flash('File is not ready yet.', 'warning')
return redirect(url_for('main.files'))
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
if os.path.exists(filepath):
safe_name = print_file.original_filename.rsplit('.', 1)[0] + '.gcode'
return send_file(filepath, as_attachment=True, download_name=safe_name)
flash('GCode file not found. It might have been deleted.', 'danger')
return redirect(url_for('main.files'))
@main_bp.route('/preview_gcode/<int:file_id>')
@login_required
def preview_gcode(file_id):
print_file = PrintFile.query.get_or_404(file_id)
if print_file.user_id != current_user.id and not current_user.is_admin:
abort(403)
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
content = "File not found or not ready."
line_count = 0
if os.path.exists(filepath):
with open(filepath, 'r') as f:
lines = f.readlines()
line_count = len(lines)
content = "".join(lines[:500]) # Preview first 500 lines
if line_count > 500:
content += f"\n... \n[Preview truncated. Total lines: {line_count}. Please download to view full file.]"
w, h, hd = 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('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)
@main_bp.route('/delete_file/<int:file_id>', methods=['POST'])
@login_required
def delete_file(file_id):
print_file = PrintFile.query.get_or_404(file_id)
if print_file.user_id != current_user.id and not current_user.is_admin:
abort(403)
stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename)
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
gcode_path = os.path.join(current_app.config['UPLOAD_FOLDER'], gcode_filename)
proxy_path = stl_path + '.proxy.stl'
if os.path.exists(stl_path):
os.remove(stl_path)
if os.path.exists(proxy_path):
os.remove(proxy_path)
if os.path.exists(gcode_path):
os.remove(gcode_path)
db.session.delete(print_file)
db.session.commit()
flash(f"Deleted {print_file.original_filename} successfully.", 'success')
return redirect(url_for('main.files'))
# --- Auth Routes ---
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter_by(username=username, is_guest=False).first()
if user and check_password_hash(user.password_hash, password):
login_user(user)
return redirect(url_for('main.index'))
flash('Invalid username or password', 'danger')
return render_template('login.html')
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
response = make_response(redirect(url_for('main.index')))
response.delete_cookie('guest_id') # Optionally clear guest cookie
return response
# --- Admin Routes ---
@admin_bp.before_request
def require_admin():
if not current_user.is_authenticated or not current_user.is_admin:
flash('Admin access required', 'danger')
return redirect(url_for('main.index'))
@admin_bp.route('/settings', methods=['GET', 'POST'])
def settings():
if request.method == 'POST':
# concurrent_slices = request.form.get('concurrent_slices')
offset_x = request.form.get('offset_x', '0')
offset_y = request.form.get('offset_y', '0')
default_infill = request.form.get('default_infill', '20')
default_support = request.form.get('default_support', 'false')
default_support_pattern = request.form.get('default_support_pattern', 'tree')
default_quality = request.form.get('default_quality', 'base_global_standard.inst.cfg')
# update or create config entries
config_items = [
('offset_x', offset_x),
('offset_y', offset_y),
('default_infill', default_infill),
('default_support', default_support),
('default_support_pattern', default_support_pattern),
('default_quality', default_quality)
]
for key, val in config_items:
conf = SystemConfig.query.filter_by(key=key).first()
if not conf:
conf = SystemConfig(key=key)
db.session.add(conf)
conf.value = val
db.session.commit()
flash('Settings updated successfully', 'success')
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)
@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)
@admin_bp.route('/user/<int:user_id>/delete', methods=['POST'])
def delete_user(user_id):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash('You cannot delete yourself.', 'danger')
return redirect(url_for('admin.users'))
print_files = PrintFile.query.filter_by(user_id=user.id).all()
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)
proxy_path = stl_path + '.proxy.stl'
if os.path.exists(stl_path):
try: os.remove(stl_path)
except: pass
if os.path.exists(proxy_path):
try: os.remove(proxy_path)
except: pass
if os.path.exists(gcode_path):
try: os.remove(gcode_path)
except: pass
db.session.delete(print_file)
db.session.delete(user)
db.session.commit()
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 []
@main_bp.route('/plater')
@login_required
def plater():
w, h, hd = get_bed_dimensions()
presets = get_quality_presets()
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'))
default_infill = configs.get('default_infill', '20')
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')
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('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)
@main_bp.route('/file/<int:file_id>')
@login_required
def serve_file(file_id):
f = PrintFile.query.get_or_404(file_id)
if f.user_id != current_user.id and not current_user.is_admin:
abort(403)
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
return send_file(path)
@main_bp.route('/proxy/<int:file_id>')
@login_required
def serve_proxy_file(file_id):
f = PrintFile.query.get_or_404(file_id)
if f.user_id != current_user.id and not current_user.is_admin:
abort(403)
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
proxy_path = path + '.proxy.stl'
if not os.path.exists(proxy_path):
try:
simplify_stl(path, proxy_path, keep_ratio=0.05) # compress to 90%
except:
return send_file(path) # fallback to original if error
if os.path.exists(proxy_path):
return send_file(proxy_path)
return send_file(path)
@main_bp.route('/api/merge_and_slice', methods=['POST'])
@login_required
def merge_and_slice():
data = request.json
pieces = data.get('pieces', [])
quality = data.get('quality', 'base_global_standard.inst.cfg')
infill_density = data.get('infill', '20')
support_enable = data.get('support', 'false')
support_pattern = data.get('support_pattern', 'lines')
if not pieces:
return jsonify({'error': 'No pieces provided'}), 400
inputs = []
# Build a combined name
names = []
for p in pieces[:3]: # Cap names at 3 to avoid super long string
f = PrintFile.query.get(p['file_id'])
if f and (f.user_id == current_user.id or current_user.is_admin):
names.append(f.original_filename.replace('.stl', ''))
combined_name = ", ".join(names)
if len(pieces) > 3:
combined_name += "等合并切片"
elif len(pieces) > 1:
combined_name += "合并切片"
else:
combined_name += " 单独切片"
for p in pieces:
f = PrintFile.query.get(p['file_id'])
if f and (f.user_id == current_user.id or current_user.is_admin):
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
inputs.append((path, p['matrix']))
if 'raw_matrix' in p:
f.transform_matrix = json.dumps(p['raw_matrix'])
db.session.add(f)
db.session.commit()
if len(inputs) == 0:
return jsonify({'error': 'Invalid files'}), 400
is_edit = data.get('is_edit', False)
if len(inputs) == 1 and is_edit:
# User is just generating gcode for a single original model, do NOT pollute list with new STL
target_file_id = pieces[0]['file_id']
print_file = PrintFile.query.get(target_file_id)
if not print_file:
return jsonify({'error': 'Original file not found'}), 404
print_file.status = 'merging'
db.session.commit()
# We still need to apply transforms to a temporary STL to generate correct GCode
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)
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')
unique_filename = f"{timestamp}_{uuid.uuid4().hex}.stl"
merged_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename)
print_file = PrintFile(
filename=unique_filename,
original_filename=f"{combined_name}.stl",
file_type='stl',
user_id=current_user.id,
status='merging'
)
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)
return jsonify({'success': True, 'message': 'Plater slice queued!'})

247
app/routes/admin_routes.py Normal file
View File

@@ -0,0 +1,247 @@
import json
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, ApiKey
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
from app import i18n_dict
from app.utils.stl_simplifier import simplify_stl
from app.utils.slice_engines import get_all_engines
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():
if not current_user.is_authenticated or not current_user.is_admin:
flash('Admin access required', 'danger')
return redirect(url_for('main.index'))
@admin_bp.route('/settings', methods=['GET', 'POST'])
def settings():
if request.method == 'POST':
# concurrent_slices = request.form.get('concurrent_slices')
offset_x = request.form.get('offset_x', '0')
offset_y = request.form.get('offset_y', '0')
proxy_skip_size_mb = request.form.get('proxy_skip_size_mb', '5.0')
default_infill = request.form.get('default_infill', '20')
default_support = request.form.get('default_support', 'false')
default_support_pattern = request.form.get('default_support_pattern', 'tree')
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 = [
('offset_x', offset_x),
('offset_y', offset_y),
('proxy_skip_size_mb', proxy_skip_size_mb),
('default_infill', default_infill),
('default_support', default_support),
('default_support_pattern', default_support_pattern),
('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()
if not conf:
conf = SystemConfig(key=key)
db.session.add(conf)
conf.value = val
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': True}), 200
flash('Settings updated successfully', 'success')
return redirect(url_for('admin.settings'))
configs = {c.key: c.value for c in SystemConfig.query.all()}
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()
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):
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash('You cannot delete yourself.', 'danger')
return redirect(url_for('admin.users'))
print_files = PrintFile.query.filter_by(user_id=user.id).all()
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(get_gcode_dir(), gcode_filename)
proxy_path = stl_path + '.proxy.stl'
if os.path.exists(stl_path):
try: os.remove(stl_path)
except: pass
if os.path.exists(proxy_path):
try: os.remove(proxy_path)
except: pass
if os.path.exists(gcode_path):
try: os.remove(gcode_path)
except: pass
db.session.delete(print_file)
db.session.delete(user)
db.session.commit()
flash(f'User {user.username} and all their files have been deleted.', 'success')
return redirect(url_for('admin.users'))
@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'))

149
app/routes/auth_routes.py Normal file
View File

@@ -0,0 +1,149 @@
import json
import uuid
import os
import configparser
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, UserSession
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
from app import i18n_dict
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__)
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
# Guest User Middleware
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter_by(username=username, is_guest=False).first()
remember = bool(request.form.get('remember'))
merge_data = bool(request.form.get('merge_data'))
if user and check_password_hash(user.password_hash, password):
# 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
# --- Admin Routes ---

690
app/routes/main_routes.py Normal file
View File

@@ -0,0 +1,690 @@
import json
import uuid
import os
import configparser
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, UserSession
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
from app import i18n_dict
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:
guest_id = str(uuid.uuid4())
user = User(username=f'guest_{guest_id[:8]}', is_guest=True, guest_cookie_id=guest_id)
db.session.add(user)
db.session.commit()
login_user(user)
# We will set the cookie in the response after request, see below
request.guest_id_to_set = guest_id
else:
user = User.query.filter_by(guest_cookie_id=guest_id).first()
if user:
login_user(user)
@main_bp.after_app_request
def set_guest_cookie(response):
if hasattr(request, 'guest_id_to_set'):
response.set_cookie('guest_id', request.guest_id_to_set, max_age=60*60*24*365) # 1 year
return response
# --- Main Routes ---
@main_bp.route('/')
@login_required
def index():
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):
if lang not in i18n_dict:
lang = 'en'
# return to previous page
response = make_response(redirect(request.referrer or url_for('main.index')))
response.set_cookie('lang', lang, max_age=60*60*24*365)
return response
@main_bp.route('/files', methods=['GET', 'POST'])
@login_required
def files():
if request.method == 'POST':
if 'file' not in request.files:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': 'No file part'}), 400
flash('No file part', 'danger')
return redirect(request.url)
uploaded_files = request.files.getlist('file')
success_count = 0
for file in uploaded_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:
ext = '.stl'
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
unique_filename = f"{timestamp}_{uuid.uuid4().hex}_{success_count}{ext}"
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename)
file.save(filepath)
print_file = PrintFile(
filename=unique_filename,
original_filename=original_filename,
file_type='stl',
user_id=current_user.id,
status='simplifying' # Set to simplifying while proxy is generated
)
db.session.add(print_file)
db.session.commit()
# Start background simplification
simplify_stl_task(print_file.id, filepath)
success_count += 1
if success_count > 0:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': True, 'count': success_count})
flash(f'{success_count} file(s) uploaded successfully!', 'success')
else:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': 'No valid files uploaded'}), 400
flash('No valid files uploaded', 'danger')
return redirect(url_for('main.files'))
# Order by newest first
user_files = PrintFile.query.filter_by(user_id=current_user.id, file_type='stl').order_by(PrintFile.created_at.desc()).all()
return render_template('slice/files.html', files=user_files)
@main_bp.route('/api/files_status')
@login_required
def files_status():
files = PrintFile.query.filter_by(user_id=current_user.id).all()
return jsonify({str(f.id): f.status for f in files})
@main_bp.route('/download/<int:file_id>')
@login_required
def download_gcode(file_id):
print_file = PrintFile.query.get_or_404(file_id)
if print_file.user_id != current_user.id and not current_user.is_admin:
abort(403)
if print_file.status != 'sliced':
flash('File is not ready yet.', 'warning')
return redirect(url_for('main.files'))
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
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'
return send_file(filepath, as_attachment=True, download_name=safe_name)
flash('GCode file not found. It might have been deleted.', 'danger')
return redirect(url_for('main.files'))
@main_bp.route('/preview_gcode/<int:file_id>')
@login_required
def preview_gcode(file_id):
print_file = PrintFile.query.get_or_404(file_id)
if print_file.user_id != current_user.id and not current_user.is_admin:
abort(403)
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
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)
content = "".join(lines[:500]) # Preview first 500 lines
if line_count > 500:
content += f"\n... \n[Preview truncated. Total lines: {line_count}. Please download to view full file.]"
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,
time_info=time_info, layer1_time=layer1_time, filament_used=filament_used)
@main_bp.route('/delete_file/<int:file_id>', methods=['POST'])
@login_required
def delete_file(file_id):
print_file = PrintFile.query.get_or_404(file_id)
if print_file.user_id != current_user.id and not current_user.is_admin:
abort(403)
stl_path = os.path.join(current_app.config['UPLOAD_FOLDER'], print_file.filename)
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
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):
os.remove(stl_path)
if os.path.exists(proxy_path):
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()
flash(f"Deleted {print_file.original_filename} successfully.", 'success')
return redirect(url_for('main.files'))
# --- Auth Routes ---
@main_bp.route('/plater')
@login_required
def plater():
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'))
offset_y = float(configs.get('offset_y', '0.0'))
default_infill = configs.get('default_infill', '20')
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, 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
def serve_file(file_id):
f = PrintFile.query.get_or_404(file_id)
if f.user_id != current_user.id and not current_user.is_admin:
abort(403)
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
return send_file(path)
@main_bp.route('/proxy/<int:file_id>')
@login_required
def serve_proxy_file(file_id):
f = PrintFile.query.get_or_404(file_id)
if f.user_id != current_user.id and not current_user.is_admin:
abort(403)
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
proxy_path = path + '.proxy.stl'
if os.path.exists(proxy_path):
return send_file(proxy_path)
return send_file(path)
@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')
if not pieces:
return jsonify({'error': 'No pieces provided'}), 400
inputs = []
# Build a combined name
names = []
for p in pieces[:3]: # Cap names at 3 to avoid super long string
f = PrintFile.query.get(p['file_id'])
if f and (f.user_id == current_user.id or current_user.is_admin):
names.append(f.original_filename.replace('.stl', ''))
combined_name = ", ".join(names)
if len(pieces) > 3:
combined_name += "等合并切片"
elif len(pieces) > 1:
combined_name += "合并切片"
else:
combined_name += " 单独切片"
is_edit = data.get('is_edit', False)
for p in pieces:
f = PrintFile.query.get(p['file_id'])
if f and (f.user_id == current_user.id or current_user.is_admin):
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
inputs.append((path, p['matrix']))
# 只有在单一编辑模式才修改原模型的矩阵 (如果多模型/新建模式,我们不修改原模型,而是后续记录到新的包含实体上)
if 'raw_matrix' in p and is_edit and len(pieces) == 1:
f.transform_matrix = json.dumps({
"is_composite": False,
"matrix": p['raw_matrix'],
"settings": {
"quality": quality,
"material": material,
"infill": infill_density,
"support": support_enable,
"support_pattern": support_pattern
}
})
db.session.add(f)
db.session.commit()
target_file_id = data.get('target_file_id')
if is_edit and target_file_id:
print_file = PrintFile.query.get(target_file_id)
if not print_file:
return jsonify({'error': 'Original file not found'}), 404
print_file.status = 'merging'
if print_file.transform_matrix and 'is_composite' in print_file.transform_matrix:
composite_data = {
"is_composite": True,
"parts": [],
"settings": {
"quality": quality,
"material": material,
"infill": infill_density,
"support": support_enable,
"support_pattern": support_pattern
}
}
for p in pieces:
pf = PrintFile.query.get(p['file_id'])
if pf:
composite_data['parts'].append({
"file_id": pf.id,
"name": pf.original_filename,
"url": url_for('main.serve_proxy_file', file_id=pf.id),
"raw_matrix": p.get('raw_matrix', p['matrix'])
})
print_file.transform_matrix = json.dumps(composite_data)
elif len(pieces) == 1:
print_file.transform_matrix = json.dumps({
"is_composite": False,
"matrix": pieces[0].get('raw_matrix', pieces[0]['matrix']),
"settings": {
"quality": quality,
"material": material,
"infill": infill_density,
"support": support_enable,
"support_pattern": support_pattern
}
})
db.session.commit()
temp_filename = f"temp_edit_{uuid.uuid4().hex}.stl"
temp_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], temp_filename)
merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, 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)
if not print_file:
return jsonify({'error': 'Original file not found'}), 404
print_file.status = 'merging'
db.session.commit()
# We still need to apply transforms to a temporary STL to generate correct GCode
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, 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')
unique_filename = f"{timestamp}_{uuid.uuid4().hex}.stl"
merged_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename)
# 构建组合文件元数据树 (is_composite: true)
composite_data = {
"is_composite": True,
"parts": [],
"settings": {
"quality": quality,
"material": material,
"infill": infill_density,
"support": support_enable,
"support_pattern": support_pattern
}
}
for p in pieces:
pf = PrintFile.query.get(p['file_id'])
if pf:
composite_data['parts'].append({
"file_id": pf.id,
"name": pf.original_filename,
"url": url_for('main.serve_proxy_file', file_id=pf.id),
"raw_matrix": p.get('raw_matrix', p['matrix'])
})
print_file = PrintFile(
filename=unique_filename,
original_filename=f"{combined_name}.stl",
file_type='stl',
user_id=current_user.id,
status='merging',
transform_matrix=json.dumps(composite_data)
)
db.session.add(print_file)
db.session.commit()
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)

View File

@@ -0,0 +1,579 @@
import os
import websockets.exceptions
import threading
import requests
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')
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"
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():
client = get_octo_client()
status_data = None
job_data = None
error = None
if client:
try:
status_data = client.get_printer_status()
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():
# 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 = []
gcode_dir = get_gcode_dir()
client = get_octo_client()
octo_files_dict = {}
if client:
try:
octo_resp = client.get_files()
for item in octo_resp.get('files', []):
octo_files_dict[item.get('name')] = item
except Exception as e:
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})
except Exception as e:
return jsonify({"success": False, "error": str(e)})
return jsonify({"success": False, "error": "Not configured or missing path"})
@printer_bp.route('/control')
def control():
client = get_octo_client()
webcam_url = None
error = None
if client:
try:
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':
client.cancel_print()
return jsonify({"success": True})
except Exception as e:
return jsonify({"success": False, "error": str(e)})
return jsonify({"success": False, "error": "Invalid client or command"})
@printer_bp.route('/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():
if not current_user.is_admin:
flash("Admin access required", "danger")
return redirect(url_for('printer.status'))
if request.method == 'POST':
url = request.form.get('octoprint_url', '')
apikey = request.form.get('octoprint_apikey', '')
conf_url = SystemConfig.query.filter_by(key='octoprint_url').first()
if not conf_url:
conf_url = SystemConfig(key='octoprint_url')
db.session.add(conf_url)
conf_url.value = url.rstrip('/')
conf_key = SystemConfig.query.filter_by(key='octoprint_apikey').first()
if not conf_key:
conf_key = SystemConfig(key='octoprint_apikey')
db.session.add(conf_key)
conf_key.value = apikey
db.session.commit()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': True}), 200
flash("OctoPrint settings updated", "success")
return redirect(url_for('printer.octo_config'))
configs = {c.key: c.value for c in SystemConfig.query.all()}
return render_template('printer/octo_config.html', configs=configs)
@printer_bp.route('/octo_embed')
@login_required
def octo_embed():
if not current_user.is_admin:
flash("Admin access required", "danger")
return redirect(url_for('printer.status'))
url = SystemConfig.query.filter_by(key='octoprint_url').first()
embed_url = url_for('printer.octo_proxy') if url and url.value else None
return render_template('printer/octo_embed.html', embed_url=embed_url)
@printer_bp.route('/proxy', defaults={'path': ''}, 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'], strict_slashes=False)
@printer_bp.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
@login_required
def octo_proxy(path):
if current_user.is_guest:
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('/')
# print("----- REQUEST HEADERS -----")
# for k, v in request.headers:
# print(f"{k}: {v}")
# print("----- END REQUEST HEADERS -----")
# --- WebSocket Proxy Logic ---
if request.headers.get('Upgrade', '').lower() == 'websocket':
# Check if environment supports WebSockets
try:
ws = Server(request.environ)
except Exception as e:
env_keys = sorted(list(request.environ.keys()))
print(f"FAILED. ENV KEYS: {env_keys}")
return f"WebSocket Upgrade Failed: {str(e)}", 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')}"
print(f"WS Proxy Query String: {request.query_string.decode('utf-8')}")
# Copy most headers, especially Origin to pass CORS
ws_headers = {}
for k, v in request.headers:
if k.lower() not in ['host', 'connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'content-length']:
ws_headers[k] = v
# 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 request.headers:
ws_headers['Origin'] = base_url
if 'Referer' in request.headers:
ws_headers['Referer'] = f"{base_url}/{path}"
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'
print(f"WS Proxy Connecting to: {target_url}")
try:
remote_ws = ws_connect(target_url, additional_headers=ws_headers)
print("WS Proxy connected to remote.")
except Exception as e:
traceback.print_exc()
print(f"Remote WS Connection Error: {e}")
ws.close(1011, str(e))
return
def recv_loop():
print("WS recv_loop started")
try:
for message in remote_ws:
ws.send(message)
except Exception as e:
print("WS recv error:", e)
finally:
try: remote_ws.close()
except: pass
try: ws.close()
except: pass
print("WS recv_loop ended")
t = threading.Thread(target=recv_loop)
t.daemon = True
t.start()
print("WS Entering client receive loop")
try:
while True:
data = ws.receive()
if data is None:
break
remote_ws.send(data)
except Exception as e:
print("WS send error:", e)
finally:
try: remote_ws.close()
except: pass
try: ws.close()
except: pass
print("WS client loop ended")
try:
handle_ws()
except ConnectionClosed:
print("WS Connection Closed")
except Exception as e:
print("WS Error in handle_ws:", e)
finally:
try: ws.close()
except: pass
class WebSocketResponse(Response):
def __call__(self, *args, **kwargs):
print("WS Response __call__")
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 ---
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', 'origin', 'referer']}
# 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-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'))
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')
# 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(
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']
# 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):
if chunk:
yield chunk
return Response(generate(), resp.status_code, response_headers)

View File

@@ -1,240 +0,0 @@
from huey import SqliteHuey
import subprocess
import os
from .models import db, PrintFile, SystemConfig
from .conf_parse import ConfParse
import json
import uuid
import configparser
huey = SqliteHuey(filename='huey_queue.db')
@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):
# This is run by the Huey worker
# We need to create an app context to interact with the database
from app import create_app
app = create_app()
with app.app_context():
print_file = PrintFile.query.get(file_id)
if not print_file:
return
# Cache variables and commit slicing status
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
gcode_filepath = os.path.join(app.config['UPLOAD_FOLDER'], gcode_filename)
print_file.status = 'slicing'
db.session.commit()
# Remove DB session to avoid locking the sqlite db during long slicing operations
db.session.remove()
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')
env = os.environ.copy()
env["CURA_ENGINE_SEARCH_PATH"] = f"{printers_path}:{extruders_path}:{materials_path}:{presets_path}:{variants_path}"
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)
# Re-fetch print_file and update status
print_file = PrintFile.query.get(file_id)
if not print_file:
return
if process.returncode == 0:
print_file.status = 'sliced'
else:
print_file.status = 'failed'
app.logger.error(f"CuraEngine Error: {stderr.decode()}")
except Exception as e:
# Re-fetch in case of exception
print_file = PrintFile.query.get(file_id)
if print_file:
print_file.status = 'failed'
app.logger.error(f"Subprocess Exception: {e}")
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
app = create_app()
with app.app_context():
from .models import PrintFile, db
print_file = PrintFile.query.get(file_id)
if not print_file:
return
db.session.remove()
try:
from 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)
except Exception as e:
print_file = PrintFile.query.get(file_id)
if print_file:
print_file.status = 'failed'
db.session.commit()
app.logger.error(f"Merge Exception: {e}")
finally:
db.session.remove()

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

View File

@@ -0,0 +1,230 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ _('System Settings') }}</h1>
</div>
<div class="card shadow-sm">
<div class="card-body">
<h5 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"><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"><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"><i class="bi bi-info-circle me-1"></i>{{ _('Files smaller than this will not generate a simplified proxy.') }}</div>
</div>
<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>
<input type="number" class="form-control" name="default_infill" id="default_infill" value="{{ configs.get('default_infill', '20') }}" min="0" max="100">
</div>
<div class="mb-3">
<label for="default_support" class="form-label">{{ _('Default Support') }}</label>
<select class="form-select" name="default_support" id="default_support">
<option value="false" {% if configs.get('default_support', 'false') == 'false' %}selected{% endif %}>{{ _('None') }}</option>
<option value="buildplate" {% if configs.get('default_support', 'false') == 'buildplate' %}selected{% endif %}>{{ _('Touching Buildplate') }}</option>
<option value="true" {% if configs.get('default_support', 'false') == 'true' %}selected{% endif %}>{{ _('Everywhere') }}</option>
</select>
</div>
<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" 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_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>
</div>
<script>
function submitSettings(event) {
event.preventDefault();
const form = document.getElementById('settingsForm');
const formData = new FormData(form);
const btn = document.getElementById('btn-save-settings');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Saving...';
fetch("{{ url_for('admin.settings') }}", {
method: "POST",
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.ok) {
window.showToast("{{ _('Settings updated successfully') }}", "success");
} else {
window.showToast("{{ _('Error updating settings') }}", "danger");
}
})
.catch(error => {
console.error('Error:', error);
window.showToast("{{ _('Network error') }}", "danger");
})
.finally(() => {
btn.disabled = false;
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 %}

View File

@@ -0,0 +1,179 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ _('User Management') }}</h1>
</div>
<div class="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>
<tr>
<th>{{ _('ID') }}</th>
<th>{{ _('Username') }}</th>
<th>{{ _('Role') }}</th>
<th>{{ _('Quotas') }}</th>
<th>{{ _('Created At') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>
{% if user.is_admin %}
<span class="badge bg-danger">{{ _('Admin') }}</span>
{% elif user.is_guest %}
<span class="badge bg-secondary">{{ _('Guest') }}</span>
{% else %}
<span class="badge bg-primary">{{ _('User') }}</span>
{% endif %}
</td>
<td>
{% 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>
</td>
</tr>
{% endfor %}
</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 %}

View File

@@ -1,71 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ _('System Settings') }}</h1>
</div>
<div class="card shadow-sm">
<div class="card-body">
<h5>{{ _('CuraEngine Configurations') }}</h5>
<hr>
<form method="POST" action="{{ url_for('admin.settings') }}">
<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>
<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>
<h5 class="mt-4">{{ _('Default Plater Settings') }}</h5>
<hr>
<div class="mb-3">
<label for="default_infill" class="form-label">{{ _('Default Infill Density (%)') }}</label>
<input type="number" class="form-control" name="default_infill" id="default_infill" value="{{ configs.get('default_infill', '20') }}" min="0" max="100">
</div>
<div class="mb-3">
<label for="default_support" class="form-label">{{ _('Default Support') }}</label>
<select class="form-select" name="default_support" id="default_support">
<option value="false" {% if configs.get('default_support', 'false') == 'false' %}selected{% endif %}>{{ _('None') }}</option>
<option value="buildplate" {% if configs.get('default_support', 'false') == 'buildplate' %}selected{% endif %}>{{ _('Touching Buildplate') }}</option>
<option value="true" {% if configs.get('default_support', 'false') == 'true' %}selected{% endif %}>{{ _('Everywhere') }}</option>
</select>
</div>
<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>
</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 %}
</select>
</div>
<button type="submit" class="btn btn-primary">{{ _('Save Settings') }}</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,44 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ _('User Management') }}</h1>
</div>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>{{ _('ID') }}</th>
<th>{{ _('Username') }}</th>
<th>{{ _('Role') }}</th>
<th>{{ _('Created At') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>
{% if user.is_admin %}
<span class="badge bg-danger">{{ _('Admin') }}</span>
{% elif user.is_guest %}
<span class="badge bg-secondary">{{ _('Guest') }}</span>
{% else %}
<span class="badge bg-primary">{{ _('User') }}</span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<form action="{{ url_for('admin.delete_user', user_id=user.id) }}" method="POST" class="d-inline" onsubmit="return confirm('{{ _('WARNING: Are you sure you want to permanently delete this user AND ALL their uploaded files and G-codes?') }}');">
<button type="submit" class="btn btn-sm btn-outline-danger" {% if user.id == current_user.id %}disabled{% endif %}>{{ _('Delete') }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

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

View File

@@ -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 -->
@@ -21,15 +22,55 @@
.card { border: none; border-radius: 0.75rem; overflow: hidden; }
.card-header { border-bottom: 1px solid rgba(0,0,0,.05); background-color: transparent; }
.toast-container { margin-bottom: 20px; margin-right: 20px; }
.toast { border-radius: 0.5rem; box-shadow: 0 0.5rem 1rem rgba(0,0,0,.15); opacity: 0.95; }
.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);
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 {
0% { opacity: 0; transform: translateY(10px); }
100% { opacity: 1; transform: translateY(0); }
}
main { animation: pageFadeInSlide 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; }
/* 提升 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="container-fluid">
<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>
<div class="d-flex text-light align-items-center">
<div class="d-none d-md-flex mx-auto" style="position: absolute; left: 50%; transform: translateX(-50%);">
<div class="btn-group border border-secondary shadow-sm rounded-pill p-1 bg-dark" role="group" style="background-color: #1a1e21 !important;">
<a href="{{ url_for('main.files') }}" class="btn btn-sm rounded-pill {% if not request.blueprint == 'printer' %}btn-primary active fw-bold text-white px-4{% else %}btn-transparent text-secondary border-0 px-3{% endif %}" style="transition: all 0.2s;"><i class="bi bi-layers me-1"></i>{{ _('Slicer') }}</a>
<a href="{{ url_for('printer.status') }}" class="btn btn-sm rounded-pill {% if request.blueprint == 'printer' %}btn-primary active fw-bold text-white px-4{% else %}btn-transparent text-secondary border-0 px-3{% endif %}" style="transition: all 0.2s;"><i class="bi bi-printer-fill me-1"></i>{{ _('Printer') }}</a>
</div>
</div>
<div class="d-flex text-light align-items-center ms-auto">
<div class="dropdown me-3">
<button class="btn btn-sm btn-outline-light dropdown-toggle" type="button" id="langDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-globe me-1"></i>{{ _('Language') }}
@@ -37,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>
@@ -53,10 +95,79 @@
</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">
<div class="sidebar-sticky pt-3 px-2">
{% if request.blueprint == 'printer' %}
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mb-2 text-muted fw-bold text-uppercase" style="font-size: 0.75rem;">
<span><i class="bi bi-list-task me-1"></i>{{ _('General Operations') }}</span>
</h6>
<ul class="nav flex-column nav-pills gap-1">
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'printer.status' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.status') }}">
<i class="bi bi-activity me-2"></i>{{ _('Printer Status') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'printer.prepare' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.prepare') }}">
<i class="bi bi-file-earmark-plus me-2"></i>{{ _('Prepare Print') }}
</a>
</li>
<li class="nav-item mb-1">
<a class="nav-link text-dark {% if request.endpoint == 'printer.control' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.control') }}">
<i class="bi bi-arrows-move me-2"></i>{{ _('Control') }}
</a>
</li>
<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 %}
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-2 text-muted fw-bold text-uppercase" style="font-size: 0.75rem;">
<span><i class="bi bi-shield-lock me-1"></i>{{ _('Admin / OctoPrint') }}</span>
</h6>
<ul class="nav flex-column nav-pills gap-1 mb-2">
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'printer.octo_config' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.octo_config') }}">
<i class="bi bi-plug me-2"></i>{{ _('System Config') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'printer.octo_embed' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.octo_embed') }}">
<i class="bi bi-window-sidebar me-2"></i>{{ _('OctoPrint Panel') }}
</a>
</li>
</ul>
{% endif %}
{% else %}
<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') }}">
@@ -73,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 %}
@@ -90,37 +213,78 @@
<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 %}
</div>
</nav>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 mt-4 bg-light min-vh-100 pb-5">
<!-- Toast Notification Container -->
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1055;">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
{% set toast_class = 'bg-success text-white' if category == 'success' else 'bg-danger text-white' if category == 'danger' else 'bg-warning text-dark' if category == 'warning' else 'bg-primary text-white' %}
<div class="toast align-items-center border-0 {{ toast_class }}" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body fw-medium">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
{% block content %}{% endblock %}
</main>
</div>
</div>
<!-- Toast Notification Container -->
<div class="toast-container" id="global-toast-container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
{% set toast_class = 'bg-success text-white' if category == 'success' else 'bg-danger text-white' if category == 'danger' else 'bg-warning text-dark' if category == 'warning' else 'bg-primary text-white' %}
<div class="toast align-items-center border-0 {{ toast_class }} mb-2" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body fw-medium">
{{ _(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>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</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">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-warning text-dark py-2">
<h6 class="modal-title fw-bold" id="globalAlertTitle"><i class="bi bi-exclamation-triangle-fill me-2"></i>{{ _('Notice') }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body fs-6 text-center py-4 text-break" id="globalAlertMessage">
</div>
<div class="modal-footer border-0 p-2 justify-content-center bg-light">
<button type="button" class="btn btn-warning px-4 rounded-pill fw-bold" data-bs-dismiss="modal">{{ _('OK') }}</button>
</div>
</div>
</div>
</div>
<!-- Global Custom Confirm Modal -->
<div class="modal fade" id="globalConfirmModal" tabindex="-1" aria-hidden="true" style="z-index: 1060;">
<div class="modal-dialog modal-dialog-centered modal-sm">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-primary text-white py-2">
<h6 class="modal-title fw-bold"><i class="bi bi-question-circle-fill me-2"></i>{{ _('Confirm') }}</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body fs-6 text-center py-4 text-break" id="globalConfirmMessage">
</div>
<div class="modal-footer border-0 p-2 justify-content-center bg-light">
<button type="button" class="btn btn-outline-secondary px-4 rounded-pill" data-bs-dismiss="modal">{{ _('Cancel') }}</button>
<button type="button" class="btn btn-primary px-4 rounded-pill fw-bold" id="globalConfirmBtn">{{ _('Yes') }}</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<script>
// Initialize Toasts automatically
@@ -130,6 +294,53 @@
return new bootstrap.Toast(toastEl, { delay: 3000 }).show()
});
});
// Global Utility: Show Toast dynamically
window.showToast = function(msg, type='success', duration=3000) {
const container = document.getElementById('global-toast-container');
const toastClass = type === 'success' ? 'bg-success text-white' :
type === 'danger' ? 'bg-danger text-white' :
type === 'warning' ? 'bg-warning text-dark' : 'bg-primary text-white';
const html = `
<div class="toast align-items-center border-0 ${toastClass} mb-2" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body fw-medium">${msg}</div>
<button type="button" class="btn-close ${type==='warning'?'':'btn-close-white'} me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
container.insertAdjacentHTML('beforeend', html);
const ts = container.lastElementChild;
new bootstrap.Toast(ts, { autohide: true, delay: duration }).show();
ts.addEventListener('hidden.bs.toast', () => { ts.remove(); });
};
// Override default alert
window.customAlert = function(msg, title) {
document.getElementById('globalAlertMessage').innerHTML = String(msg).replace(/\n/g, '<br>');
if(title) document.getElementById('globalAlertTitle').innerHTML = '<i class="bi bi-info-circle-fill me-2"></i>' + title;
else document.getElementById('globalAlertTitle').innerHTML = '<i class="bi bi-exclamation-triangle-fill me-2"></i>Notice';
new bootstrap.Modal(document.getElementById('globalAlertModal')).show();
};
// Override default confirm
window.customConfirm = function(msg, onConfirm) {
document.getElementById('globalConfirmMessage').innerHTML = String(msg).replace(/\n/g, '<br>');
const modalEl = document.getElementById('globalConfirmModal');
const modal = new bootstrap.Modal(modalEl);
// Clear previous event listener bindings
const elClone = document.getElementById('globalConfirmBtn').cloneNode(true);
document.getElementById('globalConfirmBtn').parentNode.replaceChild(elClone, document.getElementById('globalConfirmBtn'));
document.getElementById('globalConfirmBtn').addEventListener('click', function() {
modal.hide();
if(onConfirm) onConfirm();
});
modal.show();
};
</script>
</body>
</html>

View File

@@ -1,18 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ _('Dashboard') }}</h1>
</div>
<div class="row">
<div class="col-md-4">
<div class="card text-white bg-primary mb-3 shadow-sm border-0">
<div class="card-header border-0 fs-5 fw-medium"><i class="bi bi-bar-chart-fill me-2"></i>{{ _('Total Prints') }}</div>
<div class="card-body mt-2">
<h5 class="card-title">{{ _('You have sliced') }} <b class="fs-1 mx-2">{{ current_user.print_files|length }}</b> {{ _('files') }}</h5>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,100 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-arrows-move text-primary me-2"></i>{{ _('Printer Control') }}</h1>
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
</div>
{% else %}
<div class="row row-cols-1 row-cols-lg-2 g-4">
<!-- Webcam Stream -->
<div class="col">
<div class="card shadow-sm h-100">
<div class="card-header bg-dark text-light fw-bold rounded-top">
<i class="bi bi-camera-video me-1"></i>{{ _('Live Webcam') }}
</div>
<div class="card-body p-0 ratio ratio-16x9 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>
<!-- Motion Control -->
<div class="col">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold text-secondary">
<i class="bi bi-dpad me-1"></i>{{ _('Basic Control') }}
</div>
<div class="card-body text-center d-flex flex-column justify-content-center align-items-center">
<!-- Motion Controls -->
<div class="d-flex gap-4 justify-content-center mb-4 w-100">
<!-- Home button -->
<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 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">
<button class="btn btn-outline-danger flex-fill shadow-sm py-3" onclick="sendCommand('pause')" title="{{ _('Pause/Resume Print') }}">
<i class="bi bi-pause-circle fs-4 d-block mb-1"></i>{{ _('Pause') }}
</button>
<button class="btn btn-outline-warning flex-fill shadow-sm py-3" onclick="sendCommand('cancel')" title="{{ _('Cancel Print') }}">
<i class="bi bi-stop-circle fs-4 d-block mb-1"></i>{{ _('Cancel') }}
</button>
</div>
</div>
</div>
</div>
</div>
<script>
function sendCommand(cmdName) {
if (cmdName === 'cancel' || cmdName === 'home' || cmdName === 'auto_level') {
window.customConfirm("{{ _('Are you sure you want to perform this action?') }}", () => doSendCommand(cmdName));
} else {
doSendCommand(cmdName);
}
}
function doSendCommand(cmdName) {
fetch('{{ url_for("printer.api_command") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({command: cmdName})
})
.then(r => r.json())
.then(data => {
if(data.success) {
window.showToast("{{ _('Command') }} " + cmdName + " {{ _('sent.') }}", "success");
} else {
window.customAlert("{{ _('Control failed: ') }}" + data.error);
}
})
.catch(err => {
window.customAlert("{{ _('Network Error: ') }}" + err);
});
}
</script>
{% endif %}
{% endblock %}

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

View File

@@ -0,0 +1,78 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-gear-wide-connected text-primary me-2"></i>{{ _('OctoPrint Configuration') }}</h1>
</div>
<div class="card shadow-sm border-0">
<div class="card-header bg-light fw-bold text-secondary border-bottom-0">
<i class="bi bi-link-45deg me-1"></i>{{ _('Connection Settings') }}
</div>
<div class="card-body">
<form id="octoConfigForm" onsubmit="submitConfig(event)">
<div class="mb-3">
<label for="octoprint_url" class="form-label fw-bold">{{ _('OctoPrint Base URL') }}</label>
<div class="input-group mb-3 shadow-sm">
<span class="input-group-text bg-white text-muted" id="url-addon"><i class="bi bi-globe"></i></span>
<input type="url" class="form-control" id="octoprint_url" name="octoprint_url" aria-describedby="url-addon" placeholder="e.g. http://octopi.local" value="{{ configs.get('octoprint_url', '') }}" required>
</div>
<div class="form-text">
{{ _('The local IP address or hostname of your OctoPrint server.') }}
</div>
</div>
<div class="mb-4">
<label for="octoprint_apikey" class="form-label fw-bold">{{ _('API Key / Application Key') }}</label>
<div class="input-group shadow-sm">
<span class="input-group-text bg-white text-muted" id="key-addon"><i class="bi bi-key"></i></span>
<input type="password" class="form-control" id="octoprint_apikey" name="octoprint_apikey" aria-describedby="key-addon" placeholder="{{ _('Paste API Key here') }}" value="{{ configs.get('octoprint_apikey', '') }}">
</div>
<div class="form-text">
{{ _('Can be found in OctoPrint Settings -> Application Keys or API.') }}
</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary px-4 rounded-pill shadow-sm" id="btn-save-octo"><i class="bi bi-save2 me-2"></i>{{ _('Save Connection Settings') }}</button>
</div>
</form>
</div>
</div>
<script>
function submitConfig(event) {
event.preventDefault();
const form = document.getElementById('octoConfigForm');
const formData = new FormData(form);
const btn = document.getElementById('btn-save-octo');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Saving...';
fetch("{{ url_for('printer.octo_config') }}", {
method: "POST",
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.ok) {
window.showToast("{{ _('OctoPrint settings updated') }}", "success");
} else {
window.showToast("{{ _('Error saving settings') }}", "danger");
}
})
.catch(error => {
console.error('Error:', error);
window.showToast("{{ _('Network error') }}", "danger");
})
.finally(() => {
btn.disabled = false;
btn.innerHTML = originalText;
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% 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 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>
{% else %}
<div class="alert alert-warning shadow-sm border-0 d-flex align-items-center" role="alert">
<i class="bi bi-exclamation-triangle-fill fs-4 text-warning me-3"></i>
<div>
<strong>{{ _('Configuration Required:') }}</strong>
{{ _('The OctoPrint URL is not set. Please go to the ') }} <a href="{{ url_for('printer.octo_config') }}" class="alert-link text-decoration-underline">{{ _('System Configuration') }}</a> {{ _('page to set it up.') }}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,134 @@
{% 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 %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
</div>
{% else %}
<div class="card shadow-sm">
<div class="card-header bg-light fw-bold text-secondary">
<i class="bi bi-card-text me-1"></i>{{ _('Available Files on Printer') }}
</div>
<div class="list-group list-group-flush">
{% for f in files %}
{% if f.type == 'machinecode' %}
<div 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 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>
</div>
{% endif %}
{% else %}
<div class="list-group-item text-center py-5 text-muted">
<i class="bi bi-inbox display-4 d-block mb-3"></i>
<p>{{ _('No printable files found. Go slice some G-Code first!') }}</p>
</div>
{% endfor %}
</div>
</div>
<script>
function printFile(origin, path) {
window.customConfirm("{{ _('Send this file to print immediately?') }}<br><small>" + path + "</small>", () => {
fetch('{{ url_for("printer.api_print_file") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ origin: origin, path: path })
})
.then(response => response.json())
.then(data => {
if(data.success) {
window.showToast("{{ _('Print starting! Going to dashboard...') }}", "success");
setTimeout(() => {
window.location.href = "{{ url_for('printer.status') }}";
}, 1500);
} else {
window.customAlert("Error: " + data.error);
}
})
.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 %}

View File

@@ -0,0 +1,162 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-activity text-primary me-2"></i>{{ _('Printer Status') }}</h1>
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
{% if current_user.is_admin %}
<a href="{{ url_for('printer.octo_config') }}" class="alert-link">{{ _('Go to Configuration') }}</a>
{% endif %}
</div>
{% elif status %}
<div class="row row-cols-1 row-cols-md-2 g-4">
<!-- State Card -->
<div class="col">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold text-secondary">
<i class="bi bi-info-circle me-1"></i>{{ _('Current State') }}
</div>
<div class="card-body text-center">
<h3 class="display-6 mt-3 text-primary" id="printer-state-text">{{ status.get('state', {}).get('text', 'Unknown') if status else 'Unknown' }}</h3>
</div>
</div>
</div>
<!-- Temperature Card -->
<div class="col">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold text-secondary">
<i class="bi bi-thermometer-half me-1"></i>{{ _('Temperatures') }}
</div>
<div class="card-body">
{% set temps = status.get('temperature', {}) 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">
<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">
<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>
<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 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) if job else 0 %}
{% if progress == None %}{% set progress = 0 %}{% endif %}
<div class="progress mt-3 mb-2" style="height: 25px;">
<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> <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">
<button class="btn btn-warning" onclick="sendCmd('pause')"><i class="bi bi-pause-fill me-1"></i>{{ _('Pause/Resume') }}</button>
<button class="btn btn-danger" onclick="sendCmd('cancel')"><i class="bi bi-stop-fill me-1"></i>{{ _('Cancel') }}</button>
</div>
</div>
</div>
{% endif %}
<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));
} else {
doSendCmd(cmd);
}
}
function doSendCmd(cmd) {
fetch('{{ url_for("printer.api_command") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({command: cmd})
})
.then(res => res.json())
.then(data => {
if(data.success) {
updateStatus();
} else {
window.customAlert("Error: " + data.error);
}
});
}
</script>
{% endblock %}

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

View File

@@ -9,8 +9,8 @@
<div class="card-body">
<div id="drop-zone" class="border border-2 border-primary rounded p-4 text-center bg-white" style="border-style: dashed !important; cursor: pointer; transition: all 0.3s ease;">
<i class="bi bi-cloud-arrow-up display-4 text-primary mb-2"></i>
<h5 class="text-secondary fw-normal">{{ _('Drag & Drop STL file here or Click to Select') }}</h5>
<input type="file" id="file" name="file" accept=".stl" class="d-none">
<h5 class="text-secondary fw-normal">{{ _('Drag & Drop STL files here or Click to Select') }}</h5>
<input type="file" id="file" name="file" accept=".stl" class="d-none" multiple>
</div>
<div id="upload-progress-container" class="mt-3 d-none">
@@ -39,12 +39,20 @@
</thead>
<tbody>
{% for file in files %}
<tr id="file-row-{{ file.id }}" data-status="{{ file.status }}">
<td class="ps-4 text-muted"><i class="bi bi-clock me-1"></i>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td class="fw-medium">{{ file.original_filename }}</td>
{% 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">
<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>
{% elif file.status == 'simplifying' %}
<span class="badge bg-secondary text-dark rounded-pill fw-normal px-2" style="background-color: #e2e3e5 !important;"><i class="bi bi-funnel bi-spin me-1"></i>{{ _('Simplifying') }}...</span>
{% elif file.status == 'uploaded' %}
<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _('Uploaded') }}</span>
{% elif file.status == 'merging' %}
@@ -59,12 +67,14 @@
</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>
{% endif %}
<form action="{{ url_for('main.delete_file', file_id=file.id) }}" method="POST" onsubmit="return confirm('{{ _('Are you sure you want to delete this file?') }}');">
<form action="{{ url_for('main.delete_file', file_id=file.id) }}" method="POST" onsubmit="event.preventDefault(); window.customConfirm('{{ _('Are you sure you want to delete this file?') }}', () => { this.submit(); });">
<button type="submit" class="btn btn-sm btn-outline-danger shadow-sm" title="{{ _('Delete') }}"><i class="bi bi-trash3"></i></button>
</form>
</div>
@@ -86,6 +96,25 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
// Convert UTC to local time
document.querySelectorAll('.local-time').forEach(el => {
let utcStr = el.getAttribute('data-utc');
if (!utcStr) return;
if (!utcStr.endsWith('Z') && !utcStr.includes('+')) {
utcStr += 'Z';
}
const localDate = new Date(utcStr);
if (!isNaN(localDate)) {
const year = localDate.getFullYear();
const month = String(localDate.getMonth() + 1).padStart(2, '0');
const day = String(localDate.getDate()).padStart(2, '0');
const hours = String(localDate.getHours()).padStart(2, '0');
const minutes = String(localDate.getMinutes()).padStart(2, '0');
const seconds = String(localDate.getSeconds()).padStart(2, '0');
el.textContent = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
});
const checkInterval = 1000;
let pollTimer = null;
@@ -105,6 +134,7 @@ document.addEventListener('DOMContentLoaded', function() {
const actionsTd = document.getElementById('actions-container-' + id);
if (status === 'waiting') statusTd.innerHTML = '<span class="badge bg-info text-dark rounded-pill fw-normal px-2"><i class="bi bi-hourglass-split me-1"></i>{{ _("Waiting") }}...</span>';
else if (status === 'simplifying') statusTd.innerHTML = '<span class="badge bg-secondary text-dark rounded-pill fw-normal px-2" style="background-color: #e2e3e5 !important;"><i class="bi bi-funnel bi-spin me-1"></i>{{ _("Simplifying") }}...</span>';
else if (status === 'uploaded') statusTd.innerHTML = '<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _("Uploaded") }}</span>';
else if (status === 'merging') statusTd.innerHTML = '<span class="badge bg-primary text-light rounded-pill fw-normal px-2"><i class="bi bi-intersect me-1"></i>{{ _("Merging") }}...</span>';
else if (status === 'slicing') statusTd.innerHTML = '<span class="badge bg-warning text-dark rounded-pill fw-normal px-2"><i class="bi bi-gear-wide-connected bi-spin me-1"></i>{{ _("Slicing") }}...</span>';
@@ -113,7 +143,10 @@ document.addEventListener('DOMContentLoaded', function() {
let actionsHtml = '';
const platerUrl = `{{ url_for('main.plater') }}?add=${id}`;
actionsHtml += `<a href="${platerUrl}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>\n`;
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);
@@ -122,13 +155,13 @@ document.addEventListener('DOMContentLoaded', function() {
actionsHtml += `<a href="${previewUrl}" class="btn btn-sm btn-outline-info shadow-sm" title="{{ _('GCode Preview') }}"><i class="bi bi-eye"></i></a>\n`;
}
const deleteUrl = `{{ url_for('main.delete_file', file_id=999999999) }}`.replace('999999999', id);
actionsHtml += `<form action="${deleteUrl}" method="POST" onsubmit="return confirm('{{ _('Are you sure you want to delete this file?') }}');">
actionsHtml += `<form action="${deleteUrl}" method="POST" onsubmit="event.preventDefault(); window.customConfirm('{{ _('Are you sure you want to delete this file?') }}', () => { this.submit(); });">
<button type="submit" class="btn btn-sm btn-outline-danger shadow-sm" title="{{ _('Delete') }}"><i class="bi bi-trash3"></i></button>
</form>`;
actionsTd.innerHTML = actionsHtml;
}
if (status === 'waiting' || status === 'slicing' || status === 'merging') {
if (status === 'waiting' || status === 'slicing' || status === 'merging' || status === 'simplifying') {
hasPending = true;
}
}
@@ -174,25 +207,32 @@ document.addEventListener('DOMContentLoaded', function() {
dropZone.addEventListener('drop', e => {
const files = e.dataTransfer.files;
if (files.length) {
handleFileUpload(files[0]);
handleFileUpload(files);
}
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) {
handleFileUpload(fileInput.files[0]);
handleFileUpload(fileInput.files);
}
});
function handleFileUpload(file) {
if (!file.name.toLowerCase().endsWith('.stl')) {
alert('{{ _("Please upload a valid .stl file!") }}');
function handleFileUpload(files) {
const formData = new FormData();
let hasValidFile = false;
for (let i = 0; i < files.length; i++) {
if (files[i].name.toLowerCase().endsWith('.stl')) {
formData.append('file', files[i]);
hasValidFile = true;
}
}
if (!hasValidFile) {
window.customAlert('{{ _("Please upload valid .stl files!") }}');
return;
}
const formData = new FormData();
formData.append('file', file);
progressContainer.classList.remove('d-none');
dropZone.classList.add('d-none');
progressBar.style.width = '0%';
@@ -219,7 +259,7 @@ document.addEventListener('DOMContentLoaded', function() {
try {
let response = JSON.parse(xhr.responseText);
if (response.error) {
alert('{{ _("Validation Failed") }}:\n' + response.error);
window.customAlert('{{ _("Validation Failed") }}:\n' + response.error);
progressContainer.classList.add('d-none');
dropZone.classList.remove('d-none');
return;
@@ -227,14 +267,14 @@ document.addEventListener('DOMContentLoaded', function() {
} catch(e) {
console.log('No JSON error response');
}
alert('{{ _("Upload failed.") }}');
window.customAlert('{{ _("Upload failed.") }}');
progressContainer.classList.add('d-none');
dropZone.classList.remove('d-none');
}
};
xhr.onerror = function() {
alert('{{ _("Upload error.") }}');
window.customAlert('{{ _("Upload error.") }}');
progressContainer.classList.add('d-none');
dropZone.classList.remove('d-none');
};

View File

@@ -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;"><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,87 +106,90 @@ 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: 1.0 },
uShowDefault: { value: 1.0 }
},
vertexShader: `
attribute float pType;
varying vec3 vColor;
varying float vType;
void main() {
vColor = color;
vType = pType;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
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;
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;
if (show < 0.5) discard;
gl_FragColor = vec4(vColor, 1.0);
}
`,
vertexColors: true,
side: THREE.DoubleSide,
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'
};
document.querySelectorAll('.legend-item').forEach(el => {
el.addEventListener('click', function() {
const t = this.dataset.type;
const uniformName = uniformMap[t];
if (uniformName) {
const currentVal = gcodeMat.uniforms[uniformName].value;
const newVal = currentVal > 0.5 ? 0.0 : 1.0;
gcodeMat.uniforms[uniformName].value = newVal;
this.style.opacity = newVal > 0.5 ? "1.0" : "0.4";
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;
varying float vType;
void main() {
vColor = color;
vType = pType;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
varying vec3 vColor;
varying float vType;
${fragmentUniformsDecl}
void main() {
float show = 1.0;
int t = int(vType + 0.5);
${fragmentUniformsLogic}
if (show < 0.5) discard;
gl_FragColor = vec4(vColor, 1.0);
}
`,
vertexColors: true,
side: THREE.DoubleSide,
linewidth: 1
});
// Legend binding
document.querySelectorAll('.legend-item').forEach(el => {
el.addEventListener('click', function() {
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;
this.style.opacity = newVal > 0.5 ? "1.0" : "0.4";
}
});
});
}
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;
}
current.x = next.x; current.y = next.y; current.z = next.z;
}
} else if (chunk.startsWith('G92')) {
} 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; }

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

View File

@@ -0,0 +1,57 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ _('Dashboard') }}</h1>
</div>
<div class="row">
<!-- 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-box me-2"></i>{{ _('3D Model Files (STL)') }}
</div>
<div class="card-body mt-2">
<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>
</div>
{% endblock %}

View File

@@ -16,7 +16,7 @@
<!-- 3D Area -->
<div class="col-md-9 h-100 position-relative">
<div id="plater-container" class="w-100 h-100 rounded shadow-sm border border-secondary" style="overflow: hidden; background: #f8f9fa;"></div>
<div id="plater-container" class="w-100 h-100 rounded shadow-sm border border-secondary" style="overflow: hidden; background: #f8f9fa; position: relative;"></div>
<!-- Parameterized Scale Input Box -->
<div id="scale-panel" class="position-absolute top-50 start-0 translate-middle-y ms-5 ps-4 d-none" style="z-index: 10; pointer-events: none;">
@@ -52,13 +52,15 @@
</div>
<!-- Sidebar -->
<div class="col-md-3 h-100 d-flex flex-column pb-3" style="overflow-y: auto; overflow-x: hidden;">
<div class="card shadow-sm mb-3">
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center" style="cursor: pointer; z-index: 10;" data-bs-toggle="collapse" data-bs-target="#collapseModels" aria-expanded="true">
<span><i class="bi bi-layers-fill me-2"></i>{{ _('Available Models') }}</span>
<i class="bi bi-chevron-bar-contract"></i>
</div>
<div id="collapseModels" class="collapse show">
<div class="col-md-3 h-100 d-flex flex-column pb-3">
<!-- Accordion wrapper for options -->
<div class="accordion flex-grow-1" id="platerSidebarAccordion" style="overflow-y: auto; overflow-x: hidden; padding-right: 5px;">
<div class="card shadow-sm mb-3">
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center" style="cursor: pointer; z-index: 10;" data-bs-toggle="collapse" data-bs-target="#collapseModels" aria-expanded="true">
<span><i class="bi bi-layers-fill me-2"></i>{{ _('Available Models') }}</span>
<i class="bi bi-chevron-bar-contract"></i>
</div>
<div id="collapseModels" class="collapse show" data-bs-parent="#platerSidebarAccordion">
<div class="list-group list-group-flush" id="model-list" style="min-height: 160px; max-height: max(250px, 35vh); overflow-y: auto;">
{% for model in models %}
<button id="add-model-btn-{{ model.id }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" data-matrix="{{ model.transform_matrix or '' }}" onclick="addModelToPlate(this, {{ model.id }}, '{{ model.url }}', '{{ model.name }}', '{{ model.status }}')">
@@ -73,11 +75,11 @@
</div>
<div class="card shadow-sm mb-3 flex-shrink-0">
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapseSettings" aria-expanded="true">
<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="#collapseSettings" aria-expanded="false">
<span><i class="bi bi-sliders me-2"></i>{{ _('Other Settings') }}</span>
<i class="bi bi-chevron-bar-contract"></i>
</div>
<div id="collapseSettings" class="collapse show">
<div id="collapseSettings" class="collapse" data-bs-parent="#platerSidebarAccordion">
<div class="card-body py-2">
<div class="mb-2">
<label for="infill-density" class="form-label text-secondary small mb-1">{{ _('Infill Density') }} (%)</label>
@@ -93,47 +95,46 @@
</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">
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapseQuality" aria-expanded="true">
<span><i class="bi bi-gear-wide-connected me-2"></i>{{ _('Quality Profile') }}</span>
<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="collapseQuality" class="collapse show">
<div id="collapseMaterial" 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>
</div>
<hr>
<div class="d-flex justify-content-between">
<button class="btn btn-outline-danger btn-sm" onclick="clearPlate()"><i class="bi bi-trash me-1"></i>{{ _('Clear Board') }}</button>
<button class="btn btn-primary" 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>
<select class="form-select bg-light" id="material" data-selected="{{ last_material }}"></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="#collapseQuality" aria-expanded="false">
<span><i class="bi bi-gear-wide-connected me-2"></i>{{ _('Quality Profile') }}</span>
<i class="bi bi-chevron-bar-contract"></i>
</div>
<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" data-selected="{{ last_quality }}"></select>
</div>
</div>
</div>
</div>
</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="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>
</div>
</div>
@@ -146,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 () {
@@ -174,6 +180,13 @@ let offsetX = {{ offset_x|default(0) }};
let offsetY = {{ offset_y|default(0) }};
let loadedModels = [];
let activeModel = null;
let selectedModels = [];
let selectionBoxDiv = document.createElement('div');
selectionBoxDiv.id = 'selection-box';
selectionBoxDiv.style.cssText = 'position: absolute; border: 1px dashed #007bff; background: rgba(0, 123, 255, 0.1); pointer-events: none; display: none; z-index: 100;';
document.getElementById('plater-container').appendChild(selectionBoxDiv);
let dragStartPoint = null;
let isDraggingBox = false;
const initialAddId = new URLSearchParams(window.location.search).get('add');
initPlater();
@@ -212,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);
@@ -271,12 +296,24 @@ function initPlater() {
});
transformControl.addEventListener('dragging-changed', function (event) {
orbit.enabled = !event.value;
if (!event.value && activeModel) {
scene.attach(activeModel);
transformProxy.position.copy(activeModel.getWorldPosition(new THREE.Vector3()));
if (!event.value && selectedModels.length > 0) {
let center = new THREE.Vector3();
selectedModels.forEach(m => {
scene.attach(m);
m.geometry.computeBoundingBox();
let box = m.geometry.boundingBox.clone();
box.applyMatrix4(m.matrixWorld);
center.add(box.getCenter(new THREE.Vector3()));
});
center.divideScalar(selectedModels.length);
transformProxy.position.copy(center);
transformProxy.rotation.set(0, 0, 0);
transformProxy.scale.set(1, 1, 1);
transformProxy.attach(activeModel);
selectedModels.forEach(m => {
transformProxy.attach(m);
});
}
});
scene.add(transformControl);
@@ -306,7 +343,7 @@ function setTransformMode(mode) {
document.getElementById('btn-scale').className = mode === 'scale' ? 'btn btn-primary btn-sm rounded' : 'btn btn-outline-secondary btn-sm rounded';
document.getElementById('btn-layflat').className = 'btn btn-outline-info btn-sm rounded';
if (mode === 'scale' && activeModel) {
if (mode === 'scale' && selectedModels && selectedModels.length > 0) {
document.getElementById('scale-panel').classList.remove('d-none');
updateScalePanel();
} else {
@@ -324,47 +361,56 @@ function setTransformMode(mode) {
}
function updateScalePanel() {
if (!activeModel) return;
const v = activeModel.getWorldScale(new THREE.Vector3());
document.getElementById('scale-x').value = v.x.toFixed(3);
document.getElementById('scale-y').value = v.y.toFixed(3);
document.getElementById('scale-z').value = v.z.toFixed(3);
if (selectedModels.length === 0) return;
// Check if scales match
let firstScale = selectedModels[0].getWorldScale(new THREE.Vector3());
let allXMatch = true, allYMatch = true, allZMatch = true;
for (let i = 1; i < selectedModels.length; i++) {
let v = selectedModels[i].getWorldScale(new THREE.Vector3());
if (Math.abs(v.x - firstScale.x) > 0.001) allXMatch = false;
if (Math.abs(v.y - firstScale.y) > 0.001) allYMatch = false;
if (Math.abs(v.z - firstScale.z) > 0.001) allZMatch = false;
}
document.getElementById('scale-x').value = allXMatch ? firstScale.x.toFixed(3) : '';
document.getElementById('scale-y').value = allYMatch ? firstScale.y.toFixed(3) : '';
document.getElementById('scale-z').value = allZMatch ? firstScale.z.toFixed(3) : '';
}
function applyScaleInput(axis) {
if (!activeModel) return;
let val = parseFloat(document.getElementById('scale-' + axis).value);
if (selectedModels.length === 0) return;
let valStr = document.getElementById('scale-' + axis).value;
if (valStr === '') return;
let val = parseFloat(valStr);
if (isNaN(val) || val <= 0.001) val = 1.0;
scene.attach(activeModel); // temporarily detach to operate purely on local=world scale
const isUniform = document.getElementById('scale-uniform').checked;
if (isUniform) {
// Find previous scale
const prev = activeModel.scale[axis];
const ratio = val / prev;
activeModel.scale.x *= ratio;
activeModel.scale.y *= ratio;
activeModel.scale.z *= ratio;
} else {
activeModel.scale[axis] = val;
}
selectedModels.forEach(m => {
scene.attach(m);
if (isUniform) {
const prev = m.scale[axis];
const ratio = val / prev;
m.scale.x *= ratio;
m.scale.y *= ratio;
m.scale.z *= ratio;
} else {
m.scale[axis] = val;
}
m.updateMatrixWorld(true);
});
activeModel.updateMatrixWorld(true);
// re-attach proxy pivot logic without modifying the actual spatial scale
transformProxy.scale.set(1, 1, 1);
transformProxy.attach(activeModel);
selectedModels.forEach(m => {
transformProxy.attach(m);
});
updateScalePanel();
}
function removeActiveModel() {
if (activeModel) {
removeModel(activeModel);
}
}
function onKeyDown(event) {
switch (event.key.toLowerCase()) {
@@ -386,6 +432,15 @@ function onPointerDown(event) {
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
if (event.ctrlKey || event.metaKey) {
dragStartPoint = { x: event.clientX, y: event.clientY };
isDraggingBox = false;
orbit.enabled = false;
document.addEventListener('pointermove', onPointerMoveBox);
document.addEventListener('pointerup', onPointerUpBox);
}
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(loadedModels, true);
@@ -424,30 +479,146 @@ function onPointerDown(event) {
// Exit lay flat mode and reset to translate
setTransformMode('translate');
selectModel(obj);
selectModels([obj]);
}
}
return;
}
if (intersects.length > 0) {
selectModel(intersects[0].object);
if (event.ctrlKey || event.metaKey) {
toggleModelSelection(intersects[0].object);
} else {
selectModels([intersects[0].object]);
}
} else {
selectModel(null);
if (!event.ctrlKey && !event.metaKey) {
selectModels([]);
}
}
}
function selectModel(model) {
if (activeModel && activeModel !== model) {
scene.attach(activeModel);
function onPointerMoveBox(event) {
if (!dragStartPoint) return;
const dx = Math.abs(event.clientX - dragStartPoint.x);
const dy = Math.abs(event.clientY - dragStartPoint.y);
if (dx > 5 || dy > 5) {
isDraggingBox = true;
const container = document.getElementById('plater-container');
const rect = container.getBoundingClientRect();
const startX = dragStartPoint.x - rect.left;
const startY = dragStartPoint.y - rect.top;
const currentX = event.clientX - rect.left;
const currentY = event.clientY - rect.top;
selectionBoxDiv.style.display = 'block';
selectionBoxDiv.style.left = Math.min(currentX, startX) + 'px';
selectionBoxDiv.style.top = Math.min(currentY, startY) + 'px';
selectionBoxDiv.style.width = Math.abs(currentX - startX) + 'px';
selectionBoxDiv.style.height = Math.abs(currentY - startY) + 'px';
}
activeModel = model;
if (model) {
scene.attach(model);
transformProxy.position.copy(model.getWorldPosition(new THREE.Vector3()));
}
function onPointerUpBox(event) {
document.removeEventListener('pointermove', onPointerMoveBox);
document.removeEventListener('pointerup', onPointerUpBox);
orbit.enabled = true;
if (isDraggingBox) {
selectionBoxDiv.style.display = 'none';
const rect = renderer.domElement.getBoundingClientRect();
const minX = Math.min(dragStartPoint.x, event.clientX) - rect.left;
const maxX = Math.max(dragStartPoint.x, event.clientX) - rect.left;
const minY = Math.min(dragStartPoint.y, event.clientY) - rect.top;
const maxY = Math.max(dragStartPoint.y, event.clientY) - rect.top;
let newSelection = [...selectedModels];
loadedModels.forEach(m => {
m.geometry.computeBoundingBox();
let box = m.geometry.boundingBox.clone();
box.applyMatrix4(m.matrixWorld);
// Project 8 corners
const corners = [
new THREE.Vector3(box.min.x, box.min.y, box.min.z),
new THREE.Vector3(box.max.x, box.min.y, box.min.z),
new THREE.Vector3(box.min.x, box.max.y, box.min.z),
new THREE.Vector3(box.max.x, box.max.y, box.min.z),
new THREE.Vector3(box.min.x, box.min.y, box.max.z),
new THREE.Vector3(box.max.x, box.min.y, box.max.z),
new THREE.Vector3(box.min.x, box.max.y, box.max.z),
new THREE.Vector3(box.max.x, box.max.y, box.max.z)
];
let inside = false;
corners.forEach(v => {
v.project(camera);
let sx = (v.x * .5 + .5) * rect.width;
let sy = (v.y * -.5 + .5) * rect.height;
if (sx >= minX && sx <= maxX && sy >= minY && sy <= maxY) {
inside = true;
}
});
if (inside && !newSelection.includes(m)) {
newSelection.push(m);
}
});
selectModels(newSelection);
} else if (dragStartPoint && !isDraggingBox) {
// Just a ctrl+click that missed logic is handled by raycaster above, but we have to ensure no double toggle
}
dragStartPoint = null;
isDraggingBox = false;
}
function toggleModelSelection(model) {
let newSel = [...selectedModels];
if (newSel.includes(model)) {
newSel = newSel.filter(m => m !== model);
} else {
newSel.push(model);
}
selectModels(newSel);
}
function selectModels(models) {
selectedModels.forEach(m => {
scene.attach(m);
m.material.color.setHex(0xcccccc);
});
selectedModels = models;
activeModel = selectedModels.length > 0 ? selectedModels[selectedModels.length - 1] : null;
if (selectedModels.length > 0) {
// compute joint center
let center = new THREE.Vector3();
let count = 0;
selectedModels.forEach(m => {
m.material.color.setHex(0x0d6efd);
scene.attach(m); // ensure in world
m.geometry.computeBoundingBox();
let box = m.geometry.boundingBox.clone();
box.applyMatrix4(m.matrixWorld);
center.add(box.getCenter(new THREE.Vector3()));
count++;
});
center.divideScalar(count);
transformProxy.position.copy(center);
transformProxy.rotation.set(0, 0, 0);
transformProxy.scale.set(1, 1, 1);
transformProxy.attach(model);
selectedModels.forEach(m => {
transformProxy.attach(m);
});
transformControl.attach(transformProxy);
if(transformControl.getMode() === 'scale') {
document.getElementById('scale-panel').classList.remove('d-none');
@@ -459,14 +630,32 @@ function selectModel(model) {
}
}
function removeModel(model) {
if (activeModel === model) {
transformControl.detach();
scene.attach(model);
activeModel = null;
// Keep a backward compatible selectModel definition for single cases
function selectModel(model) {
if (model) {
selectModels([model]);
} else {
selectModels([]);
}
}
function removeModel(model) {
if (selectedModels.includes(model)) {
selectedModels = selectedModels.filter(m => m !== model);
if (selectedModels.length === 0) transformControl.detach();
}
scene.attach(model);
scene.remove(model);
loadedModels = loadedModels.filter(m => m !== model);
activeModel = selectedModels.length > 0 ? selectedModels[0] : null;
}
function removeActiveModel() {
if (selectedModels.length > 0) {
[...selectedModels].forEach(m => removeModel(m));
selectModels([]);
}
}
function clearPlate() {
@@ -476,24 +665,100 @@ function clearPlate() {
scene.remove(m);
});
loadedModels = [];
selectedModels = [];
activeModel = null;
}
function addModelToPlate(btnElement, fileId, url, name, status) {
const iconSpan = btnElement.querySelector('i');
const originalClass = iconSpan.className;
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
btnElement.disabled = true;
let matrixData = btnElement ? btnElement.getAttribute('data-matrix') : null;
if (matrixData) {
try {
let data = JSON.parse(matrixData);
if (data.settings) {
if (data.settings.infill) document.getElementById('infill-density').value = data.settings.infill;
if (data.settings.support) {
let supportSelect = document.getElementById('support-type');
supportSelect.value = data.settings.support;
supportSelect.dispatchEvent(new Event('change'));
}
if (data.settings.support_pattern) {
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) {}
}
if (matrixData && matrixData.includes('"is_composite"')) {
try {
let comp = JSON.parse(matrixData);
if (comp.is_composite && comp.parts) {
if (btnElement) {
const iconSpan = btnElement.querySelector('i');
const originalClass = iconSpan.className;
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
btnElement.disabled = true;
let totalParts = comp.parts.length;
let loadedCount = 0;
comp.parts.forEach(part => {
loadSTL(part.file_id, part.url, part.name, 'uploaded', JSON.stringify(part.raw_matrix), () => {
loadedCount++;
if (loadedCount === totalParts) {
iconSpan.className = originalClass;
btnElement.disabled = false;
}
});
});
} else {
comp.parts.forEach(part => {
loadSTL(part.file_id, part.url, part.name, 'uploaded', JSON.stringify(part.raw_matrix));
});
}
return;
}
} catch (e) {
console.error(e);
}
}
if (btnElement) {
const iconSpan = btnElement.querySelector('i');
const originalClass = iconSpan.className;
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
btnElement.disabled = true;
loadSTL(fileId, url, name, status, matrixData, () => {
iconSpan.className = originalClass;
btnElement.disabled = false;
});
} else {
loadSTL(fileId, url, name, status, matrixData);
}
}
function loadSTL(fileId, url, name, status, matrixData, callback) {
const loader = new THREE.STLLoader();
loader.load(url, function (geometry) {
// By default STLs center or are offset, let's normalize slightly to be on top of the plate
geometry.computeBoundingBox();
const center = geometry.boundingBox.getCenter(new THREE.Vector3());
const minZ = geometry.boundingBox.min.z;
geometry.translate(-center.x, -center.y, -minZ);
const material = new THREE.MeshPhongMaterial({ color: 0x0d6efd, specular: 0x111111, shininess: 200 });
const material = new THREE.MeshPhongMaterial({ color: 0xcccccc, specular: 0x111111, shininess: 200 });
const mesh = new THREE.Mesh(geometry, material);
mesh.userData = {
fileId: fileId,
@@ -502,10 +767,15 @@ function addModelToPlate(btnElement, fileId, url, name, status) {
geomTrans: new THREE.Matrix4().makeTranslation(-center.x, -center.y, -minZ)
};
let matrixData = btnElement.getAttribute('data-matrix');
if (matrixData && matrixData.trim() !== '' && matrixData !== 'None') {
try {
let mArray = JSON.parse(matrixData);
// Skip if it actually is a composite (handled by addModelToPlate)
if (mArray && mArray.is_composite === true) return;
if (mArray && !Array.isArray(mArray) && mArray.matrix) {
mArray = mArray.matrix;
}
let savedMatrix = new THREE.Matrix4().fromArray(mArray);
savedMatrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
} catch (e) {
@@ -514,7 +784,6 @@ function addModelToPlate(btnElement, fileId, url, name, status) {
mesh.position.y = (Math.random() - 0.5) * 50;
}
} else {
// Random slight offset so they don't exactly stack
mesh.position.x = (Math.random() - 0.5) * 50;
mesh.position.y = (Math.random() - 0.5) * 50;
}
@@ -522,14 +791,12 @@ function addModelToPlate(btnElement, fileId, url, name, status) {
scene.add(mesh);
loadedModels.push(mesh);
selectModel(mesh);
iconSpan.className = originalClass;
btnElement.disabled = false;
if (callback) callback();
}, undefined, function (error) {
console.error(error);
iconSpan.className = originalClass;
btnElement.disabled = false;
alert("{{ _('Error loading STL model file.') }}");
if (callback) callback();
window.customAlert("{{ _('Error loading STL model file.') }}");
});
}
@@ -581,43 +848,60 @@ function animate() {
}
function mergeAndSlice() {
selectModels([]); // Detach any active model to bake transformProxy world coordinates into its local matrix properties
if (loadedModels.length === 0) {
alert("{{ _('Please add at least one model to the build plate.') }}");
window.customAlert("{{ _('Please add at least one model to the build plate.') }}");
return;
}
if (checkBounds()) {
alert("{{ _('One or more models are outside the print area. Please adjust them before slicing.') }}");
window.customAlert("{{ _('One or more models are outside the print area. Please adjust them before slicing.') }}");
return;
}
let isEdit = (loadedModels.length === 1 && String(loadedModels[0].userData.fileId) === String(initialAddId));
let isEdit = false;
let targetFileId = null;
if (window.isCompositeEdit) {
isEdit = true;
targetFileId = initialAddId;
} else if (loadedModels.length === 1 && String(loadedModels[0].userData.fileId) === String(initialAddId)) {
isEdit = true;
targetFileId = initialAddId;
}
if (isEdit) {
const singleModel = loadedModels[0];
if (singleModel.userData.status === 'sliced') {
if (!confirm("{{ _('This model has already been sliced. The existing GCode will be overwritten. Continue?') }}")) {
return;
}
// Just checking if we want to warn
if (loadedModels.length === 1 && loadedModels[0].userData.status === 'sliced') {
window.customConfirm("{{ _('This model has already been sliced. The existing GCode will be overwritten. Continue?') }}", doMergeAndSlice);
return;
} else if (window.isCompositeEdit) {
window.customConfirm("{{ _('You are editing a composite model. The existing composite will be updated and re-sliced. Continue?') }}", doMergeAndSlice);
return;
}
}
const pieces = loadedModels.map(m => {
doMergeAndSlice();
function doMergeAndSlice() {
const pieces = loadedModels.map(m => {
m.updateMatrixWorld(true);
const mat = m.matrixWorld.clone();
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,
matrix: mat.elements, // Array of 16 numbers used for slicing
raw_matrix: m.matrix.elements // Local visual properties
raw_matrix: m.matrixWorld.elements // Use world matrix explicitly just in case
};
});
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;
@@ -636,25 +920,30 @@ function mergeAndSlice() {
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ pieces: pieces, quality: quality, infill: infill, support: support, support_pattern: supportPattern, is_edit: isEdit })
body: JSON.stringify({ pieces: pieces, quality: quality, 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 {
alert("{{ _('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") }}';
}
})
.catch(err => {
alert("{{ _('Error:') }} " + String(err));
window.customAlert("{{ _('Error:') }} " + String(err));
btn.disabled = false;
icon.className = 'bi bi-gear-fill me-2';
text.innerText = '{{ _("Merge & Slice") }}';
});
}
}
document.addEventListener('DOMContentLoaded', () => {
@@ -671,9 +960,62 @@ document.addEventListener('DOMContentLoaded', () => {
if (addId) {
const btn = document.getElementById('add-model-btn-' + addId);
if (btn) {
let matrixData = btn.getAttribute('data-matrix');
if (matrixData) {
try {
let d = JSON.parse(matrixData);
if (d && d.is_composite === true) {
window.isCompositeEdit = true;
}
} catch(e) {}
}
btn.click();
}
}
});
</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
View 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

View File

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

View File

@@ -0,0 +1,177 @@
import requests
from urllib.parse import urljoin
class OctoPrintClient:
"""
Client for interacting with the OctoPrint API using Application Keys or standard API Keys.
Designed to be easily extensible.
"""
def __init__(self, base_url, api_key=None):
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.session = requests.Session()
if self.api_key:
self.session.headers.update({"X-Api-Key": self.api_key})
def _request(self, method, endpoint, **kwargs):
"""Internal method to handle API requests and standard parsing."""
url = urljoin(self.base_url, endpoint)
response = self.session.request(method, url, **kwargs)
response.raise_for_status()
# Octoprint often returns 204 No Content for successful commands
if response.status_code == 204:
return True
try:
return response.json()
except ValueError:
return response.text
# -------------------------------------------------------------------------
# Application Key Workflow
# -------------------------------------------------------------------------
def request_app_key(self, app_name="My OctoPrint App"):
"""
Step 1: Start the Application Key authorization flow.
Returns: (app_token, client_token)
You should poll verify_app_key(client_token) until the user allows it in OctoPrint UI.
"""
data = self._request("POST", "/plugin/appkeys/request", json={"app": app_name})
return data.get("app_token"), data.get("client_token")
def verify_app_key(self, client_token):
"""
Step 2: Check if the requested app key is approved.
Returns True if authorized, False if still pending.
Raises an exception if denied or timed out.
"""
url = urljoin(self.base_url, "/plugin/appkeys/probe")
# App Key probe requires passing the client_token as a Bearer token
# Don't use self._request since it uses the established session/api_key
response = requests.get(url, headers={"Authorization": f"Bearer {client_token}"})
if response.status_code == 204:
return True
elif response.status_code == 202:
return False # Pending approval
else:
raise Exception(f"App key request denied or expired (HTTP {response.status_code})")
# -------------------------------------------------------------------------
# Files
# -------------------------------------------------------------------------
def get_files(self, location="local"):
"""
Retrieve all files available on OctoPrint.
location: 'local' (internal storage) or 'sdcard' (SD card on printer)
"""
return self._request("GET", f"/api/files/{location}")
def select_file(self, location, path, print_after_select=False):
"""Select a file, and optionally start printing it immediately."""
payload = {"command": "select", "print": print_after_select}
return self._request("POST", f"/api/files/{location}/{path}", json=payload)
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
# -------------------------------------------------------------------------
def get_printer_status(self):
"""
Get the current printer state (e.g., temperatures, operational state).
Note: If printer is disconnected, this may return an HTTP error.
"""
try:
return self._request("GET", "/api/printer")
except requests.HTTPError as e:
if e.response.status_code == 409:
return {"state": {"text": "Offline/Disconnected"}}
raise
def get_job_info(self):
"""Get information about the current print job and progress."""
return self._request("GET", "/api/job")
def get_printer_err_log(self):
"""Fetch the printer error log, if available."""
return self._request("GET", "/api/printer/error")
# -------------------------------------------------------------------------
# Printer Control
# -------------------------------------------------------------------------
def start_print(self):
"""Start the print job (Requires a file to be selected first)."""
return self._request("POST", "/api/job", json={"command": "start"})
def pause_print(self, action="pause"):
"""
Pause, resume, or toggle the print job.
action: 'pause', 'resume', or 'toggle'
"""
return self._request("POST", "/api/job", json={"command": "pause", "action": action})
def cancel_print(self):
"""Cancel the current print job."""
return self._request("POST", "/api/job", json={"command": "cancel"})
def send_gcode(self, commands):
"""
Send a string or list of G-Code commands directly to the printer.
Example: send_gcode("G28") or send_gcode(["G28", "G29"])
"""
if isinstance(commands, str):
commands = [commands]
return self._request("POST", "/api/printer/command", json={"commands": commands})
def home_axes(self, axes=["x", "y", "z"]):
"""Convenience method to home the printer axes."""
return self._request("POST", "/api/printer/printhead", json={"command": "home", "axes": axes})
def auto_leveling(self):
return self.send_gcode("G29")
# -------------------------------------------------------------------------
# Webcam / Video
# -------------------------------------------------------------------------
def get_webcam_stream_url(self):
"""
Attempts to fetch the configured webcam stream URL from OctoPrint settings.
Provides a fallback if settings are inaccessible.
"""
try:
settings = self._request("GET", "/api/settings")
stream_url = settings.get("webcam", {}).get("streamUrl", "/webcam/?action=stream")
if stream_url.startswith("/"):
return urljoin(self.base_url, stream_url)
return stream_url
except requests.HTTPError:
# Fallback standard URL
return urljoin(self.base_url, "/webcam/?action=stream")
# --- Example Usage / Extensibility test ---
if __name__ == "__main__":
# Example snippet of how to use the client:
OCTOPRINT_URL = "http://octopi.local"
# OCTOPRINT_KEY = "YOUR_APP_KEY_HERE"
# client = OctoPrintClient(OCTOPRINT_URL, OCTOPRINT_KEY)
# print(client.get_printer_status())
pass

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

View 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 []

View 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

107
app/utils/stl_merger.py Normal file
View File

@@ -0,0 +1,107 @@
import struct
import math
import os
def merge_stls(input_files, output_path):
try:
from stl import mesh
import numpy as np
meshes = []
for path, matrix in input_files:
try:
# 重新换回轻量级的 numpy-stl 以防内存溢出 (OOM)
m = mesh.Mesh.from_file(path)
mat = np.array(matrix, dtype=np.float64).reshape((4, 4)).T
vectors = m.vectors.reshape(-1, 3)
hom_vectors = np.hstack((vectors, np.ones((len(vectors), 1), dtype=np.float32)))
transformed = (mat @ hom_vectors.T).T
m.vectors = transformed[:, :3].reshape(-1, 3, 3)
# 检测缩放矩阵是否引发镜像翻转 (行列式为负数)
det = np.linalg.det(mat[:3, :3])
if det < 0:
# 发生镜像反转不仅法线会反向三角形三个顶点的顺逆时针Winding Order也会错乱
# 强行交换每个三角形的顶点2和顶点3以纠正渲染正反面
m.vectors[:, [1, 2]] = m.vectors[:, [2, 1]]
m.update_normals()
meshes.append(m)
except Exception as e:
print(f"Error processing path {path} with stl mesh: {e}")
if not meshes:
return
if len(meshes) == 1:
meshes[0].save(output_path)
return
merged_data = np.concatenate([m.data for m in meshes])
merged_mesh = mesh.Mesh(merged_data)
merged_mesh.save(output_path)
return
except Exception as e:
print(f"Mesh fast-merge failed: {e}. Falling back to struct parsing.")
# Extreme fallback just in case no stl libraries work
total_faces = 0
meshes_data = []
for path, matrix in input_files:
with open(path, 'rb') as f:
f.read(80)
faces = struct.unpack('<I', f.read(4))[0]
data = f.read(faces * 50)
def apply_m(_x, _y, _z):
w = _x * matrix[3] + _y * matrix[7] + _z * matrix[11] + matrix[15]
nx = (_x * matrix[0] + _y * matrix[4] + _z * matrix[8] + matrix[12]) / w
ny = (_x * matrix[1] + _y * matrix[5] + _z * matrix[9] + matrix[13]) / w
nz = (_x * matrix[2] + _y * matrix[6] + _z * matrix[10] + matrix[14]) / w
return nx, ny, nz
new_data = bytearray(faces * 50)
src_offset = 0
dst_offset = 0
for _ in range(faces):
n_x, n_y, n_z, v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z, attr = struct.unpack_from('<12fH', data, src_offset)
nv1x, nv1y, nv1z = apply_m(v1x, v1y, v1z)
nv2x, nv2y, nv2z = apply_m(v2x, v2y, v2z)
nv3x, nv3y, nv3z = apply_m(v3x, v3y, v3z)
# Recalculate normal properly using cross product to fix flipped/sheared surfaces
Ux = nv2x - nv1x
Uy = nv2y - nv1y
Uz = nv2z - nv1z
Vx = nv3x - nv1x
Vy = nv3y - nv1y
Vz = nv3z - nv1z
nnx = Uy * Vz - Uz * Vy
nny = Uz * Vx - Ux * Vz
nnz = Ux * Vy - Uy * Vx
l = math.sqrt(nnx**2 + nny**2 + nnz**2)
if l > 1e-8:
nnx, nny, nnz = nnx/l, nny/l, nnz/l
else:
nnx, nny, nnz = 0.0, 0.0, 0.0
struct.pack_into('<12fH', new_data, dst_offset, nnx, nny, nnz, nv1x, nv1y, nv1z, nv2x, nv2y, nv2z, nv3x, nv3y, nv3z, attr)
src_offset += 50
dst_offset += 50
meshes_data.append(new_data)
total_faces += faces
with open(output_path, 'wb') as f:
f.write(b'\0' * 80)
f.write(struct.pack('<I', total_faces))
for d in meshes_data:
f.write(d)

View File

@@ -5,6 +5,70 @@ import sys
import os
def simplify_stl(input_path, output_path, keep_ratio=0.1):
try:
# Try using professional pymeshlab first
import pymeshlab
ms = pymeshlab.MeshSet()
ms.load_new_mesh(input_path)
target_faces = int(ms.current_mesh().face_number() * keep_ratio)
# Optimize using quadric edge collapse to preserve 95% visual effect
try:
ms.apply_filter('meshing_decimation_quadric_edge_collapse',
targetfacenum=target_faces,
preserveboundary=True,
preservenormal=True,
preservetopology=True)
except AttributeError:
ms.meshing_decimation_quadric_edge_collapse(
targetfacenum=target_faces,
preserveboundary=True,
preservenormal=True,
preservetopology=True
)
ms.save_current_mesh(output_path)
return True
except ImportError:
pass
except Exception as e:
print(f"Pymeshlab simplification failed: {e}. Falling back to Open3D...")
try:
# Try using open3d as second fallback
import open3d as o3d
o3d_mesh = o3d.io.read_triangle_mesh(input_path)
if len(o3d_mesh.triangles) > 0:
target_faces = max(1, int(len(o3d_mesh.triangles) * keep_ratio))
smp_mesh = o3d_mesh.simplify_quadric_decimation(target_number_of_triangles=target_faces)
smp_mesh.compute_triangle_normals()
o3d.io.write_triangle_mesh(output_path, smp_mesh)
return True
except ImportError:
pass
except Exception as e:
print(f"Open3D simplification failed: {e}. Falling back to PyFQMR...")
try:
# Try using pyfqmr as third fallback
import pyfqmr
import trimesh
mesh_data = trimesh.load(input_path, file_type='stl')
target_faces = max(1, int(len(mesh_data.faces) * keep_ratio))
simplifier = pyfqmr.Simplify()
simplifier.setMesh(mesh_data.vertices, mesh_data.faces)
simplifier.simplify_mesh(target_count=target_faces, aggressiveness=7, preserve_border=True, verbose=False)
mesh_parts = simplifier.getMesh()
smp_mesh = trimesh.Trimesh(vertices=mesh_parts[0], faces=mesh_parts[1], process=False)
smp_mesh.export(output_path, file_type='stl')
return True
except ImportError:
pass
except Exception as e:
print(f"PyFQMR simplification failed: {e}. Falling back to custom algorithm...")
try:
try:
import trimesh

165
app/utils/tasks.py Normal file
View File

@@ -0,0 +1,165 @@
import subprocess
import os
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
# Ensure instance directory exists
instance_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'instance')
os.makedirs(instance_dir, exist_ok=True)
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, 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
app = create_app()
with app.app_context():
print_file = PrintFile.query.get(file_id)
if not print_file:
return
# Cache variables and commit slicing status
gcode_filename = print_file.filename.rsplit('.', 1)[0] + '.gcode'
gcode_filepath = os.path.join(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()
try:
# 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()
slicer = get_slicer_engine(engine_name,app.config['PRINT_CONFIG_FOLDER'])
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 success:
print_file.status = 'sliced'
else:
print_file.status = 'failed'
app.logger.error(f"Slicing Task Failed: {err_msg}")
except Exception as e:
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}")
db.session.commit()
db.session.remove()
@huey.task()
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():
print_file = PrintFile.query.get(file_id)
if not print_file:
return
db.session.remove()
try:
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, 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:
print_file.status = 'failed'
db.session.commit()
app.logger.error(f"Merge Exception: {e}")
finally:
db.session.remove()
@huey.task()
def simplify_stl_task(file_id, filepath):
app = create_app()
with app.app_context():
print_file = PrintFile.query.get(file_id)
if not print_file:
return
try:
file_size_mb = os.path.getsize(filepath) / (1024 * 1024)
configs = {c.key: c.value for c in SystemConfig.query.all()}
skip_size = float(configs.get('proxy_skip_size_mb', '5.0'))
proxy_path = filepath + '.proxy.stl'
if file_size_mb <= skip_size:
# File is small enough, no proxy needed
print_file.status = 'uploaded'
db.session.commit()
return
# Aim for approx 7.5 MB for the proxy
target_mb = 7.5
keep_ratio = target_mb / file_size_mb
if keep_ratio > 1.0:
keep_ratio = 1.0
elif keep_ratio < 0.01:
keep_ratio = 0.01
app.logger.info(f"Simplifying {filepath}... Size: {file_size_mb:.2f}MB, Target Ratio: {keep_ratio:.3f}")
simplify_stl(filepath, proxy_path, keep_ratio=keep_ratio)
except Exception as e:
app.logger.error(f"Simplify task error: {e}")
# Update status to uploaded regardless of success or failure of proxy generation
# So the user can still slice or download it
print_file = PrintFile.query.get(file_id)
if print_file:
print_file.status = 'uploaded'
db.session.commit()
db.session.remove()

View File

@@ -1,113 +0,0 @@
{
"Language": "Language",
"English": "English",
"Chinese": "中文",
"Guest": "Guest",
"Login": "Login",
"Logout": "Logout",
"Home": "Home",
"New Slice": "New Slice",
"My Files": "My Files",
"Admin Options": "Admin Options",
"System Settings": "System Settings",
"User Management": "User Management",
"Dashboard": "Dashboard",
"Total Prints": "Total Prints",
"You have sliced": "You have sliced",
"files": "files.",
"Upload & Slice STL": "Upload & Slice STL",
"Select STL File": "Select STL File",
"Quality Profile": "Quality Profile",
"Upload & Slice": "Upload & Slice",
"3D Preview Area": "3D Preview Area",
"Upload a file to display": "Upload a file to display",
"Date Uploaded": "Date Uploaded",
"Original Name": "Original Name",
"Status": "Status",
"Actions": "Actions",
"Uploaded": "Uploaded",
"Waiting": "Waiting",
"Other Settings": "Other Settings",
"Infill Density": "Infill Density",
"Support": "Support",
"None": "None",
"Touching Buildplate": "Touching Buildplate",
"Everywhere": "Everywhere",
"Merging": "Merging",
"Waiting in queue for slicing": "Waiting in queue for slicing",
"Slicing": "Slicing",
"Sliced": "Sliced",
"Failed": "Failed",
"Download GCode": "Download GCode",
"GCode Preview": "GCode Preview",
"Delete": "Delete",
"No files uploaded yet.": "No files uploaded yet.",
"Drag & Drop STL file here or Click to Select": "Drag & Drop STL file here or Click to Select",
"Uploading...": "Uploading...",
"Slicing queued!": "Slicing queued!",
"Draft Quality": "Draft Quality",
"Standard Quality": "Standard Quality",
"High Quality": "High Quality",
"Dynamic Quality": "Dynamic Quality",
"Low Quality": "Low Quality",
"Super Quality": "Super Quality",
"Ultra Quality": "Ultra Quality",
"Plater": "Plater",
"Layer Progress:": "Layer Progress:",
"Loading and Parsing GCode Data...": "Loading and Parsing GCode Data...",
"Failed to load GCode preview.": "Failed to load GCode preview.",
"Outer Wall": "Outer Wall",
"Inner Wall": "Inner Wall",
"Infill": "Infill",
"Skin/TopBottom": "Skin/TopBottom",
"Travel (Move)": "Travel (Move)",
"Skirt": "Skirt",
"Support Interface": "Support Interface",
"Back": "Back",
"Layer": "Layer",
"Plater / Build Plate": "Plater / Build Plate",
"Translate (W)": "Translate (W)",
"Rotate (E)": "Rotate (E)",
"Scale (R)": "Scale (R)",
"Scale": "Scale",
"Uniform Scale": "Uniform Scale",
"Lay Flat": "Lay Flat",
"Remove Selected (Del)": "Remove Selected (Del)",
"Available Models": "Available Models",
"No STL models uploaded yet. Go upload some first.": "No STL models uploaded yet. Go upload some first.",
"Support Type": "Support Type",
"Tree": "Tree",
"Lines": "Lines",
"Grid": "Grid",
"Triangles": "Triangles",
"Concentric": "Concentric",
"Zig Zag": "Zig Zag",
"Cross": "Cross",
"Gyroid": "Gyroid",
"Honeycomb": "Honeycomb",
"Octagon": "Octagon",
"Clear Board": "Clear Board",
"Merge & Slice": "Merge & Slice",
"Error loading STL model file.": "Error loading STL model file.",
"Please add at least one model to the build plate.": "Please add at least one model to the build plate.",
"One or more models are outside the print area. Please adjust them before slicing.": "One or more models are outside the print area. Please adjust them before slicing.",
"Error:": "Error:",
"ID": "ID",
"Username": "Username",
"Role": "Role",
"Created At": "Created At",
"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",
"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)",
"Adjust the Y-axis compilation offset for combined files on the build plate.": "Adjust the Y-axis compilation offset for combined files on the build plate.",
"Default Plater Settings": "Default Plater Settings",
"Default Infill Density (%)": "Default Infill Density (%)",
"Default Support": "Default Support",
"Default Support Type": "Default Support Type",
"Default Quality Profile": "Default Quality Profile",
"Save Settings": "Save Settings"
}

View File

@@ -1,119 +0,0 @@
{
"Language": "语言",
"English": "English",
"Chinese": "中文",
"Guest": "访客",
"Login": "登录",
"Logout": "退出",
"Home": "主页",
"New Slice": "新建切片",
"My Files": "我的文件",
"Admin Options": "管理员选项",
"System Settings": "系统设置",
"User Management": "用户管理",
"Dashboard": "仪表盘",
"Total Prints": "总打印数",
"You have sliced": "您已切片",
"files": "个文件。",
"Upload & Slice STL": "上传并切片 STL",
"Select STL File": "选择 STL 文件",
"Quality Profile": "质量配置",
"Upload & Slice": "上传 & 切片",
"3D Preview Area": "3D预览区",
"Upload a file to display": "上传文件以显示",
"Date Uploaded": "上传日期",
"Original Name": "原始名称",
"Status": "状态",
"Actions": "操作",
"Waiting": "等待中",
"Merging": "合并中",
"Waiting in queue for slicing": "在队列中排队等待切片",
"Slicing": "切片中",
"Sliced": "已切片",
"Uploaded": "已上传",
"Failed": "失败",
"This model has already been sliced. The existing GCode will be overwritten. Continue?": "该模型已经生成过切片重新切片会覆盖原有GCode文件是否继续",
"Upload STL": "上传STL",
"Download GCode": "下载 GCode",
"GCode Preview": "GCode 预览",
"Delete": "删除",
"No files uploaded yet.": "还没有上传文件。",
"Drag & Drop STL file here or Click to Select": "将 STL 文件拖放到此处或点击选择",
"Uploading...": "上传中...",
"Upload Complete!": "上传完成!",
"Upload error.": "上传出错。",
"Upload failed.": "上传失败。",
"Please upload a valid .stl file!": "请上传有效的 .stl 文件!",
"Slicing queued!": "切片已排队!",
"Draft Quality": "草稿质量",
"Standard Quality": "标准质量",
"High Quality": "高质量",
"Dynamic Quality": "动态质量",
"Low Quality": "低质量",
"Super Quality": "超高质量",
"Ultra Quality": "极高质量",
"Plater": "构建板",
"Layer Progress:": "单层打印进度:",
"Loading and Parsing GCode Data...": "正在加载和解析 GCode 数据...",
"Failed to load GCode preview.": "加载 GCode 预览失败。",
"Outer Wall": "外墙",
"Inner Wall": "内墙",
"Infill": "填充",
"Skin/TopBottom": "顶层/底层",
"Travel (Move)": "空驶",
"Skirt": " 裙边",
"Support Interface": "支撑界面",
"Back": "返回",
"Layer": "层数",
"Plater / Build Plate": "构建板",
"Translate (W)": "平移 (W)",
"Rotate (E)": "旋转 (E)",
"Scale (R)": "缩放 (R)",
"Scale": "缩放",
"Uniform Scale": "均匀缩放",
"Lay Flat": "平放",
"Remove Selected (Del)": "删除选中 (Del)",
"Available Models": "可用模型",
"No STL models uploaded yet. Go upload some first.": "还没有上传 STL 模型。请先上传。",
"Other Settings": "其它设置",
"Infill Density": "填充密度",
"Support": "支撑",
"None": "无",
"Touching Buildplate": "仅接触构建板",
"Everywhere": "无处不在",
"Support Type": "支撑类型",
"Tree": "树状",
"Lines": "直线",
"Grid": "网格",
"Triangles": "三角形",
"Concentric": "同心",
"Zig Zag": "之字形",
"Cross": "交叉",
"Gyroid": "螺旋",
"Honeycomb": "蜂窝",
"Octagon": "八边形",
"Clear Board": "清空画板",
"Merge & Slice": "合并并切片",
"Error loading STL model file.": "加载 STL 模型文件出错。",
"Please add at least one model to the build plate.": "请在构建板上至少放置一个模型。",
"One or more models are outside the print area. Please adjust them before slicing.": "有一个或多个模型超出了打印范围。切片前请调整它们的位置。",
"Error:": "错误:",
"ID": "ID",
"Username": "用户名",
"Role": "角色",
"Created At": "创建时间",
"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 配置",
"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)",
"Adjust the Y-axis compilation offset for combined files on the build plate.": "调整多文件在构建板合并切片时的Y坐标偏移。",
"Default Plater Settings": "默认构建板设置",
"Default Infill Density (%)": "默认填充密度 (%)",
"Default Support": "默认支撑类型",
"Default Support Type": "默认支撑图案",
"Default Quality Profile": "默认质量配置",
"Save Settings": "保存设置"
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
} )();

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More