缩放后修复,合并有问题
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
uploads/*
|
uploads/*
|
||||||
tmp/*
|
tmp/*
|
||||||
|
venv
|
||||||
|
instance
|
||||||
|
huey_queue.*
|
||||||
@@ -9,7 +9,7 @@ from flask_login import login_user, logout_user, login_required, current_user
|
|||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from .models import db, User, PrintFile, SystemConfig
|
from .models import db, User, PrintFile, SystemConfig
|
||||||
from .tasks import merge_and_slice_task, slice_stl_task
|
from .tasks import merge_and_slice_task, slice_stl_task, simplify_stl_task
|
||||||
from app import i18n_dict
|
from app import i18n_dict
|
||||||
# import trimesh.repair
|
# import trimesh.repair
|
||||||
from stl_simplifier import simplify_stl
|
from stl_simplifier import simplify_stl
|
||||||
@@ -118,11 +118,14 @@ def files():
|
|||||||
original_filename=original_filename,
|
original_filename=original_filename,
|
||||||
file_type='stl',
|
file_type='stl',
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
status='uploaded' # Only display as uploaded, no automatic slicing
|
status='simplifying' # Set to simplifying while proxy is generated
|
||||||
)
|
)
|
||||||
db.session.add(print_file)
|
db.session.add(print_file)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# Start background simplification
|
||||||
|
simplify_stl_task(print_file.id, filepath)
|
||||||
|
|
||||||
flash('File uploaded successfully!', 'success')
|
flash('File uploaded successfully!', 'success')
|
||||||
return redirect(url_for('main.files'))
|
return redirect(url_for('main.files'))
|
||||||
|
|
||||||
@@ -241,6 +244,7 @@ def settings():
|
|||||||
# concurrent_slices = request.form.get('concurrent_slices')
|
# concurrent_slices = request.form.get('concurrent_slices')
|
||||||
offset_x = request.form.get('offset_x', '0')
|
offset_x = request.form.get('offset_x', '0')
|
||||||
offset_y = request.form.get('offset_y', '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_infill = request.form.get('default_infill', '20')
|
||||||
default_support = request.form.get('default_support', 'false')
|
default_support = request.form.get('default_support', 'false')
|
||||||
default_support_pattern = request.form.get('default_support_pattern', 'tree')
|
default_support_pattern = request.form.get('default_support_pattern', 'tree')
|
||||||
@@ -250,6 +254,7 @@ def settings():
|
|||||||
config_items = [
|
config_items = [
|
||||||
('offset_x', offset_x),
|
('offset_x', offset_x),
|
||||||
('offset_y', offset_y),
|
('offset_y', offset_y),
|
||||||
|
('proxy_skip_size_mb', proxy_skip_size_mb),
|
||||||
('default_infill', default_infill),
|
('default_infill', default_infill),
|
||||||
('default_support', default_support),
|
('default_support', default_support),
|
||||||
('default_support_pattern', default_support_pattern),
|
('default_support_pattern', default_support_pattern),
|
||||||
@@ -368,11 +373,6 @@ def serve_proxy_file(file_id):
|
|||||||
abort(403)
|
abort(403)
|
||||||
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
|
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
|
||||||
proxy_path = path + '.proxy.stl'
|
proxy_path = path + '.proxy.stl'
|
||||||
if not os.path.exists(proxy_path):
|
|
||||||
try:
|
|
||||||
simplify_stl(path, proxy_path, keep_ratio=0.05) # compress to 90%
|
|
||||||
except:
|
|
||||||
return send_file(path) # fallback to original if error
|
|
||||||
if os.path.exists(proxy_path):
|
if os.path.exists(proxy_path):
|
||||||
return send_file(proxy_path)
|
return send_file(proxy_path)
|
||||||
return send_file(path)
|
return send_file(path)
|
||||||
@@ -406,24 +406,53 @@ def merge_and_slice():
|
|||||||
else:
|
else:
|
||||||
combined_name += " 单独切片"
|
combined_name += " 单独切片"
|
||||||
|
|
||||||
|
is_edit = data.get('is_edit', False)
|
||||||
|
|
||||||
for p in pieces:
|
for p in pieces:
|
||||||
f = PrintFile.query.get(p['file_id'])
|
f = PrintFile.query.get(p['file_id'])
|
||||||
if f and (f.user_id == current_user.id or current_user.is_admin):
|
if f and (f.user_id == current_user.id or current_user.is_admin):
|
||||||
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
|
path = os.path.join(current_app.config['UPLOAD_FOLDER'], f.filename)
|
||||||
inputs.append((path, p['matrix']))
|
inputs.append((path, p['matrix']))
|
||||||
if 'raw_matrix' in p:
|
# 只有在单一编辑模式才修改原模型的矩阵 (如果多模型/新建模式,我们不修改原模型,而是后续记录到新的包含实体上)
|
||||||
|
if 'raw_matrix' in p and is_edit and len(pieces) == 1:
|
||||||
f.transform_matrix = json.dumps(p['raw_matrix'])
|
f.transform_matrix = json.dumps(p['raw_matrix'])
|
||||||
db.session.add(f)
|
db.session.add(f)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
if len(inputs) == 0:
|
target_file_id = data.get('target_file_id')
|
||||||
return jsonify({'error': 'Invalid files'}), 400
|
|
||||||
|
|
||||||
is_edit = data.get('is_edit', False)
|
if is_edit and target_file_id:
|
||||||
|
print_file = PrintFile.query.get(target_file_id)
|
||||||
if len(inputs) == 1 and is_edit:
|
if not print_file:
|
||||||
# User is just generating gcode for a single original model, do NOT pollute list with new STL
|
return jsonify({'error': 'Original file not found'}), 404
|
||||||
|
print_file.status = 'merging'
|
||||||
|
|
||||||
|
if print_file.transform_matrix and 'is_composite' in print_file.transform_matrix:
|
||||||
|
composite_data = {
|
||||||
|
"is_composite": True,
|
||||||
|
"parts": []
|
||||||
|
}
|
||||||
|
for p in pieces:
|
||||||
|
pf = PrintFile.query.get(p['file_id'])
|
||||||
|
if pf:
|
||||||
|
composite_data['parts'].append({
|
||||||
|
"file_id": pf.id,
|
||||||
|
"name": pf.original_filename,
|
||||||
|
"url": url_for('main.serve_proxy_file', file_id=pf.id),
|
||||||
|
"raw_matrix": p.get('raw_matrix', p['matrix'])
|
||||||
|
})
|
||||||
|
print_file.transform_matrix = json.dumps(composite_data)
|
||||||
|
elif len(pieces) == 1:
|
||||||
|
print_file.transform_matrix = json.dumps(pieces[0].get('raw_matrix', pieces[0]['matrix']))
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
temp_filename = f"temp_edit_{uuid.uuid4().hex}.stl"
|
||||||
|
temp_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], temp_filename)
|
||||||
|
|
||||||
|
merge_and_slice_task(target_file_id, inputs, temp_filepath, quality, infill_density, support_enable, support_pattern, delete_stl=True)
|
||||||
|
elif len(inputs) == 1 and is_edit:
|
||||||
target_file_id = pieces[0]['file_id']
|
target_file_id = pieces[0]['file_id']
|
||||||
print_file = PrintFile.query.get(target_file_id)
|
print_file = PrintFile.query.get(target_file_id)
|
||||||
if not print_file:
|
if not print_file:
|
||||||
@@ -442,12 +471,28 @@ def merge_and_slice():
|
|||||||
unique_filename = f"{timestamp}_{uuid.uuid4().hex}.stl"
|
unique_filename = f"{timestamp}_{uuid.uuid4().hex}.stl"
|
||||||
merged_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename)
|
merged_filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_filename)
|
||||||
|
|
||||||
|
# 构建组合文件元数据树 (is_composite: true)
|
||||||
|
composite_data = {
|
||||||
|
"is_composite": True,
|
||||||
|
"parts": []
|
||||||
|
}
|
||||||
|
for p in pieces:
|
||||||
|
pf = PrintFile.query.get(p['file_id'])
|
||||||
|
if pf:
|
||||||
|
composite_data['parts'].append({
|
||||||
|
"file_id": pf.id,
|
||||||
|
"name": pf.original_filename,
|
||||||
|
"url": url_for('main.serve_proxy_file', file_id=pf.id),
|
||||||
|
"raw_matrix": p.get('raw_matrix', p['matrix'])
|
||||||
|
})
|
||||||
|
|
||||||
print_file = PrintFile(
|
print_file = PrintFile(
|
||||||
filename=unique_filename,
|
filename=unique_filename,
|
||||||
original_filename=f"{combined_name}.stl",
|
original_filename=f"{combined_name}.stl",
|
||||||
file_type='stl',
|
file_type='stl',
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
status='merging'
|
status='merging',
|
||||||
|
transform_matrix=json.dumps(composite_data)
|
||||||
)
|
)
|
||||||
db.session.add(print_file)
|
db.session.add(print_file)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
50
app/tasks.py
50
app/tasks.py
@@ -238,3 +238,53 @@ def merge_and_slice_task(file_id, inputs, merged_filepath, quality_preset=None,
|
|||||||
app.logger.error(f"Merge Exception: {e}")
|
app.logger.error(f"Merge Exception: {e}")
|
||||||
finally:
|
finally:
|
||||||
db.session.remove()
|
db.session.remove()
|
||||||
|
|
||||||
|
@huey.task()
|
||||||
|
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
|
||||||
|
import os
|
||||||
|
from stl_simplifier import simplify_stl
|
||||||
|
|
||||||
|
print_file = PrintFile.query.get(file_id)
|
||||||
|
if not print_file:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_size_mb = os.path.getsize(filepath) / (1024 * 1024)
|
||||||
|
|
||||||
|
configs = {c.key: c.value for c in SystemConfig.query.all()}
|
||||||
|
skip_size = float(configs.get('proxy_skip_size_mb', '5.0'))
|
||||||
|
|
||||||
|
proxy_path = filepath + '.proxy.stl'
|
||||||
|
|
||||||
|
if file_size_mb <= skip_size:
|
||||||
|
# File is small enough, no proxy needed
|
||||||
|
print_file.status = 'uploaded'
|
||||||
|
db.session.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Aim for approx 7.5 MB for the proxy
|
||||||
|
target_mb = 7.5
|
||||||
|
keep_ratio = target_mb / file_size_mb
|
||||||
|
if keep_ratio > 1.0:
|
||||||
|
keep_ratio = 1.0
|
||||||
|
elif keep_ratio < 0.01:
|
||||||
|
keep_ratio = 0.01
|
||||||
|
|
||||||
|
app.logger.info(f"Simplifying {filepath}... Size: {file_size_mb:.2f}MB, Target Ratio: {keep_ratio:.3f}")
|
||||||
|
|
||||||
|
simplify_stl(filepath, proxy_path, keep_ratio=keep_ratio)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Simplify task error: {e}")
|
||||||
|
|
||||||
|
# Update status to uploaded regardless of success or failure of proxy generation
|
||||||
|
# So the user can still slice or download it
|
||||||
|
print_file = PrintFile.query.get(file_id)
|
||||||
|
if print_file:
|
||||||
|
print_file.status = 'uploaded'
|
||||||
|
db.session.commit()
|
||||||
|
db.session.remove()
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
<div class="form-text">{{ _('Adjust the Y-axis compilation offset for combined files on the build plate.') }}</div>
|
<div class="form-text">{{ _('Adjust the Y-axis compilation offset for combined files on the build plate.') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="proxy_skip_size_mb" class="form-label">{{ _('Proxy Skip Size (MB)') }}</label>
|
||||||
|
<input type="number" class="form-control" name="proxy_skip_size_mb" id="proxy_skip_size_mb" value="{{ configs.get('proxy_skip_size_mb', '5.0') }}" step="0.1" min="0">
|
||||||
|
<div class="form-text">{{ _('Files smaller than this will not generate a simplified proxy.') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h5 class="mt-4">{{ _('Default Plater Settings') }}</h5>
|
<h5 class="mt-4">{{ _('Default Plater Settings') }}</h5>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,16 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for file in files %}
|
{% for file in files %}
|
||||||
<tr id="file-row-{{ file.id }}" data-status="{{ file.status }}">
|
<tr id="file-row-{{ file.id }}" data-status="{{ file.status }}">
|
||||||
<td class="ps-4 text-muted"><i class="bi bi-clock me-1"></i>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
<td class="ps-4 text-muted">
|
||||||
|
<i class="bi bi-clock me-1"></i>
|
||||||
|
<span class="local-time" data-utc="{{ file.created_at.isoformat() }}">{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
|
||||||
|
</td>
|
||||||
<td class="fw-medium">{{ file.original_filename }}</td>
|
<td class="fw-medium">{{ file.original_filename }}</td>
|
||||||
<td id="status-{{ file.id }}">
|
<td id="status-{{ file.id }}">
|
||||||
{% if file.status == 'waiting' %}
|
{% if file.status == 'waiting' %}
|
||||||
<span class="badge bg-info text-dark rounded-pill fw-normal px-2" title="{{ _('Waiting in queue for slicing') }}"><i class="bi bi-hourglass-split me-1"></i>{{ _('Waiting') }}...</span>
|
<span class="badge bg-info text-dark rounded-pill fw-normal px-2" title="{{ _('Waiting in queue for slicing') }}"><i class="bi bi-hourglass-split me-1"></i>{{ _('Waiting') }}...</span>
|
||||||
|
{% elif file.status == 'simplifying' %}
|
||||||
|
<span class="badge bg-secondary text-dark rounded-pill fw-normal px-2" style="background-color: #e2e3e5 !important;"><i class="bi bi-funnel bi-spin me-1"></i>{{ _('Simplifying') }}...</span>
|
||||||
{% elif file.status == 'uploaded' %}
|
{% elif file.status == 'uploaded' %}
|
||||||
<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _('Uploaded') }}</span>
|
<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _('Uploaded') }}</span>
|
||||||
{% elif file.status == 'merging' %}
|
{% elif file.status == 'merging' %}
|
||||||
@@ -86,6 +91,25 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Convert UTC to local time
|
||||||
|
document.querySelectorAll('.local-time').forEach(el => {
|
||||||
|
let utcStr = el.getAttribute('data-utc');
|
||||||
|
if (!utcStr) return;
|
||||||
|
if (!utcStr.endsWith('Z') && !utcStr.includes('+')) {
|
||||||
|
utcStr += 'Z';
|
||||||
|
}
|
||||||
|
const localDate = new Date(utcStr);
|
||||||
|
if (!isNaN(localDate)) {
|
||||||
|
const year = localDate.getFullYear();
|
||||||
|
const month = String(localDate.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(localDate.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(localDate.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(localDate.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(localDate.getSeconds()).padStart(2, '0');
|
||||||
|
el.textContent = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const checkInterval = 1000;
|
const checkInterval = 1000;
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
|
|
||||||
@@ -105,6 +129,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const actionsTd = document.getElementById('actions-container-' + id);
|
const actionsTd = document.getElementById('actions-container-' + id);
|
||||||
|
|
||||||
if (status === 'waiting') statusTd.innerHTML = '<span class="badge bg-info text-dark rounded-pill fw-normal px-2"><i class="bi bi-hourglass-split me-1"></i>{{ _("Waiting") }}...</span>';
|
if (status === 'waiting') statusTd.innerHTML = '<span class="badge bg-info text-dark rounded-pill fw-normal px-2"><i class="bi bi-hourglass-split me-1"></i>{{ _("Waiting") }}...</span>';
|
||||||
|
else if (status === 'simplifying') statusTd.innerHTML = '<span class="badge bg-secondary text-dark rounded-pill fw-normal px-2" style="background-color: #e2e3e5 !important;"><i class="bi bi-funnel bi-spin me-1"></i>{{ _("Simplifying") }}...</span>';
|
||||||
else if (status === 'uploaded') statusTd.innerHTML = '<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _("Uploaded") }}</span>';
|
else if (status === 'uploaded') statusTd.innerHTML = '<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _("Uploaded") }}</span>';
|
||||||
else if (status === 'merging') statusTd.innerHTML = '<span class="badge bg-primary text-light rounded-pill fw-normal px-2"><i class="bi bi-intersect me-1"></i>{{ _("Merging") }}...</span>';
|
else if (status === 'merging') statusTd.innerHTML = '<span class="badge bg-primary text-light rounded-pill fw-normal px-2"><i class="bi bi-intersect me-1"></i>{{ _("Merging") }}...</span>';
|
||||||
else if (status === 'slicing') statusTd.innerHTML = '<span class="badge bg-warning text-dark rounded-pill fw-normal px-2"><i class="bi bi-gear-wide-connected bi-spin me-1"></i>{{ _("Slicing") }}...</span>';
|
else if (status === 'slicing') statusTd.innerHTML = '<span class="badge bg-warning text-dark rounded-pill fw-normal px-2"><i class="bi bi-gear-wide-connected bi-spin me-1"></i>{{ _("Slicing") }}...</span>';
|
||||||
@@ -128,7 +153,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
actionsTd.innerHTML = actionsHtml;
|
actionsTd.innerHTML = actionsHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'waiting' || status === 'slicing' || status === 'merging') {
|
if (status === 'waiting' || status === 'slicing' || status === 'merging' || status === 'simplifying') {
|
||||||
hasPending = true;
|
hasPending = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -480,14 +480,59 @@ function clearPlate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addModelToPlate(btnElement, fileId, url, name, status) {
|
function addModelToPlate(btnElement, fileId, url, name, status) {
|
||||||
const iconSpan = btnElement.querySelector('i');
|
let matrixData = btnElement ? btnElement.getAttribute('data-matrix') : null;
|
||||||
const originalClass = iconSpan.className;
|
|
||||||
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
|
if (matrixData && matrixData.includes('"is_composite"')) {
|
||||||
btnElement.disabled = true;
|
try {
|
||||||
|
let comp = JSON.parse(matrixData);
|
||||||
|
if (comp.is_composite && comp.parts) {
|
||||||
|
if (btnElement) {
|
||||||
|
const iconSpan = btnElement.querySelector('i');
|
||||||
|
const originalClass = iconSpan.className;
|
||||||
|
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
|
||||||
|
btnElement.disabled = true;
|
||||||
|
|
||||||
|
let totalParts = comp.parts.length;
|
||||||
|
let loadedCount = 0;
|
||||||
|
|
||||||
|
comp.parts.forEach(part => {
|
||||||
|
loadSTL(part.file_id, part.url, part.name, 'uploaded', JSON.stringify(part.raw_matrix), () => {
|
||||||
|
loadedCount++;
|
||||||
|
if (loadedCount === totalParts) {
|
||||||
|
iconSpan.className = originalClass;
|
||||||
|
btnElement.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
comp.parts.forEach(part => {
|
||||||
|
loadSTL(part.file_id, part.url, part.name, 'uploaded', JSON.stringify(part.raw_matrix));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnElement) {
|
||||||
|
const iconSpan = btnElement.querySelector('i');
|
||||||
|
const originalClass = iconSpan.className;
|
||||||
|
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
|
||||||
|
btnElement.disabled = true;
|
||||||
|
loadSTL(fileId, url, name, status, matrixData, () => {
|
||||||
|
iconSpan.className = originalClass;
|
||||||
|
btnElement.disabled = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loadSTL(fileId, url, name, status, matrixData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSTL(fileId, url, name, status, matrixData, callback) {
|
||||||
const loader = new THREE.STLLoader();
|
const loader = new THREE.STLLoader();
|
||||||
loader.load(url, function (geometry) {
|
loader.load(url, function (geometry) {
|
||||||
// By default STLs center or are offset, let's normalize slightly to be on top of the plate
|
|
||||||
geometry.computeBoundingBox();
|
geometry.computeBoundingBox();
|
||||||
const center = geometry.boundingBox.getCenter(new THREE.Vector3());
|
const center = geometry.boundingBox.getCenter(new THREE.Vector3());
|
||||||
const minZ = geometry.boundingBox.min.z;
|
const minZ = geometry.boundingBox.min.z;
|
||||||
@@ -502,8 +547,7 @@ function addModelToPlate(btnElement, fileId, url, name, status) {
|
|||||||
geomTrans: new THREE.Matrix4().makeTranslation(-center.x, -center.y, -minZ)
|
geomTrans: new THREE.Matrix4().makeTranslation(-center.x, -center.y, -minZ)
|
||||||
};
|
};
|
||||||
|
|
||||||
let matrixData = btnElement.getAttribute('data-matrix');
|
if (matrixData && matrixData.trim() !== '' && matrixData !== 'None' && !matrixData.includes('"is_composite"')) {
|
||||||
if (matrixData && matrixData.trim() !== '' && matrixData !== 'None') {
|
|
||||||
try {
|
try {
|
||||||
let mArray = JSON.parse(matrixData);
|
let mArray = JSON.parse(matrixData);
|
||||||
let savedMatrix = new THREE.Matrix4().fromArray(mArray);
|
let savedMatrix = new THREE.Matrix4().fromArray(mArray);
|
||||||
@@ -514,7 +558,6 @@ function addModelToPlate(btnElement, fileId, url, name, status) {
|
|||||||
mesh.position.y = (Math.random() - 0.5) * 50;
|
mesh.position.y = (Math.random() - 0.5) * 50;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Random slight offset so they don't exactly stack
|
|
||||||
mesh.position.x = (Math.random() - 0.5) * 50;
|
mesh.position.x = (Math.random() - 0.5) * 50;
|
||||||
mesh.position.y = (Math.random() - 0.5) * 50;
|
mesh.position.y = (Math.random() - 0.5) * 50;
|
||||||
}
|
}
|
||||||
@@ -522,13 +565,11 @@ function addModelToPlate(btnElement, fileId, url, name, status) {
|
|||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
loadedModels.push(mesh);
|
loadedModels.push(mesh);
|
||||||
selectModel(mesh);
|
selectModel(mesh);
|
||||||
|
|
||||||
iconSpan.className = originalClass;
|
if (callback) callback();
|
||||||
btnElement.disabled = false;
|
|
||||||
}, undefined, function (error) {
|
}, undefined, function (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
iconSpan.className = originalClass;
|
if (callback) callback();
|
||||||
btnElement.disabled = false;
|
|
||||||
alert("{{ _('Error loading STL model file.') }}");
|
alert("{{ _('Error loading STL model file.') }}");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -581,6 +622,8 @@ function animate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mergeAndSlice() {
|
function mergeAndSlice() {
|
||||||
|
selectModel(null); // Detach any active model to bake transformProxy world coordinates into its local matrix properties
|
||||||
|
|
||||||
if (loadedModels.length === 0) {
|
if (loadedModels.length === 0) {
|
||||||
alert("{{ _('Please add at least one model to the build plate.') }}");
|
alert("{{ _('Please add at least one model to the build plate.') }}");
|
||||||
return;
|
return;
|
||||||
@@ -591,14 +634,27 @@ function mergeAndSlice() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let isEdit = (loadedModels.length === 1 && String(loadedModels[0].userData.fileId) === String(initialAddId));
|
let isEdit = false;
|
||||||
|
let targetFileId = null;
|
||||||
|
|
||||||
|
if (window.isCompositeEdit) {
|
||||||
|
isEdit = true;
|
||||||
|
targetFileId = initialAddId;
|
||||||
|
} else if (loadedModels.length === 1 && String(loadedModels[0].userData.fileId) === String(initialAddId)) {
|
||||||
|
isEdit = true;
|
||||||
|
targetFileId = initialAddId;
|
||||||
|
}
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
const singleModel = loadedModels[0];
|
// Just checking if we want to warn
|
||||||
if (singleModel.userData.status === 'sliced') {
|
if (loadedModels.length === 1 && loadedModels[0].userData.status === 'sliced') {
|
||||||
if (!confirm("{{ _('This model has already been sliced. The existing GCode will be overwritten. Continue?') }}")) {
|
if (!confirm("{{ _('This model has already been sliced. The existing GCode will be overwritten. Continue?') }}")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else if (window.isCompositeEdit) {
|
||||||
|
if (!confirm("{{ _('You are editing a composite model. The existing composite will be updated and re-sliced. Continue?') }}")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,7 +669,7 @@ function mergeAndSlice() {
|
|||||||
return {
|
return {
|
||||||
file_id: m.userData.fileId,
|
file_id: m.userData.fileId,
|
||||||
matrix: mat.elements, // Array of 16 numbers used for slicing
|
matrix: mat.elements, // Array of 16 numbers used for slicing
|
||||||
raw_matrix: m.matrix.elements // Local visual properties
|
raw_matrix: m.matrixWorld.elements // Use world matrix explicitly just in case
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -636,7 +692,7 @@ function mergeAndSlice() {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ pieces: pieces, quality: quality, infill: infill, support: support, support_pattern: supportPattern, is_edit: isEdit })
|
body: JSON.stringify({ pieces: pieces, quality: quality, infill: infill, support: support, support_pattern: supportPattern, is_edit: isEdit, target_file_id: targetFileId })
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -671,6 +727,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (addId) {
|
if (addId) {
|
||||||
const btn = document.getElementById('add-model-btn-' + addId);
|
const btn = document.getElementById('add-model-btn-' + addId);
|
||||||
if (btn) {
|
if (btn) {
|
||||||
|
let matrixData = btn.getAttribute('data-matrix');
|
||||||
|
if (matrixData && matrixData.includes('"is_composite"')) {
|
||||||
|
window.isCompositeEdit = true;
|
||||||
|
}
|
||||||
btn.click();
|
btn.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
"No files uploaded yet.": "No files uploaded yet.",
|
"No files uploaded yet.": "No files uploaded yet.",
|
||||||
"Drag & Drop STL file here or Click to Select": "Drag & Drop STL file here or Click to Select",
|
"Drag & Drop STL file here or Click to Select": "Drag & Drop STL file here or Click to Select",
|
||||||
"Uploading...": "Uploading...",
|
"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!",
|
"Slicing queued!": "Slicing queued!",
|
||||||
"Draft Quality": "Draft Quality",
|
"Draft Quality": "Draft Quality",
|
||||||
"Standard Quality": "Standard Quality",
|
"Standard Quality": "Standard Quality",
|
||||||
|
|||||||
@@ -40,6 +40,10 @@
|
|||||||
"No files uploaded yet.": "还没有上传文件。",
|
"No files uploaded yet.": "还没有上传文件。",
|
||||||
"Drag & Drop STL file here or Click to Select": "将 STL 文件拖放到此处或点击选择",
|
"Drag & Drop STL file here or Click to Select": "将 STL 文件拖放到此处或点击选择",
|
||||||
"Uploading...": "上传中...",
|
"Uploading...": "上传中...",
|
||||||
|
"Simplifying": "简化中",
|
||||||
|
"Simplifying...": "正在简化...",
|
||||||
|
"Proxy Skip Size (MB)": "代理免简化大小 (MB)",
|
||||||
|
"Files smaller than this will not generate a simplified proxy.": "极小体积的文件无需降维生成加速展现的代理文件。",
|
||||||
"Upload Complete!": "上传完成!",
|
"Upload Complete!": "上传完成!",
|
||||||
"Upload error.": "上传出错。",
|
"Upload error.": "上传出错。",
|
||||||
"Upload failed.": "上传失败。",
|
"Upload failed.": "上传失败。",
|
||||||
|
|||||||
138
stl_merger.py
138
stl_merger.py
@@ -2,89 +2,52 @@ import struct
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
|
||||||
def transform_stl(input_path, output_path, matrix):
|
|
||||||
# matrix is a flat 16 float array from Three.js (column-major format)
|
|
||||||
# [m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, m11, m12, m13, m14, m15]
|
|
||||||
|
|
||||||
with open(input_path, 'rb') as f:
|
|
||||||
header = f.read(80)
|
|
||||||
num_faces = struct.unpack('<I', f.read(4))[0]
|
|
||||||
|
|
||||||
faces = f.read()
|
|
||||||
|
|
||||||
# Precompute for speed
|
|
||||||
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
|
|
||||||
|
|
||||||
def apply_rot(_nx, _ny, _nz):
|
|
||||||
# Normals don't need translation, only upper 3x3
|
|
||||||
# Assuming isotropic scaling for normals, otherwise needs inverse transpose
|
|
||||||
x = _nx * matrix[0] + _ny * matrix[4] + _nz * matrix[8]
|
|
||||||
y = _nx * matrix[1] + _ny * matrix[5] + _nz * matrix[9]
|
|
||||||
z = _nx * matrix[2] + _ny * matrix[6] + _nz * matrix[10]
|
|
||||||
length = math.sqrt(x*x + y*y + z*z)
|
|
||||||
if length > 0.000001:
|
|
||||||
return x/length, y/length, z/length
|
|
||||||
return 0.0, 0.0, 0.0
|
|
||||||
|
|
||||||
out = bytearray(80 + 4 + num_faces * 50)
|
|
||||||
out[0:80] = header
|
|
||||||
out[80:84] = struct.pack('<I', num_faces)
|
|
||||||
|
|
||||||
offset = 84
|
|
||||||
src_offset = 0
|
|
||||||
for _ in range(num_faces):
|
|
||||||
face_data = faces[src_offset:src_offset+50]
|
|
||||||
if len(face_data) < 50:
|
|
||||||
break
|
|
||||||
|
|
||||||
nx, ny, nz, v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z, attr = struct.unpack('<12fH', face_data)
|
|
||||||
|
|
||||||
# Transform normal
|
|
||||||
nnx, nny, nnz = apply_rot(nx, ny, nz)
|
|
||||||
# Transform vertices
|
|
||||||
nv1x, nv1y, nv1z = apply_m(v1x, v1y, v1z)
|
|
||||||
nv2x, nv2y, nv2z = apply_m(v2x, v2y, v2z)
|
|
||||||
nv3x, nv3y, nv3z = apply_m(v3x, v3y, v3z)
|
|
||||||
|
|
||||||
struct.pack_into('<12fH', out, offset, nnx, nny, nnz, nv1x, nv1y, nv1z, nv2x, nv2y, nv2z, nv3x, nv3y, nv3z, attr)
|
|
||||||
offset += 50
|
|
||||||
src_offset += 50
|
|
||||||
|
|
||||||
with open(output_path, 'wb') as f:
|
|
||||||
f.write(out)
|
|
||||||
|
|
||||||
return num_faces
|
|
||||||
|
|
||||||
def merge_stls(input_files, output_path):
|
def merge_stls(input_files, output_path):
|
||||||
try:
|
try:
|
||||||
import trimesh
|
from stl import mesh
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
meshes = []
|
meshes = []
|
||||||
for path, matrix in input_files:
|
for path, matrix in input_files:
|
||||||
mesh = trimesh.load(path, file_type='stl')
|
try:
|
||||||
m = np.array(matrix, dtype=np.float64).reshape((4, 4)).T
|
# 重新换回轻量级的 numpy-stl 以防内存溢出 (OOM)
|
||||||
mesh.apply_transform(m)
|
m = mesh.Mesh.from_file(path)
|
||||||
meshes.append(mesh)
|
|
||||||
|
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:
|
if not meshes:
|
||||||
return
|
return
|
||||||
elif len(meshes) == 1:
|
|
||||||
merged = meshes[0]
|
|
||||||
else:
|
|
||||||
merged = trimesh.util.concatenate(meshes)
|
|
||||||
|
|
||||||
merged.export(output_path, file_type='stl')
|
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
|
return
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Trimesh fast-merge failed: {e}, falling back to struct parsing.")
|
print(f"Mesh fast-merge failed: {e}. Falling back to struct parsing.")
|
||||||
pass # Fallback to slower python struct method
|
|
||||||
|
|
||||||
|
# Extreme fallback just in case no stl libraries work
|
||||||
total_faces = 0
|
total_faces = 0
|
||||||
meshes_data = []
|
meshes_data = []
|
||||||
|
|
||||||
@@ -94,25 +57,12 @@ def merge_stls(input_files, output_path):
|
|||||||
faces = struct.unpack('<I', f.read(4))[0]
|
faces = struct.unpack('<I', f.read(4))[0]
|
||||||
data = f.read(faces * 50)
|
data = f.read(faces * 50)
|
||||||
|
|
||||||
# transform data
|
|
||||||
# To speed things up, we could just do the transform in python like above
|
|
||||||
# For this simple prototype, let's process each file into memory:
|
|
||||||
|
|
||||||
def apply_m(_x, _y, _z):
|
def apply_m(_x, _y, _z):
|
||||||
w = _x * matrix[3] + _y * matrix[7] + _z * matrix[11] + matrix[15]
|
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
|
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
|
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
|
nz = (_x * matrix[2] + _y * matrix[6] + _z * matrix[10] + matrix[14]) / w
|
||||||
return nx, ny, nz
|
return nx, ny, nz
|
||||||
|
|
||||||
def apply_rot(_nx, _ny, _nz):
|
|
||||||
x = _nx * matrix[0] + _ny * matrix[4] + _nz * matrix[8]
|
|
||||||
y = _nx * matrix[1] + _ny * matrix[5] + _nz * matrix[9]
|
|
||||||
z = _nx * matrix[2] + _ny * matrix[6] + _nz * matrix[10]
|
|
||||||
length = math.sqrt(x*x + y*y + z*z)
|
|
||||||
if length > 0.000001:
|
|
||||||
return x/length, y/length, z/length
|
|
||||||
return 0.0, 0.0, 0.0
|
|
||||||
|
|
||||||
new_data = bytearray(faces * 50)
|
new_data = bytearray(faces * 50)
|
||||||
src_offset = 0
|
src_offset = 0
|
||||||
@@ -120,11 +70,28 @@ def merge_stls(input_files, output_path):
|
|||||||
for _ in range(faces):
|
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)
|
n_x, n_y, n_z, v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z, attr = struct.unpack_from('<12fH', data, src_offset)
|
||||||
|
|
||||||
nnx, nny, nnz = apply_rot(n_x, n_y, n_z)
|
|
||||||
nv1x, nv1y, nv1z = apply_m(v1x, v1y, v1z)
|
nv1x, nv1y, nv1z = apply_m(v1x, v1y, v1z)
|
||||||
nv2x, nv2y, nv2z = apply_m(v2x, v2y, v2z)
|
nv2x, nv2y, nv2z = apply_m(v2x, v2y, v2z)
|
||||||
nv3x, nv3y, nv3z = apply_m(v3x, v3y, v3z)
|
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)
|
struct.pack_into('<12fH', new_data, dst_offset, nnx, nny, nnz, nv1x, nv1y, nv1z, nv2x, nv2y, nv2z, nv3x, nv3y, nv3z, attr)
|
||||||
src_offset += 50
|
src_offset += 50
|
||||||
dst_offset += 50
|
dst_offset += 50
|
||||||
@@ -132,7 +99,6 @@ def merge_stls(input_files, output_path):
|
|||||||
meshes_data.append(new_data)
|
meshes_data.append(new_data)
|
||||||
total_faces += faces
|
total_faces += faces
|
||||||
|
|
||||||
# write merged
|
|
||||||
with open(output_path, 'wb') as f:
|
with open(output_path, 'wb') as f:
|
||||||
f.write(b'\0' * 80)
|
f.write(b'\0' * 80)
|
||||||
f.write(struct.pack('<I', total_faces))
|
f.write(struct.pack('<I', total_faces))
|
||||||
|
|||||||
BIN
test_out.STL
BIN
test_out.STL
Binary file not shown.
Reference in New Issue
Block a user