整理文件夹及架构,加入打印机页面,octo反代有问题

This commit is contained in:
2026-04-14 00:11:00 +08:00
parent 1de35f21d7
commit 570af7c225
54 changed files with 939 additions and 292 deletions

View File

@@ -22,7 +22,7 @@ i18n_dict = {}
def load_i18n(app):
global i18n_dict
i18n_dir = os.path.join(app.root_path, '..', 'assets', 'i18n')
i18n_dir = os.path.join(app.root_path, 'assets', 'i18n')
if os.path.exists(i18n_dir):
for f in os.listdir(i18n_dir):
if f.endswith('.json'):
@@ -50,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)

View File

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

View File

@@ -0,0 +1,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

File diff suppressed because it is too large Load Diff

6
app/assets/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

117
app/assets/i18n/en.json Normal file
View 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
View 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": "保存设置"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

6
app/assets/js/three.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

131
app/routes/admin_routes.py Normal file
View 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
View 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 ---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,8 @@
<div class="card-body">
<div id="drop-zone" class="border border-2 border-primary rounded p-4 text-center bg-white" style="border-style: dashed !important; cursor: pointer; transition: all 0.3s ease;">
<i class="bi bi-cloud-arrow-up display-4 text-primary mb-2"></i>
<h5 class="text-secondary fw-normal">{{ _('Drag & Drop STL file here or Click to Select') }}</h5>
<input type="file" id="file" name="file" accept=".stl" class="d-none">
<h5 class="text-secondary fw-normal">{{ _('Drag & Drop STL files here or Click to Select') }}</h5>
<input type="file" id="file" name="file" accept=".stl" class="d-none" multiple>
</div>
<div id="upload-progress-container" class="mt-3 d-none">
@@ -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');
};

View File

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

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

170
app/utils/stl_simplifier.py Normal file
View 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])

View File

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