有构建板,支持多模型构建,但生成支撑的切片还有bug

This commit is contained in:
2026-04-11 01:50:30 +08:00
parent 975f06eb46
commit 3020957367
31 changed files with 3001 additions and 303 deletions

View File

@@ -9,12 +9,20 @@
<div class="card-body">
<h5>CuraEngine Configurations</h5>
<hr>
<form method="POST">
<form method="POST" action="{{ url_for('admin.settings') }}">
<div class="mb-3">
<label for="concurrent_slices" class="form-label">Concurrent Slices (Queue Worker limit)</label>
<input type="number" class="form-control" id="concurrent_slices" value="2" min="1" max="10">
<label for="offset_x" class="form-label">Plater Origin Offset X (mm)</label>
<input type="number" class="form-control" name="offset_x" id="offset_x" value="{{ configs.get('offset_x', '0') }}">
<div class="form-text">Adjust the X-axis compilation offset for combined files on the build plate.</div>
</div>
<button type="button" class="btn btn-primary" onclick="alert('Settings saved (demo)')">Save Settings</button>
<div class="mb-3">
<label for="offset_y" class="form-label">Plater Origin Offset Y (mm)</label>
<input type="number" class="form-control" name="offset_y" id="offset_y" value="{{ configs.get('offset_y', '0') }}">
<div class="form-text">Adjust the Y-axis compilation offset for combined files on the build plate.</div>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>

View File

