整理文件夹及架构,加入打印机页面,octo反代有问题
This commit is contained in:
@@ -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,9 +50,9 @@ 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['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'))
|
||||
|
||||
@@ -74,8 +74,10 @@ def create_app():
|
||||
from . import models
|
||||
db.create_all()
|
||||
|
||||
from .routes import main_bp, auth_bp, admin_bp
|
||||
from .printer_routes import printer_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
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
23
app/assets/THIRDPARTY_LICENSES.md
Normal file
23
app/assets/THIRDPARTY_LICENSES.md
Normal 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.
|
||||
15
app/assets/css/README-LICENSE.txt
Normal file
15
app/assets/css/README-LICENSE.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
Notice regarding the CSS styles and Fonts included in this directory:
|
||||
|
||||
This project as a whole is licensed under the GNU General Public License v3.0 (GPLv3).
|
||||
However, the included CSS and Font dependencies are distributed under their own permissive licenses (which are fully compatible with GPLv3).
|
||||
|
||||
1. Bootstrap (bootstrap.min.css)
|
||||
- Copyright (c) 2011-2023 The Bootstrap Authors
|
||||
- Licensed under the MIT License
|
||||
- https://github.com/twbs/bootstrap/blob/main/LICENSE
|
||||
|
||||
2. Bootstrap Icons (bootstrap-icons.css, fonts/bootstrap-icons.woff, etc.)
|
||||
- Copyright (c) 2019-2023 The Bootstrap Authors
|
||||
- Font files are licensed under the SIL Open Font License (OFL) v1.1.
|
||||
- The CSS generated code is licensed under the MIT License.
|
||||
- https://github.com/twbs/icons/blob/main/LICENSE
|
||||
1981
app/assets/css/bootstrap-icons.css
vendored
Normal file
1981
app/assets/css/bootstrap-icons.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
6
app/assets/css/bootstrap.min.css
vendored
Normal file
6
app/assets/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/css/bootstrap.min.css.map
Normal file
1
app/assets/css/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
BIN
app/assets/css/fonts/bootstrap-icons.woff
Normal file
BIN
app/assets/css/fonts/bootstrap-icons.woff
Normal file
Binary file not shown.
BIN
app/assets/css/fonts/bootstrap-icons.woff.1
Normal file
BIN
app/assets/css/fonts/bootstrap-icons.woff.1
Normal file
Binary file not shown.
BIN
app/assets/css/fonts/bootstrap-icons.woff2
Normal file
BIN
app/assets/css/fonts/bootstrap-icons.woff2
Normal file
Binary file not shown.
BIN
app/assets/css/fonts/bootstrap-icons.woff2.1
Normal file
BIN
app/assets/css/fonts/bootstrap-icons.woff2.1
Normal file
Binary file not shown.
117
app/assets/i18n/en.json
Normal file
117
app/assets/i18n/en.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"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?",
|
||||
"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"
|
||||
}
|
||||
123
app/assets/i18n/zh-cn.json
Normal file
123
app/assets/i18n/zh-cn.json
Normal file
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"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上传的所有文件和切片吗?",
|
||||
"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": "保存设置"
|
||||
}
|
||||
1045
app/assets/js/OrbitControls.js
Normal file
1045
app/assets/js/OrbitControls.js
Normal file
File diff suppressed because it is too large
Load Diff
1045
app/assets/js/OrbitControls.js.1
Normal file
1045
app/assets/js/OrbitControls.js.1
Normal file
File diff suppressed because it is too large
Load Diff
15
app/assets/js/README-LICENSE.txt
Normal file
15
app/assets/js/README-LICENSE.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
Notice regarding the JavaScript libraries included in this directory:
|
||||
|
||||
This project as a whole is licensed under the GNU General Public License v3.0 (GPLv3).
|
||||
|
||||
However, the included JavaScript dependencies are distributed under their own permissive licenses (which are fully compatible with GPLv3).
|
||||
|
||||
1. Bootstrap (bootstrap.bundle.min.js)
|
||||
- Copyright (c) 2011-2023 The Bootstrap Authors
|
||||
- Licensed under the MIT License
|
||||
- https://github.com/twbs/bootstrap/blob/main/LICENSE
|
||||
|
||||
2. Three.js (three.min.js, OrbitControls.js, STLLoader.js)
|
||||
- Copyright © 2010-2023 three.js authors
|
||||
- Licensed under the MIT License
|
||||
- https://github.com/mrdoob/three.js/blob/dev/LICENSE
|
||||
371
app/assets/js/STLLoader.js
Normal file
371
app/assets/js/STLLoader.js
Normal file
@@ -0,0 +1,371 @@
|
||||
( 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;
|
||||
|
||||
} )();
|
||||
371
app/assets/js/STLLoader.js.1
Normal file
371
app/assets/js/STLLoader.js.1
Normal file
@@ -0,0 +1,371 @@
|
||||
( 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;
|
||||
|
||||
} )();
|
||||
1471
app/assets/js/TransformControls.js
Normal file
1471
app/assets/js/TransformControls.js
Normal file
File diff suppressed because it is too large
Load Diff
7
app/assets/js/bootstrap.bundle.min.js
vendored
Normal file
7
app/assets/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/js/gcode-preview.min.js
vendored
Normal file
1
app/assets/js/gcode-preview.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
app/assets/js/three.min.js
vendored
Normal file
6
app/assets/js/three.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/js/three.min.js.1
Normal file
1
app/assets/js/three.min.js.1
Normal file
File diff suppressed because one or more lines are too long
131
app/routes/admin_routes.py
Normal file
131
app/routes/admin_routes.py
Normal file
@@ -0,0 +1,131 @@
|
||||
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 app.models import db, User, PrintFile, SystemConfig
|
||||
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
|
||||
from app import i18n_dict
|
||||
# import trimesh.repair
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
# 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')
|
||||
|
||||
# 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)
|
||||
]
|
||||
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()}
|
||||
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 []
|
||||
|
||||
46
app/routes/auth_routes.py
Normal file
46
app/routes/auth_routes.py
Normal file
@@ -0,0 +1,46 @@
|
||||
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 app.models import db, User, PrintFile, SystemConfig
|
||||
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
|
||||
from app import i18n_dict
|
||||
# import trimesh.repair
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
|
||||
|
||||
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()
|
||||
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('auth/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 ---
|
||||
|
||||
@@ -8,11 +8,11 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
from .models import db, User, PrintFile, SystemConfig
|
||||
from .tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
|
||||
from app.models import db, User, PrintFile, SystemConfig
|
||||
from app.utils.tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
|
||||
from app import i18n_dict
|
||||
# import trimesh.repair
|
||||
from stl_simplifier import simplify_stl
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
@@ -49,7 +49,7 @@ def set_guest_cookie(response):
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
return render_template('slice/index.html')
|
||||
|
||||
@main_bp.route('/set_language/<lang>')
|
||||
def set_language(lang):
|
||||
@@ -66,72 +66,55 @@ def set_language(lang):
|
||||
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)
|
||||
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
|
||||
|
||||
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'):
|
||||
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)
|
||||
|
||||
# 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='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')
|
||||
|
||||
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)
|
||||
|
||||
flash('File uploaded successfully!', 'success')
|
||||
return redirect(url_for('main.files'))
|
||||
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)
|
||||
return render_template('slice/files.html', files=user_files)
|
||||
|
||||
@main_bp.route('/api/files_status')
|
||||
@login_required
|
||||
@@ -182,7 +165,7 @@ def preview_gcode(file_id):
|
||||
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)
|
||||
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)
|
||||
|
||||
@main_bp.route('/delete_file/<int:file_id>', methods=['POST'])
|
||||
@login_required
|
||||
@@ -210,106 +193,6 @@ def delete_file(file_id):
|
||||
|
||||
# --- 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')
|
||||
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')
|
||||
|
||||
# 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)
|
||||
]
|
||||
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')
|
||||
@@ -354,7 +237,7 @@ def plater():
|
||||
|
||||
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)
|
||||
return render_template('slice/plater.html', w=w, h=h, hd=hd, presets=presets, last_quality=default_quality, models=models, offset_x=offset_x, offset_y=offset_y, default_infill=default_infill, default_support=default_support, default_support_pattern=default_support_pattern)
|
||||
|
||||
@main_bp.route('/file/<int:file_id>')
|
||||
@login_required
|
||||
@@ -1,8 +1,12 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for, current_app, Response
|
||||
from flask_login import login_required, current_user
|
||||
from websockets.sync.client import connect as ws_connect
|
||||
import websockets.exceptions
|
||||
import threading
|
||||
import requests
|
||||
from .models import SystemConfig, db
|
||||
from .octoprint_client import OctoPrintClient
|
||||
from urllib.parse import urlparse
|
||||
from app.models import SystemConfig, db
|
||||
from app.utils.octoprint_client import OctoPrintClient
|
||||
|
||||
printer_bp = Blueprint('printer', __name__, url_prefix='/printer')
|
||||
|
||||
@@ -118,6 +122,8 @@ def octo_config():
|
||||
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'))
|
||||
|
||||
@@ -135,6 +141,8 @@ def octo_embed():
|
||||
embed_url = url_for('printer.octo_proxy') if url and url.value else None
|
||||
return render_template('printer/octo_embed.html', embed_url=embed_url)
|
||||
|
||||
@printer_bp.route('/proxy', defaults={'path': ''}, websocket=True)
|
||||
@printer_bp.route('/proxy/<path:path>', websocket=True)
|
||||
@printer_bp.route('/proxy', defaults={'path': ''}, methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
|
||||
@printer_bp.route('/proxy/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
|
||||
@login_required
|
||||
@@ -146,29 +154,153 @@ def octo_proxy(path):
|
||||
if not url_config or not url_config.value:
|
||||
return "OctoPrint URL not configured", 404
|
||||
|
||||
from urllib.parse import urlparse
|
||||
base_url = url_config.value.rstrip('/')
|
||||
|
||||
# print("----- REQUEST HEADERS -----")
|
||||
# for k, v in request.headers:
|
||||
# print(f"{k}: {v}")
|
||||
# print("----- END HEADERS -----")
|
||||
|
||||
# --- WebSocket Proxy Logic ---
|
||||
if request.headers.get('Upgrade', '').lower() == 'websocket':
|
||||
from flask_sock import Server, ConnectionClosed
|
||||
|
||||
# Check if environment supports WebSockets
|
||||
try:
|
||||
ws = Server(request.environ)
|
||||
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:
|
||||
import traceback
|
||||
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') == 'werkzeug':
|
||||
return super().__call__(*args, **kwargs)
|
||||
return []
|
||||
|
||||
return WebSocketResponse()
|
||||
|
||||
# --- Standard HTTP Proxy Logic ---
|
||||
# from urllib.parse import urlparse
|
||||
target_url = f"{base_url}/{path}"
|
||||
|
||||
if request.query_string:
|
||||
target_url = f"{target_url}?{request.query_string.decode('utf-8')}"
|
||||
|
||||
# Build headers for reverse proxy, masking origin/referer to avoid CSRF
|
||||
# Build headers for reverse proxy based on nginx config reference
|
||||
parsed_base = urlparse(base_url)
|
||||
headers = {k: v for k, v in request.headers if k.lower() not in ['host', 'content-length']}
|
||||
headers['Host'] = parsed_base.netloc
|
||||
|
||||
if 'Origin' in headers:
|
||||
headers['Origin'] = base_url
|
||||
if 'Referer' in headers:
|
||||
headers['Referer'] = f"{base_url}/{path}"
|
||||
|
||||
headers['X-Forwarded-For'] = request.remote_addr
|
||||
# NGINX equivalent proxy headers
|
||||
headers['Host'] = request.host
|
||||
headers['X-Real-IP'] = request.remote_addr
|
||||
headers['X-Real-Port'] = str(request.environ.get('REMOTE_PORT', ''))
|
||||
|
||||
forwarded_for = request.headers.get('X-Forwarded-For', '')
|
||||
headers['X-Forwarded-For'] = f"{forwarded_for}, {request.remote_addr}" if forwarded_for else request.remote_addr
|
||||
|
||||
headers['X-Forwarded-Protocol'] = request.scheme
|
||||
headers['X-Script-Name'] = "/printer/proxy"
|
||||
headers['X-Forwarded-Host'] = request.host
|
||||
headers['X-Forwarded-Proto'] = request.scheme
|
||||
headers['X-Script-Name'] = '/printer/proxy'
|
||||
headers['X-Forwarded-Port'] = str(request.environ.get('SERVER_PORT', '80'))
|
||||
headers['REMOTE-HOST'] = request.remote_addr
|
||||
|
||||
if request.headers.get('Upgrade'):
|
||||
headers['Upgrade'] = request.headers.get('Upgrade')
|
||||
if request.headers.get('Connection'):
|
||||
headers['Connection'] = request.headers.get('Connection')
|
||||
|
||||
try:
|
||||
# proxy_connect_timeout 60s, proxy_read_timeout 600s
|
||||
resp = requests.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
@@ -176,7 +308,8 @@ def octo_proxy(path):
|
||||
data=request.get_data(),
|
||||
cookies=request.cookies,
|
||||
allow_redirects=False,
|
||||
stream=True
|
||||
stream=True,
|
||||
timeout=(60, 600)
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return f"Proxy connection error: {str(e)}", 502
|
||||
@@ -191,3 +324,4 @@ def octo_proxy(path):
|
||||
yield chunk
|
||||
|
||||
return Response(generate(), resp.status_code, response_headers)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="card-body">
|
||||
<h5>{{ _('CuraEngine Configurations') }}</h5>
|
||||
<hr>
|
||||
<form method="POST" action="{{ url_for('admin.settings') }}">
|
||||
<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') }}">
|
||||
@@ -70,8 +70,44 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">{{ _('Save Settings') }}</button>
|
||||
<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;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -32,7 +32,7 @@
|
||||
</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?') }}');">
|
||||
<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>
|
||||
@@ -21,8 +21,18 @@
|
||||
.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); opacity: 1 !important; pointer-events: auto; }
|
||||
|
||||
/* 页面切换动画 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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -150,12 +160,12 @@
|
||||
<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;">
|
||||
<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 }}" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<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 }}
|
||||
@@ -173,6 +183,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Custom Alert Modal -->
|
||||
<div class="modal fade" id="globalAlertModal" tabindex="-1" aria-hidden="true" style="z-index: 1060;">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<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
|
||||
@@ -182,6 +227,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>
|
||||
|
||||
@@ -52,10 +52,13 @@
|
||||
|
||||
<script>
|
||||
function sendCommand(cmdName) {
|
||||
if ((cmdName === 'cancel' || cmdName === 'home') && !confirm("Are you sure you want to perform this action?")) {
|
||||
return;
|
||||
if (cmdName === 'cancel' || cmdName === 'home') {
|
||||
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' },
|
||||
@@ -64,24 +67,15 @@ function sendCommand(cmdName) {
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
flashMessage("success", "Command " + cmdName + " sent.");
|
||||
window.showToast("{{ _('Command') }} " + cmdName + " {{ _('sent.') }}", "success");
|
||||
} else {
|
||||
flashMessage("danger", "Control failed: " + data.error);
|
||||
window.customAlert("{{ _('Control failed: ') }}" + data.error);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
flashMessage("danger", "Network Error: " + err);
|
||||
window.customAlert("{{ _('Network Error: ') }}" + err);
|
||||
});
|
||||
}
|
||||
function flashMessage(type, text) {
|
||||
const container = document.querySelector('.toast-container');
|
||||
if(!container) return alert(text);
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center border-0 bg-${type} text-white show`;
|
||||
toast.innerHTML = `<div class="d-flex"><div class="toast-body fw-medium">${text}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -10,7 +10,7 @@
|
||||
<i class="bi bi-link-45deg me-1"></i>{{ _('Connection Settings') }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('printer.octo_config') }}">
|
||||
<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">
|
||||
@@ -34,9 +34,45 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-primary px-4 rounded-pill shadow-sm"><i class="bi bi-save2 me-2"></i>{{ _('Save Connection Settings') }}</button>
|
||||
<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 %}
|
||||
@@ -39,23 +39,25 @@
|
||||
|
||||
<script>
|
||||
function printFile(origin, path) {
|
||||
if(!confirm("{{ _('Send this file to print immediately?') }}\n\n" + path)) return;
|
||||
|
||||
fetch('{{ url_for("printer.api_print_file") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ origin: origin, path: path })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
alert("{{ _('Print starting! Going to dashboard...') }}");
|
||||
window.location.href = "{{ url_for('printer.status') }}";
|
||||
} else {
|
||||
alert("Error: " + data.error);
|
||||
}
|
||||
})
|
||||
.catch(err => alert("Error: " + err));
|
||||
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));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@@ -84,8 +84,13 @@
|
||||
|
||||
<script>
|
||||
function sendCmd(cmd) {
|
||||
if(cmd === 'cancel' && !confirm("{{ _('Are you sure you want to cancel the print?') }}")) return;
|
||||
|
||||
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' },
|
||||
@@ -96,7 +101,7 @@ function sendCmd(cmd) {
|
||||
if(data.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert("Error: " + data.error);
|
||||
window.customAlert("Error: " + data.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
@@ -69,7 +69,7 @@
|
||||
<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>
|
||||
@@ -147,7 +147,7 @@ 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;
|
||||
@@ -199,25 +199,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%';
|
||||
@@ -244,7 +251,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;
|
||||
@@ -252,14 +259,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');
|
||||
};
|
||||
@@ -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>
|
||||
@@ -110,12 +112,12 @@
|
||||
</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">
|
||||
<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 show">
|
||||
<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">
|
||||
@@ -124,16 +126,16 @@
|
||||
{% 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>
|
||||
</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="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>
|
||||
@@ -764,7 +766,7 @@ function loadSTL(fileId, url, name, status, matrixData, callback) {
|
||||
}, undefined, function (error) {
|
||||
console.error(error);
|
||||
if (callback) callback();
|
||||
alert("{{ _('Error loading STL model file.') }}");
|
||||
window.customAlert("{{ _('Error loading STL model file.') }}");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -819,12 +821,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -842,17 +844,18 @@ function mergeAndSlice() {
|
||||
if (isEdit) {
|
||||
// Just checking if we want to warn
|
||||
if (loadedModels.length === 1 && loadedModels[0].userData.status === 'sliced') {
|
||||
if (!confirm("{{ _('This model has already been sliced. The existing GCode will be overwritten. Continue?') }}")) {
|
||||
return;
|
||||
}
|
||||
window.customConfirm("{{ _('This model has already been sliced. The existing GCode will be overwritten. Continue?') }}", doMergeAndSlice);
|
||||
return;
|
||||
} else if (window.isCompositeEdit) {
|
||||
if (!confirm("{{ _('You are editing a composite model. The existing composite will be updated and re-sliced. Continue?') }}")) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
@@ -893,18 +896,19 @@ function mergeAndSlice() {
|
||||
if(data.success) {
|
||||
window.location.href = "{{ url_for('main.files') }}";
|
||||
} else {
|
||||
alert("{{ _('Error:') }} " + data.error);
|
||||
window.customAlert("{{ _('Error:') }} " + data.error);
|
||||
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', () => {
|
||||
107
app/utils/stl_merger.py
Normal file
107
app/utils/stl_merger.py
Normal 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)
|
||||
|
||||
170
app/utils/stl_simplifier.py
Normal file
170
app/utils/stl_simplifier.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import numpy as np
|
||||
from stl import mesh
|
||||
import struct
|
||||
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
|
||||
mesh_data = trimesh.load(input_path, file_type='stl')
|
||||
if hasattr(mesh_data, 'triangles'):
|
||||
vertices = mesh_data.triangles.reshape(-1, 3)
|
||||
else:
|
||||
vertices = mesh_data.vertices[mesh_data.faces].reshape(-1, 3)
|
||||
use_trimesh = True
|
||||
except ImportError:
|
||||
# Load mesh using numpy-stl fallback
|
||||
m = mesh.Mesh.from_file(input_path)
|
||||
vertices = m.vectors.reshape(-1, 3)
|
||||
use_trimesh = False
|
||||
|
||||
min_v = vertices.min(axis=0)
|
||||
max_v = vertices.max(axis=0)
|
||||
bbox_size = max_v - min_v
|
||||
max_dim = np.max(bbox_size)
|
||||
|
||||
if max_dim == 0:
|
||||
if use_trimesh:
|
||||
mesh_data.export(output_path, file_type='stl')
|
||||
else:
|
||||
m.save(output_path)
|
||||
return True
|
||||
|
||||
# Target roughly a resolution that gives us keep_ratio faces.
|
||||
# This is a heuristic approach to grid-based vertex clustering.
|
||||
# Function to simplify given a grid size
|
||||
def do_simplify(g_size):
|
||||
v_idx = np.round((vertices - min_v) / g_size).astype(np.int64)
|
||||
|
||||
# Fast 1D hash to avoid extremely slow np.unique(axis=0) on 2D arrays
|
||||
max_idx = v_idx.max(axis=0) + 1
|
||||
v_1d = v_idx[:, 0] + v_idx[:, 1] * max_idx[0] + v_idx[:, 2] * max_idx[0] * max_idx[1]
|
||||
|
||||
# Find unique grid cells and map old vertices to them
|
||||
_, unique_idx, inv_idx = np.unique(v_1d, return_index=True, return_inverse=True)
|
||||
new_verts = vertices[unique_idx]
|
||||
|
||||
# Map faces to new vertices
|
||||
faces = inv_idx.reshape(-1, 3)
|
||||
|
||||
# Remove degenerate faces (faces where at least two vertices resolve to the same cell)
|
||||
valid = (faces[:,0] != faces[:,1]) & (faces[:,1] != faces[:,2]) & (faces[:,0] != faces[:,2])
|
||||
valid_faces = faces[valid]
|
||||
|
||||
return new_verts, valid_faces
|
||||
|
||||
target_faces = max(1, int((len(vertices) // 3) * keep_ratio))
|
||||
low_g = max_dim * 0.0005
|
||||
high_g = max_dim * 0.2
|
||||
best_verts = vertices
|
||||
best_faces = np.arange(len(vertices)).reshape(-1, 3)
|
||||
|
||||
# Binary search for the right grid size
|
||||
for _ in range(8):
|
||||
g_size = (low_g + high_g) / 2
|
||||
v, f = do_simplify(g_size)
|
||||
best_verts, best_faces = v, f
|
||||
|
||||
if len(f) > target_faces:
|
||||
# too many faces, make grid coarser (larger)
|
||||
low_g = g_size
|
||||
else:
|
||||
# too few faces, make grid finer (smaller)
|
||||
high_g = g_size
|
||||
|
||||
if abs(len(f) - target_faces) < target_faces * 0.05:
|
||||
break
|
||||
|
||||
new_vertices, valid_faces = best_verts, best_faces
|
||||
|
||||
if use_trimesh:
|
||||
simplified = trimesh.Trimesh(vertices=new_vertices, faces=valid_faces, process=False)
|
||||
simplified.export(output_path, file_type='stl')
|
||||
return True
|
||||
|
||||
# Build the simplified mesh using fallback
|
||||
new_m = mesh.Mesh(np.zeros(valid_faces.shape[0], dtype=mesh.Mesh.dtype))
|
||||
|
||||
# Vectorized assignment
|
||||
new_m.vectors[:, 0, :] = new_vertices[valid_faces[:, 0]]
|
||||
new_m.vectors[:, 1, :] = new_vertices[valid_faces[:, 1]]
|
||||
new_m.vectors[:, 2, :] = new_vertices[valid_faces[:, 2]]
|
||||
|
||||
# Calculate normals correctly
|
||||
new_m.update_normals()
|
||||
new_m.save(output_path)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error simplifying STL: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 2:
|
||||
simplify_stl(sys.argv[1], sys.argv[2])
|
||||
@@ -1,14 +1,21 @@
|
||||
from huey import SqliteHuey
|
||||
import subprocess
|
||||
import os
|
||||
from .models import db, PrintFile, SystemConfig
|
||||
from .conf_parse import ConfParse
|
||||
from app.models import db, PrintFile, SystemConfig
|
||||
from app.utils.conf_parse import ConfParse
|
||||
import json
|
||||
import uuid
|
||||
import configparser
|
||||
|
||||
|
||||
huey = SqliteHuey(filename='huey_queue.db')
|
||||
import os
|
||||
|
||||
# Ensure instance directory exists
|
||||
instance_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'instance')
|
||||
os.makedirs(instance_dir, exist_ok=True)
|
||||
huey_db_path = os.path.join(instance_dir, 'huey_queue.db')
|
||||
|
||||
huey = SqliteHuey(filename=huey_db_path)
|
||||
|
||||
|
||||
@huey.task()
|
||||
@@ -216,7 +223,7 @@ def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None,
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
from .models import PrintFile, db
|
||||
from app.models import PrintFile, db
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if not print_file:
|
||||
return
|
||||
@@ -224,7 +231,7 @@ def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None,
|
||||
db.session.remove()
|
||||
|
||||
try:
|
||||
from stl_merger import merge_stls
|
||||
from app.utils.stl_merger import merge_stls
|
||||
merge_stls(inputs, merged_filepath)
|
||||
|
||||
# Now trigger the regular slicing task
|
||||
@@ -244,9 +251,9 @@ def simplify_stl_task(file_id, filepath):
|
||||
from app import create_app
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
from .models import PrintFile, SystemConfig, db
|
||||
from app.models import PrintFile, SystemConfig, db
|
||||
import os
|
||||
from stl_simplifier import simplify_stl
|
||||
from app.utils.stl_simplifier import simplify_stl
|
||||
|
||||
print_file = PrintFile.query.get(file_id)
|
||||
if not print_file:
|
||||
Reference in New Issue
Block a user