Files
AIO_3D_Print_Web_Platform/app/templates/slice/plater.html

952 lines
41 KiB
HTML

{% 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() {
{% if quota_exceeded %}
window.customConfirm("{{ _('GCode Storage Quota Exceeded. Please delete some files first.') }}",()=>{window.location.href = "{{ url_for('main.files') }}"});
return;
{% endif %}
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(offsetX,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 {
let errorMsg = data.error;
if (errorMsg === 'GCode Storage Quota Exceeded. Please delete some files first.') {
errorMsg = "{{ _('GCode Storage Quota Exceeded. Please delete some files first.') }}";
}
window.customAlert("{{ _('Error:') }} " + errorMsg);
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 %}