@@ -63,16 +63,16 @@
<i class="bi bi-house-door me-2"></i>{{ _('Home') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'main.slice_page' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.slice_page') }}">
<i class="bi bi-box me-2"></i>{{ _('New Slice') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'main.files' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.files') }}">
<i class="bi bi-folder2-open me-2"></i>{{ _('My Files') }}
</a>
</li>
<li class="nav-item mb-1">
<a class="nav-link text-dark {% if request.endpoint == 'main.plater' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.plater') }}">
<i class="bi bi-grid-3x3 me-2"></i>{{ _('Plater') }}
</a>
</li>
</ul>
{% if current_user.is_authenticated and current_user.is_admin %}

View File

@@ -1,8 +1,28 @@
{% extends 'base.html' %}
{% extends "base.html" %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-4 border-bottom">
<h1 class="h2"><i class="bi bi-files me-2 text-warning"></i>{{ _('My Files') }}</h1>
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ _('My Files') }}</h1>
</div>
<div class="card shadow-sm border-0 mb-4 bg-light">
<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">
</div>
<div id="upload-progress-container" class="mt-3 d-none">
<div class="d-flex justify-content-between mb-1">
<span class="text-muted small" id="upload-status-text"><i class="bi bi-cloud-arrow-up-fill me-1"></i>{{ _('Uploading...') }}</span>
<span class="text-primary small fw-bold" id="upload-progress-text">0%</span>
</div>
<div class="progress shadow-sm" style="height: 10px;">
<div id="upload-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-primary" role="progressbar" style="width: 0%;"></div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm border-0">
@@ -23,8 +43,12 @@
<td class="ps-4 text-muted"><i class="bi bi-clock me-1"></i>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td class="fw-medium">{{ file.original_filename }}</td>
<td id="status-{{ file.id }}">
{% if file.status == 'waiting' or file.status == 'uploaded' %}
{% if file.status == 'waiting' %}
<span class="badge bg-info text-dark rounded-pill fw-normal px-2" title="{{ _('Waiting in queue for slicing') }}"><i class="bi bi-hourglass-split me-1"></i>{{ _('Waiting') }}...</span>
{% elif file.status == 'uploaded' %}
<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _('Uploaded') }}</span>
{% elif file.status == 'merging' %}
<span class="badge bg-primary text-light rounded-pill fw-normal px-2"><i class="bi bi-intersect me-1"></i>{{ _('Merging') }}...</span>
{% elif file.status == 'slicing' %}
<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>
{% elif file.status == 'sliced' %}
@@ -35,6 +59,7 @@
</td>
<td class="pe-4">
<div class="d-flex gap-2" id="actions-container-{{ file.id }}">
<a href="{{ url_for('main.plater') }}?add={{ file.id }}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>
{% if file.status == 'sliced' %}
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-primary shadow-sm" title="{{ _('Download GCode') }}"><i class="bi bi-download"></i></a>
<a href="{{ url_for('main.preview_gcode', file_id=file.id) }}" class="btn btn-sm btn-outline-info shadow-sm" title="{{ _('GCode Preview') }}"><i class="bi bi-eye"></i></a>
@@ -75,19 +100,21 @@ document.addEventListener('DOMContentLoaded', function() {
const currentStatus = tr.getAttribute('data-status');
if (currentStatus !== status) {
// Change DOM state
tr.setAttribute('data-status', status);
const statusTd = document.getElementById('status-' + id);
const actionsTd = document.getElementById('actions-container-' + id);
// Update Status Badge HTML correctly preserving translations
if (status === 'waiting' || status === 'uploaded') statusTd.innerHTML = '<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>';
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 === 'uploaded') statusTd.innerHTML = '<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _("Uploaded") }}</span>';
else if (status === 'merging') statusTd.innerHTML = '<span class="badge bg-primary text-light rounded-pill fw-normal px-2"><i class="bi bi-intersect me-1"></i>{{ _("Merging") }}...</span>';
else if (status === 'slicing') statusTd.innerHTML = '<span class="badge bg-warning text-dark rounded-pill fw-normal px-2"><i class="bi bi-gear-wide-connected bi-spin me-1"></i>{{ _("Slicing") }}...</span>';
else if (status === 'sliced') statusTd.innerHTML = '<span class="badge bg-success rounded-pill fw-normal px-2"><i class="bi bi-check-circle me-1"></i>{{ _("Sliced") }}</span>';
else if (status === 'failed') statusTd.innerHTML = '<span class="badge bg-danger rounded-pill fw-normal px-2"><i class="bi bi-x-circle me-1"></i>{{ _("Failed") }}</span>';
// Update Actions HTML
let actionsHtml = '';
const platerUrl = `{{ url_for('main.plater') }}?add=${id}`;
actionsHtml += `<a href="${platerUrl}" class="btn btn-sm btn-outline-warning shadow-sm" title="{{ _('Slice') }}"><i class="bi bi-braces"></i> {{ _('Slice') }}</a>\n`;
if (status === 'sliced') {
const downloadUrl = `{{ url_for('main.download_gcode', file_id=999999999) }}`.replace('999999999', id);
const previewUrl = `{{ url_for('main.preview_gcode', file_id=999999999) }}`.replace('999999999', id);
@@ -101,31 +128,118 @@ document.addEventListener('DOMContentLoaded', function() {
actionsTd.innerHTML = actionsHtml;
}
if (status === 'waiting' || status === 'uploaded' || status === 'slicing') {
if (status === 'waiting' || status === 'slicing' || status === 'merging') {
hasPending = true;
}
}
// Stop polling if there are no more pending files in the user's scope
if (!hasPending && pollTimer) {
if (!hasPending) {
clearInterval(pollTimer);
pollTimer = null;
}
})
.catch(error => console.error('Error fetching file statuses:', error));
.catch(error => console.error('Error fetching status:', error));
}
pollTimer = setInterval(fetchStatus, checkInterval);
// Check initially if we have any pending slices
let needsPolling = false;
document.querySelectorAll('tr[id^="file-row-"]').forEach(row => {
const st = row.getAttribute('data-status');
if (st === 'waiting' || st === 'uploaded' || st === 'slicing') {
needsPolling = true;
// Drag & Drop File Upload Logic
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file');
const progressContainer = document.getElementById('upload-progress-container');
const progressBar = document.getElementById('upload-progress-bar');
const progressText = document.getElementById('upload-progress-text');
const statusText = document.getElementById('upload-status-text');
dropZone.addEventListener('click', () => fileInput.click());
['dragover', 'dragenter'].forEach(evt => {
dropZone.addEventListener(evt, e => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add('bg-light');
dropZone.classList.replace('border-primary', 'border-success');
});
});
['dragleave', 'dragend', 'drop'].forEach(evt => {
dropZone.addEventListener(evt, e => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('bg-light');
dropZone.classList.replace('border-success', 'border-primary');
});
});
dropZone.addEventListener('drop', e => {
const files = e.dataTransfer.files;
if (files.length) {
handleFileUpload(files[0]);
}
});
if (needsPolling) {
pollTimer = setInterval(fetchStatus, checkInterval);
fileInput.addEventListener('change', () => {
if (fileInput.files.length) {
handleFileUpload(fileInput.files[0]);
}
});
function handleFileUpload(file) {
if (!file.name.toLowerCase().endsWith('.stl')) {
alert('{{ _("Please upload a valid .stl file!") }}');
return;
}
const formData = new FormData();
formData.append('file', file);
progressContainer.classList.remove('d-none');
dropZone.classList.add('d-none');
progressBar.style.width = '0%';
progressText.innerText = '0%';
const xhr = new XMLHttpRequest();
xhr.open('POST', '{{ url_for("main.files") }}', true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const percentComplete = Math.floor((e.loaded / e.total) * 100);
progressBar.style.width = percentComplete + '%';
progressText.innerText = percentComplete + '%';
}
};
xhr.onload = function() {
if (xhr.status === 200) {
statusText.innerHTML = '<i class="bi bi-check-circle-fill text-success me-1"></i>{{ _("Upload Complete!") }}';
progressBar.classList.replace('progress-bar-animated', 'bg-success');
setTimeout(() => window.location.reload(), 500);
} else {
try {
let response = JSON.parse(xhr.responseText);
if (response.error) {
alert('{{ _("Validation Failed") }}:\n' + response.error);
progressContainer.classList.add('d-none');
dropZone.classList.remove('d-none');
return;
}
} catch(e) {
console.log('No JSON error response');
}
alert('{{ _("Upload failed.") }}');
progressContainer.classList.add('d-none');
dropZone.classList.remove('d-none');
}
};
xhr.onerror = function() {
alert('{{ _("Upload error.") }}');
progressContainer.classList.add('d-none');
dropZone.classList.remove('d-none');
};
xhr.send(formData);
}
});
</script>

586
app/templates/plater.html Normal file
View File

@@ -0,0 +1,586 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-grid-3x3 me-2 text-primary"></i>{{ _('Plater / Build Plate') }}</h1>
</div>
<div class="row" style="height: 70vh;">
<!-- 3D Area -->
<div class="col-md-9 h-100 position-relative">
<div id="plater-container" class="w-100 h-100 rounded shadow-sm border border-secondary" style="overflow: hidden; background: #f8f9fa;"></div>
<div class="position-absolute top-50 start-0 translate-middle-y ms-3 p-2 bg-white rounded shadow-sm d-flex flex-column gap-2 opacity-75" style="z-index: 10;">
<button class="btn btn-primary btn-sm rounded" id="btn-translate" title="{{ _('Translate (W)') }}" onclick="setTransformMode('translate')"><i class="bi bi-arrows-move"></i></button>
<button class="btn btn-outline-secondary btn-sm rounded" id="btn-rotate" title="{{ _('Rotate (E)') }}" onclick="setTransformMode('rotate')"><i class="bi bi-arrow-clockwise"></i></button>
<button class="btn btn-outline-secondary btn-sm rounded" id="btn-scale" title="{{ _('Scale (R)') }}" onclick="setTransformMode('scale')"><i class="bi bi-arrows-angle-expand"></i></button>
<hr class="m-0 border-secondary">
<button class="btn btn-outline-info btn-sm rounded" id="btn-layflat" title="{{ _('Lay Flat') }}" onclick="setTransformMode('layflat')"><i class="bi bi-symmetry-horizontal"></i></button>
<button class="btn btn-outline-danger btn-sm rounded mt-2" id="btn-remove" title="{{ _('Remove Selected (Del)') }}" onclick="removeActiveModel()"><i class="bi bi-trash3"></i></button>
</div>
</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="list-group list-group-flush" id="model-list" style="max-height: 250px; 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 }}')">
<span class="text-truncate">{{ model.name }}</span>
<i class="bi bi-plus-circle text-success"></i>
</button>
{% else %}
<div class="p-3 text-center text-muted">{{ _("No STL models uploaded yet. Go upload some first.") }}</div>
{% endfor %}
</div>
</div>
</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">
<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 class="card-body py-2">
<div class="mb-2">
<label for="infill-density" class="form-label text-secondary small mb-1">{{ _('Infill Density') }} (%)</label>
<input type="number" class="form-control form-control-sm" id="infill-density" value="20" min="0" max="100">
</div>
<div class="mb-2">
<label for="support-type" class="form-label text-secondary small mb-1">{{ _('Support') }}</label>
<select class="form-select form-select-sm" id="support-type">
<option value="false">{{ _('None') }}</option>
<option value="buildplate">{{ _('Touching Buildplate') }}</option>
<option value="true">{{ _('Everywhere') }}</option>
</select>
</div>
<div class="mb-2">
<label for="support-pattern" class="form-label text-secondary small mb-1">{{ _('Support Type') }}</label>
<select class="form-select form-select-sm" id="support-pattern" disabled>
<option value="lines">{{ _('Lines') }} ({{ _('默认线状') }})</option>
<option value="grid">{{ _('Grid') }} ({{ _('网格状') }})</option>
<option value="triangles">{{ _('Triangles') }} ({{ _('三角网') }})</option>
<option value="zigzag">{{ _('ZigZag') }} ({{ _('之字形') }})</option>
<option value="tree">{{ _('Tree') }} ({{ _('树状') }})</option>
</select>
</div>
</div>
</div>
</div>
<div class="card shadow-sm flex-shrink-0">
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapseQuality" aria-expanded="true">
<span><i class="bi bi-gear-wide-connected me-2"></i>{{ _('Quality Profile') }}</span>
<i class="bi bi-chevron-bar-contract"></i>
</div>
<div id="collapseQuality" class="collapse show">
<div class="card-body">
<div class="mb-3">
<select class="form-select bg-light" id="quality">
{% for key, name in presets %}
<option value="{{ key }}" {% if key == last_quality %}selected{% endif %}>{{ _(name) }}</option>
{% endfor %}
</select>
</div>
<hr>
<div class="d-flex justify-content-between">
<button class="btn btn-outline-danger btn-sm" onclick="clearPlate()"><i class="bi bi-trash me-1"></i>{{ _('Clear Board') }}</button>
<button class="btn btn-primary" onclick="mergeAndSlice()" id="btn-merge"><i class="bi bi-gear-fill me-2" id="merge-icon"></i><span id="merge-text">{{ _('Merge & Slice') }}</span></button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/three.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/OrbitControls.js') }}"></script>
<script src="{{ url_for('static', filename='js/TransformControls.js') }}"></script>
<script src="{{ url_for('static', filename='js/STLLoader.js') }}"></script>
<script>
// Toggle icons on collapse
document.addEventListener('DOMContentLoaded', function() {
const cards = document.querySelectorAll('.collapse');
cards.forEach(card => {
card.addEventListener('show.bs.collapse', function () {
const icon = this.previousElementSibling.querySelector('i.bi-chevron-bar-expand');
if(icon) {
icon.classList.remove('bi-chevron-bar-expand');
icon.classList.add('bi-chevron-bar-contract');
}
});
card.addEventListener('hide.bs.collapse', function () {
const icon = this.previousElementSibling.querySelector('i.bi-chevron-bar-contract');
if(icon) {
icon.classList.remove('bi-chevron-bar-contract');
icon.classList.add('bi-chevron-bar-expand');
}
});
});
});
let scene, camera, renderer, orbit, transformControl, transformProxy, gridHelper, bedBoxOutline;
let boundPlanes = {};
let bedWidth = {{ w }};
let bedDepth = {{ h }};
let bedHeight = {{ hd }};
let offsetX = {{ offset_x|default(0) }};
let offsetY = {{ offset_y|default(0) }};
let loadedModels = [];
let activeModel = null;
initPlater();
animate();
function initPlater() {
const container = document.getElementById('plater-container');
scene = new THREE.Scene();
scene.background = new THREE.Color(0xe9ecef);
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 1, 3000);
camera.position.set(0, -bedDepth * 1.2, bedHeight);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(renderer.domElement);
// Lights
scene.add(new THREE.AmbientLight(0x888888));
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(100, 100, 200);
scene.add(dirLight);
// Bed Grid
const gridSizeX = bedWidth;
const gridSizeY = bedDepth;
const maxGridSize = Math.max(gridSizeX, gridSizeY);
// Divisions needed to make each square exactly 10mm wide
const gridDivisions = Math.ceil(maxGridSize / 10);
gridHelper = new THREE.GridHelper(gridDivisions * 10, gridDivisions, 0xbbbbbb, 0xdddddd);
gridHelper.rotation.x = Math.PI / 2;
scene.add(gridHelper);
// Bed Origin Axes (Bottom-Left Corner)
const axesHelper = new THREE.AxesHelper(maxGridSize / 4);
axesHelper.position.set(-bedWidth / 2, -bedDepth / 2, 0.2);
scene.add(axesHelper);
// Show Bed Box outline
const boxGeo = new THREE.BoxGeometry(bedWidth, bedDepth, bedHeight);
const edges = new THREE.EdgesGeometry(boxGeo);
bedBoxOutline = new THREE.LineSegments(edges, new THREE.LineBasicMaterial( { color: 0xcccccc } ));
bedBoxOutline.position.z = bedHeight / 2;
scene.add(bedBoxOutline);
// Warning planes
const planeMat = new THREE.MeshBasicMaterial({ color: 0xff0000, transparent: true, opacity: 0.4, side: THREE.DoubleSide, depthWrite: false });
boundPlanes = {
minX: new THREE.Mesh(new THREE.PlaneGeometry(bedDepth, bedHeight), planeMat),
maxX: new THREE.Mesh(new THREE.PlaneGeometry(bedDepth, bedHeight), planeMat),
minY: new THREE.Mesh(new THREE.PlaneGeometry(bedWidth, bedHeight), planeMat),
maxY: new THREE.Mesh(new THREE.PlaneGeometry(bedWidth, bedHeight), planeMat),
minZ: new THREE.Mesh(new THREE.PlaneGeometry(bedWidth, bedDepth), planeMat),
maxZ: new THREE.Mesh(new THREE.PlaneGeometry(bedWidth, bedDepth), planeMat)
};
boundPlanes.minX.rotation.y = Math.PI / 2;
boundPlanes.minX.rotation.z = Math.PI / 2;
boundPlanes.minX.position.set(-bedWidth/2, 0, bedHeight/2);
boundPlanes.maxX.rotation.y = Math.PI / 2;
boundPlanes.maxX.rotation.z = Math.PI / 2;
boundPlanes.maxX.position.set(bedWidth/2, 0, bedHeight/2);
boundPlanes.minY.rotation.x = Math.PI / 2;
boundPlanes.minY.position.set(0, -bedDepth/2, bedHeight/2);
boundPlanes.maxY.rotation.x = Math.PI / 2;
boundPlanes.maxY.position.set(0, bedDepth/2, bedHeight/2);
boundPlanes.minZ.position.set(0, 0, 0); // bottom
boundPlanes.maxZ.position.set(0, 0, bedHeight); // top
for (let key in boundPlanes) {
boundPlanes[key].visible = false;
scene.add(boundPlanes[key]);
}
// Controls
orbit = new THREE.OrbitControls(camera, renderer.domElement);
orbit.enableDamping = true;
orbit.target.set(0, 0, 0);
transformProxy = new THREE.Object3D();
scene.add(transformProxy);
transformControl = new THREE.TransformControls(camera, renderer.domElement);
transformControl.setSpace('world');
transformControl.addEventListener('dragging-changed', function (event) {
orbit.enabled = !event.value;
if (!event.value && activeModel) {
scene.attach(activeModel);
transformProxy.position.copy(activeModel.getWorldPosition(new THREE.Vector3()));
transformProxy.rotation.set(0, 0, 0);
transformProxy.scale.set(1, 1, 1);
transformProxy.attach(activeModel);
}
});
scene.add(transformControl);
window.addEventListener('resize', onWindowResize);
window.addEventListener('keydown', onKeyDown);
renderer.domElement.addEventListener('pointerdown', onPointerDown);
}
function onWindowResize() {
const container = document.getElementById('plater-container');
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
let layFlatMode = false;
function setTransformMode(mode) {
if (mode !== 'layflat') {
transformControl.setMode(mode);
transformControl.setSpace(mode === 'scale' ? 'local' : 'world');
layFlatMode = false;
document.getElementById('btn-translate').className = mode === 'translate' ? 'btn btn-primary btn-sm rounded' : 'btn btn-outline-secondary btn-sm rounded';
document.getElementById('btn-rotate').className = mode === 'rotate' ? 'btn btn-primary btn-sm rounded' : 'btn btn-outline-secondary btn-sm rounded';
document.getElementById('btn-scale').className = mode === 'scale' ? 'btn btn-primary btn-sm rounded' : 'btn btn-outline-secondary btn-sm rounded';
document.getElementById('btn-layflat').className = 'btn btn-outline-info btn-sm rounded';
} else {
layFlatMode = true;
document.getElementById('btn-translate').className = 'btn btn-outline-secondary btn-sm rounded';
document.getElementById('btn-rotate').className = 'btn btn-outline-secondary btn-sm rounded';
document.getElementById('btn-scale').className = 'btn btn-outline-secondary btn-sm rounded';
document.getElementById('btn-layflat').className = 'btn btn-info btn-sm rounded text-white';
transformControl.detach();
}
}
function removeActiveModel() {
if (activeModel) {
removeModel(activeModel);
}
}
function onKeyDown(event) {
switch (event.key.toLowerCase()) {
case 'w': setTransformMode('translate'); break;
case 'e': setTransformMode('rotate'); break;
case 'r': setTransformMode('scale'); break;
case 'backspace':
case 'delete':
removeActiveModel();
break;
}
}
function onPointerDown(event) {
if(transformControl.dragging) return;
const container = document.getElementById('plater-container');
const rect = renderer.domElement.getBoundingClientRect();
const pointer = new THREE.Vector2();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(loadedModels, true);
if (layFlatMode) {
if (intersects.length > 0) {
const hit = intersects[0];
const obj = hit.object;
const face = hit.face;
if (face) {
// Ensure model is in world space before applying transformations
scene.attach(obj);
// The target normal to align with (pointing downward to the bed, Z = -1)
const targetNormal = new THREE.Vector3(0, 0, -1);
// Get the face's normal in world space
let localNormal = face.normal.clone();
let currentWorldNormal = localNormal.transformDirection(obj.matrixWorld).normalize();
// Compute quaternion to rotate current normal to point straight down
let quaternion = new THREE.Quaternion();
quaternion.setFromUnitVectors(currentWorldNormal, targetNormal);
// Apply global rotation
obj.quaternion.premultiply(quaternion);
obj.updateMatrixWorld(true);
// Snap to bed (Z=0)
obj.geometry.computeBoundingBox();
const box = obj.geometry.boundingBox.clone();
box.applyMatrix4(obj.matrixWorld);
const minZ = box.min.z;
obj.position.z -= minZ;
obj.updateMatrixWorld(true);
// Exit lay flat mode and reset to translate
setTransformMode('translate');
selectModel(obj);
}
}
return;
}
if (intersects.length > 0) {
selectModel(intersects[0].object);
} else {
selectModel(null);
}
}
function selectModel(model) {
if (activeModel && activeModel !== model) {
scene.attach(activeModel);
}
activeModel = model;
if (model) {
scene.attach(model);
transformProxy.position.copy(model.getWorldPosition(new THREE.Vector3()));
transformProxy.rotation.set(0, 0, 0);
transformProxy.scale.set(1, 1, 1);
transformProxy.attach(model);
transformControl.attach(transformProxy);
} else {
transformControl.detach();
}
}
function removeModel(model) {
if (activeModel === model) {
transformControl.detach();
scene.attach(model);
activeModel = null;
}
scene.remove(model);
loadedModels = loadedModels.filter(m => m !== model);
}
function clearPlate() {
transformControl.detach();
loadedModels.forEach(m => {
scene.attach(m);
scene.remove(m);
});
loadedModels = [];
activeModel = null;
}
function addModelToPlate(btnElement, fileId, url, name, status) {
const iconSpan = btnElement.querySelector('i');
const originalClass = iconSpan.className;
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
btnElement.disabled = true;
const loader = new THREE.STLLoader();
loader.load(url, function (geometry) {
// By default STLs center or are offset, let's normalize slightly to be on top of the plate
geometry.computeBoundingBox();
const center = geometry.boundingBox.getCenter(new THREE.Vector3());
const minZ = geometry.boundingBox.min.z;
geometry.translate(-center.x, -center.y, -minZ);
const material = new THREE.MeshPhongMaterial({ color: 0x0d6efd, specular: 0x111111, shininess: 200 });
const mesh = new THREE.Mesh(geometry, material);
mesh.userData = {
fileId: fileId,
name: name,
status: status,
geomTrans: new THREE.Matrix4().makeTranslation(-center.x, -center.y, -minZ)
};
let matrixData = btnElement.getAttribute('data-matrix');
if (matrixData && matrixData.trim() !== '' && matrixData !== 'None') {
try {
let mArray = JSON.parse(matrixData);
let savedMatrix = new THREE.Matrix4().fromArray(mArray);
savedMatrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
} catch (e) {
console.error('Failed to parse saved matrix:', e);
mesh.position.x = (Math.random() - 0.5) * 50;
mesh.position.y = (Math.random() - 0.5) * 50;
}
} else {
// Random slight offset so they don't exactly stack
mesh.position.x = (Math.random() - 0.5) * 50;
mesh.position.y = (Math.random() - 0.5) * 50;
}
scene.add(mesh);
loadedModels.push(mesh);
selectModel(mesh);
iconSpan.className = originalClass;
btnElement.disabled = false;
}, undefined, function (error) {
console.error(error);
iconSpan.className = originalClass;
btnElement.disabled = false;
alert("{{ _('Error loading STL model file.') }}");
});
}
function checkBounds() {
if (!bedBoxOutline) return false;
let boundsViolation = {
minX: false, maxX: false,
minY: false, maxY: false,
minZ: false, maxZ: false
};
let outOfBounds = false;
for (let i = 0; i < loadedModels.length; i++) {
let m = loadedModels[i];
let box = new THREE.Box3().setFromObject(m);
if (box.min.x < -bedWidth / 2 - 0.05) boundsViolation.minX = true;
if (box.max.x > bedWidth / 2 + 0.05) boundsViolation.maxX = true;
if (box.min.y < -bedDepth / 2 - 0.05) boundsViolation.minY = true;
if (box.max.y > bedDepth / 2 + 0.05) boundsViolation.maxY = true;
if (box.min.z < -0.05) boundsViolation.minZ = true;
if (box.max.z > bedHeight + 0.05) boundsViolation.maxZ = true;
}
outOfBounds = boundsViolation.minX || boundsViolation.maxX ||
boundsViolation.minY || boundsViolation.maxY ||
boundsViolation.minZ || boundsViolation.maxZ;
for (let key in boundsViolation) {
if (boundPlanes && boundPlanes[key]) {
boundPlanes[key].visible = boundsViolation[key];
}
}
if (outOfBounds) {
bedBoxOutline.material.color.setHex(0xffaaaa);
} else {
bedBoxOutline.material.color.setHex(0xcccccc);
}
return outOfBounds;
}
function animate() {
requestAnimationFrame(animate);
checkBounds();
orbit.update();
renderer.render(scene, camera);
}
function mergeAndSlice() {
if (loadedModels.length === 0) {
alert("{{ _('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.') }}");
return;
}
if (loadedModels.length === 1) {
const singleModel = loadedModels[0];
if (singleModel.userData.status === 'sliced') {
if (!confirm("{{ _('This model has already been sliced. The existing GCode will be overwritten. Continue?') }}")) {
return;
}
}
}
const pieces = loadedModels.map(m => {
m.updateMatrixWorld(true);
const mat = m.matrixWorld.clone();
if (m.userData.geomTrans) {
mat.multiply(m.userData.geomTrans);
}
const translation = new THREE.Matrix4().makeTranslation((bedWidth / 2) + offsetX, (bedDepth / 2) + offsetY, 0);
mat.premultiply(translation);
return {
file_id: m.userData.fileId,
matrix: mat.elements, // Array of 16 numbers used for slicing
raw_matrix: m.matrix.elements // Local visual properties
};
});
const quality = document.getElementById('quality').value;
const infill = document.getElementById('infill-density').value;
const support = document.getElementById('support-type').value;
const supportPattern = document.getElementById('support-pattern').value;
const btn = document.getElementById('btn-merge');
const icon = document.getElementById('merge-icon');
const text = document.getElementById('merge-text');
btn.disabled = true;
icon.className = 'spinner-border spinner-border-sm me-2';
text.innerText = '{{ _("Slicing queued!") }}';
// Ajax request
fetch('{{ url_for("main.merge_and_slice") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ pieces: pieces, quality: quality, infill: infill, support: support, support_pattern: supportPattern })
})
.then(response => response.json())
.then(data => {
if(data.success) {
window.location.href = "{{ url_for('main.files') }}";
} else {
alert("Error: " + data.error);
btn.disabled = false;
icon.className = 'bi bi-gear-fill me-2';
text.innerText = '{{ _("Merge & Slice") }}';
}
})
.catch(err => {
alert("Error: " + String(err));
btn.disabled = false;
icon.className = 'bi bi-gear-fill me-2';
text.innerText = '{{ _("Merge & Slice") }}';
});
}
document.addEventListener('DOMContentLoaded', () => {
const supportType = document.getElementById('support-type');
const supportPattern = document.getElementById('support-pattern');
if (supportType && supportPattern) {
supportType.addEventListener('change', function() {
supportPattern.disabled = (this.value === 'false');
});
}
const params = new URLSearchParams(window.location.search);
const addId = params.get('add');
if (addId) {
const btn = document.getElementById('add-model-btn-' + addId);
if (btn) {
btn.click();
}
}
});
</script>
{% endblock %}

View File

@@ -1,234 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-cloud-arrow-up me-2 text-primary"></i>{{ _('Upload & Slice STL') }}</h1>
</div>
<div class="row">
<div class="col-md-6 mb-4 mb-md-0">
<div class="card shadow-sm h-100">
<div class="card-body p-4">
<form id="upload-form" method="POST" enctype="multipart/form-data">
<div class="mb-4">
<label class="form-label fw-bold text-secondary">{{ _('Select STL File') }}</label>
<div id="drop-zone" class="border rounded p-4 text-center position-relative" style="border: 2px dashed #0d6efd !important; cursor: pointer; transition: all 0.3s ease; background-color: #f8f9fa;">
<i class="bi bi-cloud-arrow-up display-4 text-primary mb-2"></i>
<p class="mt-2 text-secondary fw-bold mb-0" id="drop-text">{{ _('Drag & Drop STL file here or Click to Select') }}</p>
<input class="form-control position-absolute w-100 h-100 top-0 start-0 opacity-0" type="file" id="file" name="file" accept=".stl" style="cursor: pointer;" required>
</div>
</div>
<div id="progress-container" class="mb-4 d-none">
<div class="d-flex justify-content-between mb-1">
<span class="text-secondary fw-bold small" id="progress-text">{{ _('Uploading...') }}</span>
<span class="text-primary fw-bold small" id="progress-percent">0%</span>
</div>
<div class="progress rounded-pill" style="height: 10px;">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
<!-- Here you can add slice configurations -->
<div class="mb-4">
<label for="quality" class="form-label fw-bold text-secondary">{{ _('Quality Profile') }}</label>
<select class="form-select bg-light" id="quality" name="quality">
{% for key, name in presets %}
<option value="{{ key }}" {% if key == last_quality %}selected{% endif %}>{{ _(name) }}</option>
{% endfor %}
</select>
</div>
<button type="submit" id="submit-btn" class="btn btn-success fw-bold px-4 py-2 w-100 shadow-sm"><i class="bi bi-gear-fill me-2" id="submit-icon"></i><span id="submit-text">{{ _('Upload & Slice') }}</span></button>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-body p-0 position-relative">
<div id="stl_viewer_container" style="height: 400px; width: 100%; border-radius: 0.375rem; overflow: hidden; background: #f8f9fa;">
<!-- STL Viewer Integration Point -->
<div id="viewer_placeholder" class="text-muted text-center position-absolute top-50 start-50 translate-middle">
<i class="bi bi-box display-1 text-secondary opacity-50 mb-3 d-block"></i>
<h5>{{ _('3D Preview Area') }}</h5><small>{{ _('Upload a file to display') }}</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Three.js + STLLoader + OrbitControls -->
<script src="{{ url_for('static', filename='js/three.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/OrbitControls.js') }}"></script>
<script src="{{ url_for('static', filename='js/STLLoader.js') }}"></script>
<script>
const fileInput = document.getElementById('file');
const dropZone = document.getElementById('drop-zone');
const dropText = document.getElementById('drop-text');
const uploadForm = document.getElementById('upload-form');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
const progressPercent = document.getElementById('progress-percent');
const submitBtn = document.getElementById('submit-btn');
const submitIcon = document.getElementById('submit-icon');
const submitText = document.getElementById('submit-text');
function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); }
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.style.backgroundColor = '#e9ecef';
dropZone.style.borderColor = '#0b5ed7';
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.style.backgroundColor = '#f8f9fa';
dropZone.style.borderColor = '#0d6efd';
}, false);
});
dropZone.addEventListener('drop', e => {
if(e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
fileInput.dispatchEvent(new Event('change'));
}
});
fileInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if(!file) return;
dropText.innerText = file.name;
document.getElementById('viewer_placeholder').style.display = 'none';
const reader = new FileReader();
reader.onload = function(event) {
initViewer(event.target.result);
};
reader.readAsArrayBuffer(file);
});
uploadForm.addEventListener('submit', function(e) {
e.preventDefault();
const file = fileInput.files[0];
if(!file) return;
const formData = new FormData(uploadForm);
progressContainer.classList.remove('d-none');
submitBtn.disabled = true;
submitIcon.className = 'spinner-border spinner-border-sm me-2';
submitText.innerText = '{{ _("Uploading...") }}';
const xhr = new XMLHttpRequest();
xhr.open('POST', window.location.href, true);
xhr.upload.onprogress = function(e) {
if(e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = percent + '%';
progressBar.setAttribute('aria-valuenow', percent);
progressPercent.innerText = percent + '%';
}
};
xhr.onload = function() {
if(xhr.status >= 200 && xhr.status < 300) {
progressBar.classList.remove('progress-bar-animated');
progressBar.classList.remove('progress-bar-striped');
submitText.innerText = '{{ _("Slicing queued!") }}';
window.location.href = "{{ url_for('main.files') }}";
} else {
alert('Error: ' + xhr.statusText);
resetUploadState();
}
};
xhr.onerror = function() {
alert('Upload failed');
resetUploadState();
};
xhr.send(formData);
});
function resetUploadState() {
progressContainer.classList.add('d-none');
submitBtn.disabled = false;
submitIcon.className = 'bi bi-gear-fill me-2';
submitText.innerText = '{{ _("Upload & Slice") }}';
progressBar.style.width = '0%';
progressPercent.innerText = '0%';
}
let scene, camera, renderer, controls;
function initViewer(data) {
const container = document.getElementById('stl_viewer_container');
// Clear previous if any
container.innerHTML = '';
scene = new THREE.Scene();
scene.background = new THREE.Color( 0xf8f9fa );
// Setup camera
camera = new THREE.PerspectiveCamera( 45, container.clientWidth / container.clientHeight, 1, 1000 );
camera.position.set( 0, -150, 150 );
// Setup renderer
renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setSize( container.clientWidth, container.clientHeight );
container.appendChild( renderer.domElement );
// Add lighting
scene.add( new THREE.AmbientLight( 0x777777 ) );
const directionalLight = new THREE.DirectionalLight( 0xffffff, 1 );
directionalLight.position.set( 1, 1, 2 );
scene.add( directionalLight );
// Load STL
const loader = new THREE.STLLoader();
const geometry = loader.parse( data );
geometry.computeBoundingBox();
const center = geometry.boundingBox.getCenter(new THREE.Vector3());
geometry.center();
const material = new THREE.MeshPhongMaterial( { color: 0x0d6efd, specular: 0x111111, shininess: 200 } );
const mesh = new THREE.Mesh( geometry, material );
// Optional: scale model to fit view automatically
const boundingSphere = geometry.boundingBox.getBoundingSphere(new THREE.Sphere());
const radius = boundingSphere.radius;
camera.position.set(0, -radius * 2, radius * 2);
scene.add( mesh );
// Add controls
controls = new THREE.OrbitControls( camera, renderer.domElement );
controls.enableDamping = true;
controls.target.set(0,0,0);
controls.update();
animate();
}
function animate() {
requestAnimationFrame( animate );
controls.update();
renderer.render( scene, camera );
}
// Handle window resize dynamically inside container context
window.addEventListener('resize', function() {
if(camera && renderer) {
const container = document.getElementById('stl_viewer_container');
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize( container.clientWidth, container.clientHeight );
}
});
</script>
{% endblock %}