整理文件夹及架构,加入打印机页面,octo反代有问题
This commit is contained in:
278
app/templates/slice/files.html
Normal file
278
app/templates/slice/files.html
Normal file
@@ -0,0 +1,278 @@
|
||||
{% 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">{{ _('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 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">
|
||||
<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">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-4 fw-semibold text-secondary">{{ _('Date Uploaded') }}</th>
|
||||
<th class="fw-semibold text-secondary">{{ _('Original Name') }}</th>
|
||||
<th class="fw-semibold text-secondary">{{ _('Status') }}</th>
|
||||
<th class="pe-4 fw-semibold text-secondary">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for file in files %}
|
||||
<tr id="file-row-{{ file.id }}" data-status="{{ file.status }}">
|
||||
<td class="ps-4 text-muted">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
<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 id="status-{{ file.id }}">
|
||||
{% if file.status == 'waiting' %}
|
||||
<span class="badge bg-info text-dark rounded-pill fw-normal px-2" title="{{ _('Waiting in queue for slicing') }}"><i class="bi bi-hourglass-split me-1"></i>{{ _('Waiting') }}...</span>
|
||||
{% elif file.status == 'simplifying' %}
|
||||
<span class="badge bg-secondary text-dark rounded-pill fw-normal px-2" style="background-color: #e2e3e5 !important;"><i class="bi bi-funnel bi-spin me-1"></i>{{ _('Simplifying') }}...</span>
|
||||
{% elif file.status == 'uploaded' %}
|
||||
<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _('Uploaded') }}</span>
|
||||
{% elif file.status == 'merging' %}
|
||||
<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' %}
|
||||
<span class="badge bg-success rounded-pill fw-normal px-2"><i class="bi bi-check-circle me-1"></i>{{ _('Sliced') }}</span>
|
||||
{% elif file.status == 'failed' %}
|
||||
<span class="badge bg-danger rounded-pill fw-normal px-2"><i class="bi bi-x-circle me-1"></i>{{ _('Failed') }}</span>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% endif %}
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-5">
|
||||
<i class="bi bi-folder-x display-4 d-block mb-3 opacity-50"></i>
|
||||
{{ _('No files uploaded yet.') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Convert UTC to local time
|
||||
document.querySelectorAll('.local-time').forEach(el => {
|
||||
let utcStr = el.getAttribute('data-utc');
|
||||
if (!utcStr) return;
|
||||
if (!utcStr.endsWith('Z') && !utcStr.includes('+')) {
|
||||
utcStr += 'Z';
|
||||
}
|
||||
const localDate = new Date(utcStr);
|
||||
if (!isNaN(localDate)) {
|
||||
const year = localDate.getFullYear();
|
||||
const month = String(localDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(localDate.getDate()).padStart(2, '0');
|
||||
const hours = String(localDate.getHours()).padStart(2, '0');
|
||||
const minutes = String(localDate.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(localDate.getSeconds()).padStart(2, '0');
|
||||
el.textContent = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
});
|
||||
|
||||
const checkInterval = 1000;
|
||||
let pollTimer = null;
|
||||
|
||||
function fetchStatus() {
|
||||
fetch(`{{ url_for('main.files_status') }}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
let hasPending = false;
|
||||
for (const [id, status] of Object.entries(data)) {
|
||||
const tr = document.getElementById('file-row-' + id);
|
||||
if (!tr) continue;
|
||||
|
||||
const currentStatus = tr.getAttribute('data-status');
|
||||
if (currentStatus !== status) {
|
||||
tr.setAttribute('data-status', status);
|
||||
const statusTd = document.getElementById('status-' + 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>';
|
||||
else if (status === 'simplifying') statusTd.innerHTML = '<span class="badge bg-secondary text-dark rounded-pill fw-normal px-2" style="background-color: #e2e3e5 !important;"><i class="bi bi-funnel bi-spin me-1"></i>{{ _("Simplifying") }}...</span>';
|
||||
else if (status === 'uploaded') statusTd.innerHTML = '<span class="badge bg-secondary text-light rounded-pill fw-normal px-2"><i class="bi bi-cloud-check me-1"></i>{{ _("Uploaded") }}</span>';
|
||||
else if (status === 'merging') statusTd.innerHTML = '<span class="badge bg-primary text-light rounded-pill fw-normal px-2"><i class="bi bi-intersect me-1"></i>{{ _("Merging") }}...</span>';
|
||||
else if (status === 'slicing') statusTd.innerHTML = '<span class="badge bg-warning text-dark rounded-pill fw-normal px-2"><i class="bi bi-gear-wide-connected bi-spin me-1"></i>{{ _("Slicing") }}...</span>';
|
||||
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>';
|
||||
|
||||
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);
|
||||
actionsHtml += `<a href="${downloadUrl}" class="btn btn-sm btn-outline-primary shadow-sm" title="{{ _('Download GCode') }}"><i class="bi bi-download"></i></a>\n`;
|
||||
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="event.preventDefault(); window.customConfirm('{{ _('Are you sure you want to delete this file?') }}', () => { this.submit(); });">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger shadow-sm" title="{{ _('Delete') }}"><i class="bi bi-trash3"></i></button>
|
||||
</form>`;
|
||||
actionsTd.innerHTML = actionsHtml;
|
||||
}
|
||||
|
||||
if (status === 'waiting' || status === 'slicing' || status === 'merging' || status === 'simplifying') {
|
||||
hasPending = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPending) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching status:', error));
|
||||
}
|
||||
|
||||
pollTimer = setInterval(fetchStatus, checkInterval);
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files.length) {
|
||||
handleFileUpload(fileInput.files);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
window.customAlert('{{ _("Validation Failed") }}:\n' + response.error);
|
||||
progressContainer.classList.add('d-none');
|
||||
dropZone.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
} catch(e) {
|
||||
console.log('No JSON error response');
|
||||
}
|
||||
window.customAlert('{{ _("Upload failed.") }}');
|
||||
progressContainer.classList.add('d-none');
|
||||
dropZone.classList.remove('d-none');
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
window.customAlert('{{ _("Upload error.") }}');
|
||||
progressContainer.classList.add('d-none');
|
||||
dropZone.classList.remove('d-none');
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
471
app/templates/slice/gcode_preview.html
Normal file
471
app/templates/slice/gcode_preview.html
Normal file
@@ -0,0 +1,471 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2"><i class="bi bi-eye text-primary me-2"></i>{{ _('GCode Preview') }}: {{ file.original_filename }}</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('main.download_gcode', file_id=file.id) }}" class="btn btn-primary btn-sm rounded shadow-sm"><i class="bi bi-download"></i> {{ _('Download GCode') }}</a>
|
||||
<a href="{{ url_for('main.files') }}" class="btn btn-outline-secondary btn-sm rounded ms-2 shadow-sm"><i class="bi bi-arrow-left"></i> {{ _('Back') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading-overlay" class="text-center py-5 my-5">
|
||||
<div class="spinner-border text-primary shadow-sm" role="status" style="width: 3rem; height: 3rem;"></div>
|
||||
<h4 class="mt-4 text-secondary">{{ _('Loading and Parsing GCode Data...') }}</h4>
|
||||
</div>
|
||||
|
||||
<div class="row d-none" id="preview-container" style="height: 75vh;">
|
||||
<!-- 3D Canvas Area -->
|
||||
<div class="col-md-11 position-relative h-100 p-0 border rounded border-secondary shadow-sm" style="background: #111;">
|
||||
<div id="canvas-container" class="w-100 h-100 d-block overflow-hidden"></div>
|
||||
|
||||
<!-- Legend Overlay -->
|
||||
<div class="position-absolute top-0 start-0 m-3 p-2 rounded shadow bg-dark bg-opacity-75 border border-secondary" style="color: #eee; font-size: 0.85rem; pointer-events: auto; z-index: 10;">
|
||||
<div class="mb-1 legend-item user-select-none" data-type="WALL-OUTER" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #eb8b38;"></span>{{ _('Outer Wall') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="WALL-INNER" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #4080cf;"></span>{{ _('Inner Wall') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="FILL" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #ccc04b;"></span>{{ _('Infill') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="SKIN" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #9e60b3;"></span>{{ _('Skin/TopBottom') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="SUPPORT" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #57b357;"></span>{{ _('Support') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="SKIRT" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #00ffff;"></span>{{ _('Skirt') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="SUPPORT-INTERFACE" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #2b6b2b;"></span>{{ _('Support Interface') }}</div>
|
||||
<div class="mb-1 legend-item user-select-none" data-type="TRAVEL" style="cursor: pointer; transition: opacity 0.2s; opacity: 0.4;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #405060;"></span>{{ _('Travel (Move)') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Slider (Intra-Layer Progress) -->
|
||||
<div class="position-absolute bottom-0 start-0 w-100 p-3" style="background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.8) 100%); z-index: 10;">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-white fw-medium text-nowrap user-select-none"><i class="bi bi-play-circle me-1"></i>{{ _('Layer Progress:') }}</span>
|
||||
<input type="range" class="form-range flex-grow-1" id="progress-slider" min="0" max="100" value="100" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar (Layer Slider) -->
|
||||
<div class="col-md-1 h-100 d-flex flex-column align-items-center justify-content-center bg-white border rounded shadow-sm position-relative">
|
||||
<label class="form-label mb-3 fw-bold text-center text-primary mt-3">{{ _('Layer') }}<br>
|
||||
<span id="layer-display" class="badge bg-primary fs-6 mt-1 shadow-sm px-3 rounded-pill">0</span>
|
||||
</label>
|
||||
<div class="flex-grow-1 w-100 d-flex justify-content-center pb-4 py-2">
|
||||
<input type="range" class="form-range h-100" id="layer-slider" min="0" max="0" value="0" style="writing-mode: bt-lr; -webkit-appearance: slider-vertical; cursor: ns-resize;">
|
||||
</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>
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const COLORS = {
|
||||
'WALL-OUTER': new THREE.Color(0xeb8b38),
|
||||
'WALL-INNER': new THREE.Color(0x4080cf),
|
||||
'FILL': new THREE.Color(0xccc04b),
|
||||
'SKIN': new THREE.Color(0x9e60b3),
|
||||
'SUPPORT': new THREE.Color(0x57b357),
|
||||
'SKIRT': new THREE.Color(0x00ffff),
|
||||
'SUPPORT-INTERFACE': new THREE.Color(0x2b6b2b),
|
||||
'TRAVEL': new THREE.Color(0x405060),
|
||||
'DEFAULT': new THREE.Color(0xaaaaaa),
|
||||
};
|
||||
|
||||
// Inject printer machine dimensions via Jinja
|
||||
const bedWidth = {{ machine_width | default(220) }};
|
||||
const bedDepth = {{ machine_depth | default(220) }};
|
||||
const bedHeight = {{ machine_height | default(250) }};
|
||||
const offsetX = {{ offset_x | default(0.0) }};
|
||||
const offsetY = {{ offset_y | default(0.0) }};
|
||||
|
||||
// Type indices for shader visibility filtering
|
||||
const TYPE_INDEX = {
|
||||
'TRAVEL': 0, 'WALL-OUTER': 1, 'WALL-INNER': 2,
|
||||
'FILL': 3, 'SKIN': 4, 'SUPPORT': 5, 'DEFAULT': 6,
|
||||
'SKIRT': 7, 'SUPPORT-INTERFACE': 8
|
||||
};
|
||||
|
||||
let layers = [];
|
||||
let scene, camera, renderer, controls;
|
||||
let group = new THREE.Group();
|
||||
|
||||
const layerSlider = document.getElementById('layer-slider');
|
||||
const layerDisplay = document.getElementById('layer-display');
|
||||
const progressSlider = document.getElementById('progress-slider');
|
||||
|
||||
// Shader material for high-speed dynamic feature visibility
|
||||
const gcodeMat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uShowOuter: { value: 1.0 },
|
||||
uShowInner: { value: 1.0 },
|
||||
uShowInfill: { value: 1.0 },
|
||||
uShowSkin: { value: 1.0 },
|
||||
uShowSupport: { value: 1.0 },
|
||||
uShowSkirt: { value: 1.0 },
|
||||
uShowSupportInterface: { value: 1.0 },
|
||||
uShowTravel: { value: 0.0 },
|
||||
uShowDefault: { value: 1.0 }
|
||||
},
|
||||
vertexShader: `
|
||||
attribute float pType;
|
||||
varying vec3 vColor;
|
||||
varying float vType;
|
||||
void main() {
|
||||
vColor = color;
|
||||
vType = pType;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
varying vec3 vColor;
|
||||
varying float vType;
|
||||
uniform float uShowOuter;
|
||||
uniform float uShowInner;
|
||||
uniform float uShowInfill;
|
||||
uniform float uShowSkin;
|
||||
uniform float uShowSupport;
|
||||
uniform float uShowSkirt;
|
||||
uniform float uShowSupportInterface;
|
||||
uniform float uShowTravel;
|
||||
uniform float uShowDefault;
|
||||
void main() {
|
||||
float show = 1.0;
|
||||
int t = int(vType + 0.5);
|
||||
if (t == 0) show = uShowTravel;
|
||||
else if (t == 1) show = uShowOuter;
|
||||
else if (t == 2) show = uShowInner;
|
||||
else if (t == 3) show = uShowInfill;
|
||||
else if (t == 4) show = uShowSkin;
|
||||
else if (t == 5) show = uShowSupport;
|
||||
else if (t == 7) show = uShowSkirt;
|
||||
else if (t == 8) show = uShowSupportInterface;
|
||||
else show = uShowDefault;
|
||||
|
||||
if (show < 0.5) discard;
|
||||
gl_FragColor = vec4(vColor, 1.0);
|
||||
}
|
||||
`,
|
||||
vertexColors: true,
|
||||
side: THREE.DoubleSide,
|
||||
linewidth: 1
|
||||
});
|
||||
|
||||
// Binding the Legend Buttons
|
||||
const uniformMap = {
|
||||
'WALL-OUTER': 'uShowOuter',
|
||||
'WALL-INNER': 'uShowInner',
|
||||
'FILL': 'uShowInfill',
|
||||
'SKIN': 'uShowSkin',
|
||||
'SUPPORT': 'uShowSupport',
|
||||
'SKIRT': 'uShowSkirt',
|
||||
'SUPPORT-INTERFACE': 'uShowSupportInterface',
|
||||
'TRAVEL': 'uShowTravel'
|
||||
};
|
||||
|
||||
document.querySelectorAll('.legend-item').forEach(el => {
|
||||
el.addEventListener('click', function() {
|
||||
const t = this.dataset.type;
|
||||
const uniformName = uniformMap[t];
|
||||
if (uniformName) {
|
||||
const currentVal = gcodeMat.uniforms[uniformName].value;
|
||||
const newVal = currentVal > 0.5 ? 0.0 : 1.0;
|
||||
gcodeMat.uniforms[uniformName].value = newVal;
|
||||
this.style.opacity = newVal > 0.5 ? "1.0" : "0.4";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function init3D() {
|
||||
const container = document.getElementById('canvas-container');
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a1a);
|
||||
|
||||
scene.add(group);
|
||||
|
||||
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 1, 5000);
|
||||
camera.up.set(0, 0, 1);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = false;
|
||||
controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN;
|
||||
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
const container = document.getElementById('canvas-container');
|
||||
camera.aspect = container.clientWidth / container.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
async function loadGCode() {
|
||||
try {
|
||||
const url = '{{ url_for("main.download_gcode", file_id=file.id) }}';
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error("GCode Request Failed");
|
||||
|
||||
const gcodeText = await response.text();
|
||||
|
||||
document.getElementById('loading-overlay').classList.add('d-none');
|
||||
document.getElementById('preview-container').classList.remove('d-none');
|
||||
|
||||
init3D();
|
||||
parseGCode(gcodeText);
|
||||
|
||||
// Add grid matching printer size
|
||||
setupMachineEnvironment();
|
||||
|
||||
animate();
|
||||
|
||||
// Init controls
|
||||
layerSlider.max = Math.max(0, layers.length - 1);
|
||||
layerSlider.value = Math.max(0, layers.length - 1);
|
||||
updateUI();
|
||||
|
||||
layerSlider.addEventListener('input', updateUI);
|
||||
progressSlider.addEventListener('input', updateUI);
|
||||
|
||||
} catch(e) {
|
||||
console.error("Error Loading GCode", e);
|
||||
document.getElementById('loading-overlay').innerHTML = `
|
||||
<div class="text-danger my-5 py-5">
|
||||
<i class="bi bi-exclamation-triangle display-1"></i>
|
||||
<h3 class="mt-3">{{ _('Failed to load GCode preview.') }}</h3>
|
||||
<p class="text-muted">${e.toString()}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGCode(text) {
|
||||
const lines = text.split('\n');
|
||||
|
||||
let current = { x: 0, y: 0, z: 0, e: 0 };
|
||||
let currentTypeStr = 'DEFAULT';
|
||||
|
||||
let currentExtrudePoints = [];
|
||||
let currentExtrudeColors = [];
|
||||
let currentExtrudeTypes = [];
|
||||
|
||||
let currentTravelPoints = [];
|
||||
let currentTravelColors = [];
|
||||
let currentTravelTypes = [];
|
||||
|
||||
function flushLayer() {
|
||||
if (currentExtrudePoints.length === 0 && currentTravelPoints.length === 0) return;
|
||||
|
||||
let layerGroup = new THREE.Group();
|
||||
|
||||
if (currentExtrudePoints.length > 0) {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(currentExtrudePoints, 3));
|
||||
geo.setAttribute('color', new THREE.Float32BufferAttribute(currentExtrudeColors, 3));
|
||||
geo.setAttribute('pType', new THREE.Float32BufferAttribute(currentExtrudeTypes, 1));
|
||||
const mesh = new THREE.Mesh(geo, gcodeMat);
|
||||
mesh.userData.isExtrude = true;
|
||||
layerGroup.add(mesh);
|
||||
currentExtrudePoints = []; currentExtrudeColors = []; currentExtrudeTypes = [];
|
||||
}
|
||||
|
||||
if (currentTravelPoints.length > 0) {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(currentTravelPoints, 3));
|
||||
geo.setAttribute('color', new THREE.Float32BufferAttribute(currentTravelColors, 3));
|
||||
geo.setAttribute('pType', new THREE.Float32BufferAttribute(currentTravelTypes, 1));
|
||||
const lineSeg = new THREE.LineSegments(geo, gcodeMat);
|
||||
lineSeg.userData.isTravel = true;
|
||||
layerGroup.add(lineSeg);
|
||||
currentTravelPoints = []; currentTravelColors = []; currentTravelTypes = [];
|
||||
}
|
||||
|
||||
layers.push(layerGroup);
|
||||
group.add(layerGroup);
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let chunk = lines[i].trim().toUpperCase();
|
||||
if (!chunk) continue;
|
||||
|
||||
if (chunk.startsWith(';LAYER:')) {
|
||||
flushLayer();
|
||||
} else if (chunk.startsWith(';TYPE:')) {
|
||||
currentTypeStr = chunk.substring(6).trim();
|
||||
} else if (chunk.startsWith('G0') || chunk.startsWith('G1')) {
|
||||
let next = { x: current.x, y: current.y, z: current.z, e: current.e };
|
||||
let parts = chunk.split(/\s+/);
|
||||
let hasMove = false;
|
||||
|
||||
for (let p of parts) {
|
||||
if (p.startsWith('X')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.x = v; hasMove = true; } }
|
||||
if (p.startsWith('Y')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.y = v; hasMove = true; } }
|
||||
if (p.startsWith('Z')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.z = v; hasMove = true; } }
|
||||
if (p.startsWith('E')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) { next.e = v; } }
|
||||
}
|
||||
|
||||
if (hasMove && !isNaN(next.x) && !isNaN(next.y) && !isNaN(next.z)) {
|
||||
let isExtrude = (next.e > current.e);
|
||||
// Cura uses G0 for travel generally
|
||||
if (chunk.startsWith('G0') && !chunk.includes('E')) isExtrude = false;
|
||||
|
||||
let activeType = isExtrude ? currentTypeStr : 'TRAVEL';
|
||||
let col = COLORS[activeType] || COLORS['DEFAULT'];
|
||||
let tIdx = TYPE_INDEX[activeType] !== undefined ? TYPE_INDEX[activeType] : TYPE_INDEX['DEFAULT'];
|
||||
|
||||
if (isExtrude) {
|
||||
let dx = next.x - current.x;
|
||||
let dy = next.y - current.y;
|
||||
let dist = Math.sqrt(dx*dx + dy*dy);
|
||||
if (dist > 0.0001) {
|
||||
let hw = 0.4 / 2.0; // 0.4mm wire width
|
||||
let hh = 0.2 / 2.0; // 0.2mm layer height roughly
|
||||
|
||||
let nx = -(dy / dist) * hw;
|
||||
let ny = (dx / dist) * hw;
|
||||
|
||||
let p1x = current.x + nx, p1y = current.y + ny; // current-left
|
||||
let p2x = current.x - nx, p2y = current.y - ny; // current-right
|
||||
let p3x = next.x + nx, p3y = next.y + ny; // next-left
|
||||
let p4x = next.x - nx, p4y = next.y - ny; // next-right
|
||||
|
||||
// Top face
|
||||
currentExtrudePoints.push(
|
||||
p1x, p1y, current.z + hh,
|
||||
p3x, p3y, next.z + hh,
|
||||
p2x, p2y, current.z + hh,
|
||||
p3x, p3y, next.z + hh,
|
||||
p4x, p4y, next.z + hh,
|
||||
p2x, p2y, current.z + hh
|
||||
);
|
||||
for(let k=0; k<6; k++) { currentExtrudeColors.push(col.r, col.g, col.b); currentExtrudeTypes.push(tIdx); }
|
||||
|
||||
// Bottom face
|
||||
currentExtrudePoints.push(
|
||||
p1x, p1y, current.z - hh,
|
||||
p2x, p2y, current.z - hh,
|
||||
p3x, p3y, next.z - hh,
|
||||
p2x, p2y, current.z - hh,
|
||||
p4x, p4y, next.z - hh,
|
||||
p3x, p3y, next.z - hh
|
||||
);
|
||||
for(let k=0; k<6; k++) { currentExtrudeColors.push(col.r*0.4, col.g*0.4, col.b*0.4); currentExtrudeTypes.push(tIdx); }
|
||||
|
||||
// Left face
|
||||
currentExtrudePoints.push(
|
||||
p1x, p1y, current.z - hh,
|
||||
p3x, p3y, next.z - hh,
|
||||
p1x, p1y, current.z + hh,
|
||||
p3x, p3y, next.z - hh,
|
||||
p3x, p3y, next.z + hh,
|
||||
p1x, p1y, current.z + hh
|
||||
);
|
||||
// Fake lighting based on normal side
|
||||
for(let k=0; k<6; k++) { currentExtrudeColors.push(col.r*0.6, col.g*0.6, col.b*0.6); currentExtrudeTypes.push(tIdx); }
|
||||
|
||||
// Right face
|
||||
currentExtrudePoints.push(
|
||||
p2x, p2y, current.z - hh,
|
||||
p2x, p2y, current.z + hh,
|
||||
p4x, p4y, next.z - hh,
|
||||
p2x, p2y, current.z + hh,
|
||||
p4x, p4y, next.z + hh,
|
||||
p4x, p4y, next.z - hh
|
||||
);
|
||||
for(let k=0; k<6; k++) { currentExtrudeColors.push(col.r*0.8, col.g*0.8, col.b*0.8); currentExtrudeTypes.push(tIdx); }
|
||||
}
|
||||
} else {
|
||||
currentTravelPoints.push(current.x, current.y, current.z);
|
||||
currentTravelPoints.push(next.x, next.y, next.z);
|
||||
currentTravelColors.push(col.r, col.g, col.b, col.r, col.g, col.b);
|
||||
currentTravelTypes.push(tIdx, tIdx);
|
||||
}
|
||||
|
||||
current.x = next.x; current.y = next.y; current.z = next.z; current.e = next.e;
|
||||
}
|
||||
} else if (chunk.startsWith('G92')) {
|
||||
let parts = chunk.split(/\s+/);
|
||||
for (let p of parts) {
|
||||
if (p.startsWith('E')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) current.e = v; }
|
||||
if (p.startsWith('X')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) current.x = v; }
|
||||
if (p.startsWith('Y')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) current.y = v; }
|
||||
if (p.startsWith('Z')) { let v = parseFloat(p.substring(1)); if(!isNaN(v)) current.z = v; }
|
||||
}
|
||||
}
|
||||
}
|
||||
flushLayer();
|
||||
}
|
||||
|
||||
function setupMachineEnvironment() {
|
||||
if (layers.length === 0) return;
|
||||
|
||||
let bbox = new THREE.Box3();
|
||||
for (let layerGrp of layers) {
|
||||
layerGrp.children.forEach(child => {
|
||||
child.geometry.computeBoundingBox();
|
||||
bbox.union(child.geometry.boundingBox);
|
||||
});
|
||||
}
|
||||
|
||||
// The GCode coordinates for the actual print bed are from (0,0) to (W,H).
|
||||
// The GCode trajectory is ALREADY offset by plater.html during slicing.
|
||||
// We just need to place the grid exactly in the center of the bed: (W/2, H/2).
|
||||
let gridOffsetX = (bedWidth / 2);
|
||||
let gridOffsetY = (bedDepth / 2);
|
||||
|
||||
// Add Grid
|
||||
const gridDivisions = Math.ceil(Math.max(bedWidth, bedDepth) / 10);
|
||||
const gridHelper = new THREE.GridHelper(Math.max(bedWidth, bedDepth), gridDivisions, 0x444444, 0x242424);
|
||||
gridHelper.rotation.x = Math.PI / 2;
|
||||
gridHelper.position.set(gridOffsetX, gridOffsetY, 0);
|
||||
scene.add(gridHelper);
|
||||
|
||||
// Add Printer Volume Outline
|
||||
const boxGeo = new THREE.BoxGeometry(bedWidth, bedDepth, bedHeight);
|
||||
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||
const boxOutline = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x444444 }));
|
||||
boxOutline.position.set(gridOffsetX, gridOffsetY, bedHeight/2);
|
||||
scene.add(boxOutline);
|
||||
|
||||
// Align Camera to target the center of the bed grid
|
||||
controls.target.set(gridOffsetX, gridOffsetY, 0);
|
||||
camera.position.set(gridOffsetX, gridOffsetY - (bedDepth * 1.5), bedHeight * 0.8);
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
if (layers.length === 0) return;
|
||||
let activeIdx = parseInt(layerSlider.value);
|
||||
let intraProg = parseFloat(progressSlider.value);
|
||||
|
||||
layerDisplay.innerText = activeIdx + " / " + (layers.length - 1);
|
||||
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
let layerGrp = layers[i];
|
||||
if (i < activeIdx) {
|
||||
layerGrp.visible = true;
|
||||
layerGrp.children.forEach(child => child.geometry.setDrawRange(0, Infinity));
|
||||
} else if (i === activeIdx) {
|
||||
layerGrp.visible = true;
|
||||
layerGrp.children.forEach(child => {
|
||||
let totalVertices = child.geometry.attributes.position.count;
|
||||
let elementsPerUnit = child.userData.isTravel ? 2 : 24;
|
||||
let totalUnits = totalVertices / elementsPerUnit;
|
||||
let drawCount = Math.floor(totalUnits * (intraProg / 100)) * elementsPerUnit;
|
||||
child.geometry.setDrawRange(0, drawCount);
|
||||
});
|
||||
} else {
|
||||
layerGrp.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadGCode();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
18
app/templates/slice/index.html
Normal file
18
app/templates/slice/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">{{ _('Dashboard') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card text-white bg-primary mb-3 shadow-sm border-0">
|
||||
<div class="card-header border-0 fs-5 fw-medium"><i class="bi bi-bar-chart-fill me-2"></i>{{ _('Total Prints') }}</div>
|
||||
<div class="card-body mt-2">
|
||||
<h5 class="card-title">{{ _('You have sliced') }} <b class="fs-1 mx-2">{{ current_user.print_files|length }}</b> {{ _('files') }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
942
app/templates/slice/plater.html
Normal file
942
app/templates/slice/plater.html
Normal file
@@ -0,0 +1,942 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
/* 防止整个大页面滚动 */
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2"><i class="bi bi-grid-3x3 me-2 text-primary"></i>{{ _('Plater / Build Plate') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row" style="height: calc(100vh - 140px);">
|
||||
|
||||
|
||||
<!-- 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; position: relative;"></div>
|
||||
|
||||
<!-- Parameterized Scale Input Box -->
|
||||
<div id="scale-panel" class="position-absolute top-50 start-0 translate-middle-y ms-5 ps-4 d-none" style="z-index: 10; pointer-events: none;">
|
||||
<div class="bg-white rounded shadow-sm p-3 opacity-90 border border-secondary" style="width: 170px; pointer-events: auto;">
|
||||
<h6 class="fs-6 mb-2 text-primary"><i class="bi bi-arrows-angle-expand me-1"></i>{{ _('Scale') }}</h6>
|
||||
<div class="input-group input-group-sm mb-1">
|
||||
<span class="input-group-text bg-danger text-white border-danger fw-bold opacity-75" style="width: 32px;">X</span>
|
||||
<input type="number" class="form-control" id="scale-x" value="1.0" step="0.1" onchange="applyScaleInput('x')">
|
||||
</div>
|
||||
<div class="input-group input-group-sm mb-1">
|
||||
<span class="input-group-text bg-success text-white border-success fw-bold opacity-75" style="width: 32px;">Y</span>
|
||||
<input type="number" class="form-control" id="scale-y" value="1.0" step="0.1" onchange="applyScaleInput('y')">
|
||||
</div>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<span class="input-group-text bg-primary text-white border-primary fw-bold opacity-75" style="width: 32px;">Z</span>
|
||||
<input type="number" class="form-control" id="scale-z" value="1.0" step="0.1" onchange="applyScaleInput('z')">
|
||||
</div>
|
||||
<div class="form-check form-switch small mb-0 mt-1">
|
||||
<input class="form-check-input" type="checkbox" id="scale-uniform" checked>
|
||||
<label class="form-check-label user-select-none" for="scale-uniform">{{ _('Uniform Scale') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<!-- 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 }}')">
|
||||
<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 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" 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>
|
||||
<input type="number" class="form-control form-control-sm" id="infill-density" value="{{ default_infill }}" 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" {% if default_support == 'false' %}selected{% endif %}>{{ _('None') }}</option>
|
||||
<option value="buildplate" {% if default_support == 'buildplate' %}selected{% endif %}>{{ _('Touching Buildplate') }}</option>
|
||||
<option value="true" {% if default_support == 'true' %}selected{% endif %}>{{ _('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" {% if default_support == 'false' %}disabled{% endif %}>
|
||||
<option value="tree" {% if default_support_pattern == 'tree' %}selected{% endif %}>{{ _('Tree') }}</option>
|
||||
<option value="lines" {% if default_support_pattern == 'lines' %}selected{% endif %}>{{ _('Lines') }}</option>
|
||||
<option value="grid" {% if default_support_pattern == 'grid' %}selected{% endif %}>{{ _('Grid') }}</option>
|
||||
<option value="triangles" {% if default_support_pattern == 'triangles' %}selected{% endif %}>{{ _('Triangles') }}</option>
|
||||
<option value="concentric" {% if default_support_pattern == 'concentric' %}selected{% endif %}>{{ _('Concentric') }}</option>
|
||||
<option value="zigzag" {% if default_support_pattern == 'zigzag' %}selected{% endif %}>{{ _('Zig Zag') }}</option>
|
||||
<option value="cross" {% if default_support_pattern == 'cross' %}selected{% endif %}>{{ _('Cross') }}</option>
|
||||
<option value="gyroid" {% if default_support_pattern == 'gyroid' %}selected{% endif %}>{{ _('Gyroid') }}</option>
|
||||
<option value="honeycomb" {% if default_support_pattern == 'honeycomb' %}selected{% endif %}>{{ _('Honeycomb') }}</option>
|
||||
<option value="octagon" {% if default_support_pattern == 'octagon' %}selected{% endif %}>{{ _('Octagon') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm flex-shrink-0 mb-3">
|
||||
<div class="card-header bg-light fw-bold text-secondary d-flex justify-content-between align-items-center collapsed" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#collapseQuality" aria-expanded="false">
|
||||
<span><i class="bi bi-gear-wide-connected me-2"></i>{{ _('Quality Profile') }}</span>
|
||||
<i class="bi bi-chevron-bar-contract"></i>
|
||||
</div>
|
||||
<div id="collapseQuality" class="collapse" data-bs-parent="#platerSidebarAccordion">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<select class="form-select bg-light" id="quality">
|
||||
{% for key, name in presets %}
|
||||
<option value="{{ key }}" {% if key == last_quality %}selected{% endif %}>{{ _(name) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- End of accordion wrapper -->
|
||||
|
||||
<div class="mt-auto pt-3 border-top d-flex flex-column gap-2 mb-1">
|
||||
<button class="btn btn-outline-danger w-100" onclick="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>
|
||||
|
||||
<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;
|
||||
let selectedModels = [];
|
||||
let selectionBoxDiv = document.createElement('div');
|
||||
selectionBoxDiv.id = 'selection-box';
|
||||
selectionBoxDiv.style.cssText = 'position: absolute; border: 1px dashed #007bff; background: rgba(0, 123, 255, 0.1); pointer-events: none; display: none; z-index: 100;';
|
||||
document.getElementById('plater-container').appendChild(selectionBoxDiv);
|
||||
let dragStartPoint = null;
|
||||
let isDraggingBox = false;
|
||||
const initialAddId = new URLSearchParams(window.location.search).get('add');
|
||||
|
||||
initPlater();
|
||||
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 = false;
|
||||
orbit.mouseButtons.MIDDLE = THREE.MOUSE.PAN;
|
||||
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('change', function () {
|
||||
if (transformControl.getMode() === 'scale' && !document.getElementById('scale-panel').classList.contains('d-none')) {
|
||||
updateScalePanel();
|
||||
}
|
||||
});
|
||||
transformControl.addEventListener('dragging-changed', function (event) {
|
||||
orbit.enabled = !event.value;
|
||||
if (!event.value && selectedModels.length > 0) {
|
||||
let center = new THREE.Vector3();
|
||||
selectedModels.forEach(m => {
|
||||
scene.attach(m);
|
||||
m.geometry.computeBoundingBox();
|
||||
let box = m.geometry.boundingBox.clone();
|
||||
box.applyMatrix4(m.matrixWorld);
|
||||
center.add(box.getCenter(new THREE.Vector3()));
|
||||
});
|
||||
center.divideScalar(selectedModels.length);
|
||||
|
||||
transformProxy.position.copy(center);
|
||||
transformProxy.rotation.set(0, 0, 0);
|
||||
transformProxy.scale.set(1, 1, 1);
|
||||
|
||||
selectedModels.forEach(m => {
|
||||
transformProxy.attach(m);
|
||||
});
|
||||
}
|
||||
});
|
||||
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';
|
||||
|
||||
if (mode === 'scale' && selectedModels && selectedModels.length > 0) {
|
||||
document.getElementById('scale-panel').classList.remove('d-none');
|
||||
updateScalePanel();
|
||||
} else {
|
||||
document.getElementById('scale-panel').classList.add('d-none');
|
||||
}
|
||||
} 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();
|
||||
document.getElementById('scale-panel').classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function updateScalePanel() {
|
||||
if (selectedModels.length === 0) return;
|
||||
|
||||
// Check if scales match
|
||||
let firstScale = selectedModels[0].getWorldScale(new THREE.Vector3());
|
||||
let allXMatch = true, allYMatch = true, allZMatch = true;
|
||||
|
||||
for (let i = 1; i < selectedModels.length; i++) {
|
||||
let v = selectedModels[i].getWorldScale(new THREE.Vector3());
|
||||
if (Math.abs(v.x - firstScale.x) > 0.001) allXMatch = false;
|
||||
if (Math.abs(v.y - firstScale.y) > 0.001) allYMatch = false;
|
||||
if (Math.abs(v.z - firstScale.z) > 0.001) allZMatch = false;
|
||||
}
|
||||
|
||||
document.getElementById('scale-x').value = allXMatch ? firstScale.x.toFixed(3) : '';
|
||||
document.getElementById('scale-y').value = allYMatch ? firstScale.y.toFixed(3) : '';
|
||||
document.getElementById('scale-z').value = allZMatch ? firstScale.z.toFixed(3) : '';
|
||||
}
|
||||
|
||||
function applyScaleInput(axis) {
|
||||
if (selectedModels.length === 0) return;
|
||||
let valStr = document.getElementById('scale-' + axis).value;
|
||||
if (valStr === '') return;
|
||||
let val = parseFloat(valStr);
|
||||
if (isNaN(val) || val <= 0.001) val = 1.0;
|
||||
|
||||
const isUniform = document.getElementById('scale-uniform').checked;
|
||||
|
||||
selectedModels.forEach(m => {
|
||||
scene.attach(m);
|
||||
if (isUniform) {
|
||||
const prev = m.scale[axis];
|
||||
const ratio = val / prev;
|
||||
m.scale.x *= ratio;
|
||||
m.scale.y *= ratio;
|
||||
m.scale.z *= ratio;
|
||||
} else {
|
||||
m.scale[axis] = val;
|
||||
}
|
||||
m.updateMatrixWorld(true);
|
||||
});
|
||||
|
||||
transformProxy.scale.set(1, 1, 1);
|
||||
selectedModels.forEach(m => {
|
||||
transformProxy.attach(m);
|
||||
});
|
||||
|
||||
updateScalePanel();
|
||||
}
|
||||
|
||||
|
||||
|
||||
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;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
dragStartPoint = { x: event.clientX, y: event.clientY };
|
||||
isDraggingBox = false;
|
||||
orbit.enabled = false;
|
||||
document.addEventListener('pointermove', onPointerMoveBox);
|
||||
document.addEventListener('pointerup', onPointerUpBox);
|
||||
}
|
||||
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const intersects = raycaster.intersectObjects(loadedModels, true);
|
||||
|
||||
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');
|
||||
selectModels([obj]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (intersects.length > 0) {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
toggleModelSelection(intersects[0].object);
|
||||
} else {
|
||||
selectModels([intersects[0].object]);
|
||||
}
|
||||
} else {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
selectModels([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMoveBox(event) {
|
||||
if (!dragStartPoint) return;
|
||||
const dx = Math.abs(event.clientX - dragStartPoint.x);
|
||||
const dy = Math.abs(event.clientY - dragStartPoint.y);
|
||||
if (dx > 5 || dy > 5) {
|
||||
isDraggingBox = true;
|
||||
|
||||
const container = document.getElementById('plater-container');
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
const startX = dragStartPoint.x - rect.left;
|
||||
const startY = dragStartPoint.y - rect.top;
|
||||
const currentX = event.clientX - rect.left;
|
||||
const currentY = event.clientY - rect.top;
|
||||
|
||||
selectionBoxDiv.style.display = 'block';
|
||||
selectionBoxDiv.style.left = Math.min(currentX, startX) + 'px';
|
||||
selectionBoxDiv.style.top = Math.min(currentY, startY) + 'px';
|
||||
selectionBoxDiv.style.width = Math.abs(currentX - startX) + 'px';
|
||||
selectionBoxDiv.style.height = Math.abs(currentY - startY) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUpBox(event) {
|
||||
document.removeEventListener('pointermove', onPointerMoveBox);
|
||||
document.removeEventListener('pointerup', onPointerUpBox);
|
||||
orbit.enabled = true;
|
||||
|
||||
if (isDraggingBox) {
|
||||
selectionBoxDiv.style.display = 'none';
|
||||
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const minX = Math.min(dragStartPoint.x, event.clientX) - rect.left;
|
||||
const maxX = Math.max(dragStartPoint.x, event.clientX) - rect.left;
|
||||
const minY = Math.min(dragStartPoint.y, event.clientY) - rect.top;
|
||||
const maxY = Math.max(dragStartPoint.y, event.clientY) - rect.top;
|
||||
|
||||
let newSelection = [...selectedModels];
|
||||
|
||||
loadedModels.forEach(m => {
|
||||
m.geometry.computeBoundingBox();
|
||||
let box = m.geometry.boundingBox.clone();
|
||||
box.applyMatrix4(m.matrixWorld);
|
||||
|
||||
// Project 8 corners
|
||||
const corners = [
|
||||
new THREE.Vector3(box.min.x, box.min.y, box.min.z),
|
||||
new THREE.Vector3(box.max.x, box.min.y, box.min.z),
|
||||
new THREE.Vector3(box.min.x, box.max.y, box.min.z),
|
||||
new THREE.Vector3(box.max.x, box.max.y, box.min.z),
|
||||
new THREE.Vector3(box.min.x, box.min.y, box.max.z),
|
||||
new THREE.Vector3(box.max.x, box.min.y, box.max.z),
|
||||
new THREE.Vector3(box.min.x, box.max.y, box.max.z),
|
||||
new THREE.Vector3(box.max.x, box.max.y, box.max.z)
|
||||
];
|
||||
|
||||
let inside = false;
|
||||
corners.forEach(v => {
|
||||
v.project(camera);
|
||||
let sx = (v.x * .5 + .5) * rect.width;
|
||||
let sy = (v.y * -.5 + .5) * rect.height;
|
||||
if (sx >= minX && sx <= maxX && sy >= minY && sy <= maxY) {
|
||||
inside = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (inside && !newSelection.includes(m)) {
|
||||
newSelection.push(m);
|
||||
}
|
||||
});
|
||||
selectModels(newSelection);
|
||||
} else if (dragStartPoint && !isDraggingBox) {
|
||||
// Just a ctrl+click that missed logic is handled by raycaster above, but we have to ensure no double toggle
|
||||
}
|
||||
dragStartPoint = null;
|
||||
isDraggingBox = false;
|
||||
}
|
||||
|
||||
|
||||
function toggleModelSelection(model) {
|
||||
let newSel = [...selectedModels];
|
||||
if (newSel.includes(model)) {
|
||||
newSel = newSel.filter(m => m !== model);
|
||||
} else {
|
||||
newSel.push(model);
|
||||
}
|
||||
selectModels(newSel);
|
||||
}
|
||||
|
||||
function selectModels(models) {
|
||||
selectedModels.forEach(m => {
|
||||
scene.attach(m);
|
||||
m.material.color.setHex(0xcccccc);
|
||||
});
|
||||
|
||||
selectedModels = models;
|
||||
activeModel = selectedModels.length > 0 ? selectedModels[selectedModels.length - 1] : null;
|
||||
|
||||
if (selectedModels.length > 0) {
|
||||
// compute joint center
|
||||
let center = new THREE.Vector3();
|
||||
let count = 0;
|
||||
selectedModels.forEach(m => {
|
||||
m.material.color.setHex(0x0d6efd);
|
||||
scene.attach(m); // ensure in world
|
||||
m.geometry.computeBoundingBox();
|
||||
let box = m.geometry.boundingBox.clone();
|
||||
box.applyMatrix4(m.matrixWorld);
|
||||
center.add(box.getCenter(new THREE.Vector3()));
|
||||
count++;
|
||||
});
|
||||
center.divideScalar(count);
|
||||
|
||||
transformProxy.position.copy(center);
|
||||
transformProxy.rotation.set(0, 0, 0);
|
||||
transformProxy.scale.set(1, 1, 1);
|
||||
|
||||
selectedModels.forEach(m => {
|
||||
transformProxy.attach(m);
|
||||
});
|
||||
|
||||
transformControl.attach(transformProxy);
|
||||
if(transformControl.getMode() === 'scale') {
|
||||
document.getElementById('scale-panel').classList.remove('d-none');
|
||||
updateScalePanel();
|
||||
}
|
||||
} else {
|
||||
document.getElementById('scale-panel').classList.add('d-none');
|
||||
transformControl.detach();
|
||||
}
|
||||
}
|
||||
|
||||
// Keep a backward compatible selectModel definition for single cases
|
||||
function selectModel(model) {
|
||||
if (model) {
|
||||
selectModels([model]);
|
||||
} else {
|
||||
selectModels([]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function removeModel(model) {
|
||||
if (selectedModels.includes(model)) {
|
||||
selectedModels = selectedModels.filter(m => m !== model);
|
||||
if (selectedModels.length === 0) transformControl.detach();
|
||||
}
|
||||
scene.attach(model);
|
||||
scene.remove(model);
|
||||
loadedModels = loadedModels.filter(m => m !== model);
|
||||
activeModel = selectedModels.length > 0 ? selectedModels[0] : null;
|
||||
}
|
||||
|
||||
function removeActiveModel() {
|
||||
if (selectedModels.length > 0) {
|
||||
[...selectedModels].forEach(m => removeModel(m));
|
||||
selectModels([]);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPlate() {
|
||||
transformControl.detach();
|
||||
loadedModels.forEach(m => {
|
||||
scene.attach(m);
|
||||
scene.remove(m);
|
||||
});
|
||||
loadedModels = [];
|
||||
selectedModels = [];
|
||||
activeModel = null;
|
||||
}
|
||||
|
||||
function addModelToPlate(btnElement, fileId, url, name, status) {
|
||||
let matrixData = btnElement ? btnElement.getAttribute('data-matrix') : null;
|
||||
|
||||
if (matrixData) {
|
||||
try {
|
||||
let data = JSON.parse(matrixData);
|
||||
if (data.settings) {
|
||||
if (data.settings.infill) document.getElementById('infill-density').value = data.settings.infill;
|
||||
if (data.settings.support) {
|
||||
let supportSelect = document.getElementById('support-type');
|
||||
supportSelect.value = data.settings.support;
|
||||
supportSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
if (data.settings.support_pattern) document.getElementById('support-pattern').value = data.settings.support_pattern;
|
||||
if (data.settings.quality) document.getElementById('quality').value = data.settings.quality;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (matrixData && matrixData.includes('"is_composite"')) {
|
||||
try {
|
||||
let comp = JSON.parse(matrixData);
|
||||
if (comp.is_composite && comp.parts) {
|
||||
if (btnElement) {
|
||||
const iconSpan = btnElement.querySelector('i');
|
||||
const originalClass = iconSpan.className;
|
||||
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
|
||||
btnElement.disabled = true;
|
||||
|
||||
let totalParts = comp.parts.length;
|
||||
let loadedCount = 0;
|
||||
|
||||
comp.parts.forEach(part => {
|
||||
loadSTL(part.file_id, part.url, part.name, 'uploaded', JSON.stringify(part.raw_matrix), () => {
|
||||
loadedCount++;
|
||||
if (loadedCount === totalParts) {
|
||||
iconSpan.className = originalClass;
|
||||
btnElement.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
comp.parts.forEach(part => {
|
||||
loadSTL(part.file_id, part.url, part.name, 'uploaded', JSON.stringify(part.raw_matrix));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (btnElement) {
|
||||
const iconSpan = btnElement.querySelector('i');
|
||||
const originalClass = iconSpan.className;
|
||||
iconSpan.className = 'spinner-border spinner-border-sm text-primary';
|
||||
btnElement.disabled = true;
|
||||
loadSTL(fileId, url, name, status, matrixData, () => {
|
||||
iconSpan.className = originalClass;
|
||||
btnElement.disabled = false;
|
||||
});
|
||||
} else {
|
||||
loadSTL(fileId, url, name, status, matrixData);
|
||||
}
|
||||
}
|
||||
|
||||
function loadSTL(fileId, url, name, status, matrixData, callback) {
|
||||
const loader = new THREE.STLLoader();
|
||||
loader.load(url, function (geometry) {
|
||||
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: 0xcccccc, 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)
|
||||
};
|
||||
|
||||
if (matrixData && matrixData.trim() !== '' && matrixData !== 'None') {
|
||||
try {
|
||||
let mArray = JSON.parse(matrixData);
|
||||
// Skip if it actually is a composite (handled by addModelToPlate)
|
||||
if (mArray && mArray.is_composite === true) return;
|
||||
|
||||
if (mArray && !Array.isArray(mArray) && mArray.matrix) {
|
||||
mArray = mArray.matrix;
|
||||
}
|
||||
let savedMatrix = new THREE.Matrix4().fromArray(mArray);
|
||||
savedMatrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
|
||||
} catch (e) {
|
||||
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 {
|
||||
mesh.position.x = (Math.random() - 0.5) * 50;
|
||||
mesh.position.y = (Math.random() - 0.5) * 50;
|
||||
}
|
||||
|
||||
scene.add(mesh);
|
||||
loadedModels.push(mesh);
|
||||
selectModel(mesh);
|
||||
|
||||
if (callback) callback();
|
||||
}, undefined, function (error) {
|
||||
console.error(error);
|
||||
if (callback) callback();
|
||||
window.customAlert("{{ _('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() {
|
||||
selectModels([]); // Detach any active model to bake transformProxy world coordinates into its local matrix properties
|
||||
|
||||
if (loadedModels.length === 0) {
|
||||
window.customAlert("{{ _('Please add at least one model to the build plate.') }}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkBounds()) {
|
||||
window.customAlert("{{ _('One or more models are outside the print area. Please adjust them before slicing.') }}");
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
// Just checking if we want to warn
|
||||
if (loadedModels.length === 1 && loadedModels[0].userData.status === 'sliced') {
|
||||
window.customConfirm("{{ _('This model has already been sliced. The existing GCode will be overwritten. Continue?') }}", doMergeAndSlice);
|
||||
return;
|
||||
} else if (window.isCompositeEdit) {
|
||||
window.customConfirm("{{ _('You are editing a composite model. The existing composite will be updated and re-sliced. Continue?') }}", doMergeAndSlice);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
doMergeAndSlice();
|
||||
|
||||
function doMergeAndSlice() {
|
||||
const pieces = loadedModels.map(m => {
|
||||
m.updateMatrixWorld(true);
|
||||
const mat = m.matrixWorld.clone();
|
||||
if (m.userData.geomTrans) {
|
||||
mat.multiply(m.userData.geomTrans);
|
||||
}
|
||||
const translation = new THREE.Matrix4().makeTranslation((bedWidth / 2) + offsetX, (bedDepth / 2) + offsetY, 0);
|
||||
mat.premultiply(translation);
|
||||
return {
|
||||
file_id: m.userData.fileId,
|
||||
matrix: mat.elements, // Array of 16 numbers used for slicing
|
||||
raw_matrix: m.matrixWorld.elements // Use world matrix explicitly just in case
|
||||
};
|
||||
});
|
||||
|
||||
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, is_edit: isEdit, target_file_id: targetFileId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if(data.success) {
|
||||
window.location.href = "{{ url_for('main.files') }}";
|
||||
} else {
|
||||
window.customAlert("{{ _('Error:') }} " + data.error);
|
||||
btn.disabled = false;
|
||||
icon.className = 'bi bi-gear-fill me-2';
|
||||
text.innerText = '{{ _("Merge & Slice") }}';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
window.customAlert("{{ _('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) {
|
||||
let matrixData = btn.getAttribute('data-matrix');
|
||||
if (matrixData) {
|
||||
try {
|
||||
let d = JSON.parse(matrixData);
|
||||
if (d && d.is_composite === true) {
|
||||
window.isCompositeEdit = true;
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
btn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user