3265 lines
111 KiB
PHP
3265 lines
111 KiB
PHP
<?php include('include/headscript.php'); ?>
|
||
<?php
|
||
$db = DBHandlerSelect::getInstance();
|
||
$pdo = $db->getConnection();
|
||
|
||
$iduser = $iduserlogin ?? null;
|
||
|
||
if ($iduser === null || $iduser === '') {
|
||
$stmt = $pdo->prepare("
|
||
SELECT *
|
||
FROM cad_area_jobs
|
||
ORDER BY id DESC
|
||
");
|
||
$stmt->execute();
|
||
} else {
|
||
$stmt = $pdo->prepare("
|
||
SELECT *
|
||
FROM cad_area_jobs
|
||
WHERE iduser = :iduser
|
||
ORDER BY id DESC
|
||
");
|
||
$stmt->execute([
|
||
':iduser' => $iduser
|
||
]);
|
||
}
|
||
|
||
$jobs = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||
?>
|
||
<!doctype html>
|
||
<html lang="it">
|
||
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<link rel="icon" href="assets/images/favicon-32x32.png" type="image/png" />
|
||
<?php include('cssinclude.php'); ?>
|
||
|
||
<title>Calcolo Area CAD - <?= htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?></title>
|
||
|
||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||
|
||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/dataTables.bootstrap5.min.css">
|
||
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
|
||
<script src="https://cdn.datatables.net/1.13.6/js/dataTables.bootstrap5.min.js"></script>
|
||
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||
<script>
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||
</script>
|
||
|
||
<style>
|
||
body {
|
||
font-size: 1.05rem;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.card {
|
||
border-radius: 16px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.back-dashboard {
|
||
background-color: #cfe3ff !important;
|
||
color: #1f2d3d !important;
|
||
border: 1px solid #bcd4f4 !important;
|
||
border-radius: 10px;
|
||
font-weight: 600;
|
||
font-size: 1rem;
|
||
padding: 10px 18px;
|
||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1);
|
||
transition: all 0.2s ease-in-out;
|
||
}
|
||
|
||
.back-dashboard:hover {
|
||
background-color: #b9d3ff !important;
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.btn-add {
|
||
background-color: #0d6efd;
|
||
color: #fff;
|
||
border-radius: 8px;
|
||
padding: 10px 20px;
|
||
font-weight: 500;
|
||
transition: all 0.2s ease-in-out;
|
||
}
|
||
|
||
.btn-add:hover {
|
||
background-color: #0b5ed7;
|
||
color: #fff;
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
.table thead {
|
||
background-color: #cfe3ff;
|
||
color: #1f2d3d;
|
||
}
|
||
|
||
.drop-zone {
|
||
border: 2px dashed #9fc5f8;
|
||
background: #f4f8ff;
|
||
border-radius: 16px;
|
||
padding: 38px 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease-in-out;
|
||
}
|
||
|
||
.drop-zone:hover,
|
||
.drop-zone.dragover {
|
||
background: #e8f2ff;
|
||
border-color: #0d6efd;
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.drop-zone-icon {
|
||
font-size: 3rem;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.file-preview {
|
||
border: 1px solid #dbeafe;
|
||
background: #ffffff;
|
||
border-radius: 12px;
|
||
padding: 10px 14px;
|
||
margin-bottom: 8px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.file-preview small {
|
||
color: #64748b;
|
||
}
|
||
|
||
.status-badge {
|
||
font-size: 0.85rem;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
#cadAreaTable {
|
||
table-layout: fixed;
|
||
width: 100% !important;
|
||
}
|
||
|
||
#cadAreaTable th,
|
||
#cadAreaTable td {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
#cadAreaTable th:nth-child(1),
|
||
#cadAreaTable td:nth-child(1) {
|
||
width: 55px;
|
||
}
|
||
|
||
#cadAreaTable th:nth-child(2),
|
||
#cadAreaTable td:nth-child(2) {
|
||
width: auto;
|
||
}
|
||
|
||
#cadAreaTable th:nth-child(3),
|
||
#cadAreaTable td:nth-child(3) {
|
||
width: 130px;
|
||
}
|
||
|
||
#cadAreaTable th:nth-child(4),
|
||
#cadAreaTable td:nth-child(4),
|
||
#cadAreaTable th:nth-child(5),
|
||
#cadAreaTable td:nth-child(5) {
|
||
width: 120px;
|
||
}
|
||
|
||
#cadAreaTable th:nth-child(6),
|
||
#cadAreaTable td:nth-child(6) {
|
||
width: 190px;
|
||
}
|
||
|
||
.processing-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(15, 23, 42, 0.55);
|
||
z-index: 9999;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.processing-box {
|
||
background: white;
|
||
border-radius: 18px;
|
||
padding: 30px;
|
||
min-width: 320px;
|
||
text-align: center;
|
||
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
.manual-modal-body {
|
||
background: #f8fafc;
|
||
overflow: auto;
|
||
max-height: 78vh;
|
||
text-align: center;
|
||
}
|
||
|
||
.pdf-manual-wrapper {
|
||
position: relative;
|
||
display: inline-block;
|
||
border: 1px solid #cbd5e1;
|
||
background: white;
|
||
}
|
||
|
||
#manualPdfCanvas {
|
||
display: block;
|
||
}
|
||
|
||
#manualOverlayCanvas {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
cursor: crosshair;
|
||
}
|
||
|
||
.manual-info-box {
|
||
background: #eef6ff;
|
||
border: 1px solid #bfdbfe;
|
||
border-radius: 12px;
|
||
padding: 10px 14px;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.manual-toolbar {
|
||
display: flex;
|
||
gap: 6px 14px;
|
||
justify-content: center;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.toolbar-group {
|
||
display: flex;
|
||
gap: 5px;
|
||
align-items: center;
|
||
padding-right: 14px;
|
||
border-right: 1px solid #cbd5e1;
|
||
}
|
||
|
||
.toolbar-group:last-child {
|
||
border-right: none;
|
||
padding-right: 0;
|
||
}
|
||
|
||
.manual-view-badge {
|
||
padding: 5px 10px;
|
||
font-size: 0.82rem;
|
||
}
|
||
|
||
.manual-toolbar .btn.active {
|
||
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.2);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.manual-result-box {
|
||
background: #f0fdf4;
|
||
border: 1px solid #bbf7d0;
|
||
border-radius: 12px;
|
||
padding: 10px 14px;
|
||
font-size: 0.95rem;
|
||
text-align: left;
|
||
}
|
||
|
||
.action-buttons {
|
||
display: flex;
|
||
gap: 6px;
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.manual-view-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: #fff7ed;
|
||
border: 1px solid #fed7aa;
|
||
color: #9a3412;
|
||
border-radius: 999px;
|
||
padding: 7px 12px;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.manual-edit-help {
|
||
background: #f8fafc;
|
||
border: 1px dashed #cbd5e1;
|
||
color: #475569;
|
||
border-radius: 10px;
|
||
padding: 8px 12px;
|
||
font-size: 0.9rem;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="wrapper" id="appWrapper">
|
||
<?php include('include/navbar.php'); ?>
|
||
<?php include('include/topbar.php'); ?>
|
||
|
||
<div class="page-wrapper">
|
||
<div class="page-content">
|
||
|
||
<div class="card p-3 mb-4">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<h5 class="mb-0">Calcolo Area 2D da PDF CAD</h5>
|
||
<small class="text-muted">
|
||
Modalità affidabile: calibrazione quota + tracciamento manuale del profilo.
|
||
</small>
|
||
</div>
|
||
|
||
<button type="button" class="btn back-dashboard" onclick="location.href='production_dashboard.php'">
|
||
↩️ Torna alla Dashboard
|
||
</button>
|
||
</div>
|
||
|
||
<div class="card-body">
|
||
<div id="dropZone" class="drop-zone">
|
||
<div class="drop-zone-icon">📐</div>
|
||
<h5 class="mb-1">Trascina qui uno o più PDF CAD</h5>
|
||
<p class="text-muted mb-2">Oppure clicca per selezionare i file</p>
|
||
<small class="text-muted">Formati accettati: PDF - massimo 25 MB per file</small>
|
||
<input type="file" id="pdfFiles" accept="application/pdf,.pdf" multiple hidden>
|
||
</div>
|
||
|
||
<div id="selectedFiles" class="mt-3"></div>
|
||
|
||
<div class="text-end mt-3">
|
||
<button id="uploadBtn" class="btn btn-add" disabled>
|
||
⬆️ Carica PDF
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card p-3">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<h5 class="mb-0">Risultati</h5>
|
||
<small class="text-muted">
|
||
Per PDF scansionati/sporchi usa “Traccia Manuale”.
|
||
</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table id="cadAreaTable" class="table table-striped align-middle text-center" style="width:100%;">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>Nome PDF</th>
|
||
<th>Stato</th>
|
||
<th>Area mm²</th>
|
||
<th>Area cm²</th>
|
||
<th>Azioni</th>
|
||
</thead>
|
||
|
||
<tbody>
|
||
<?php foreach ($jobs as $job): ?>
|
||
<?php
|
||
$status = $job['status'] ?? 'uploaded';
|
||
|
||
if ($status === 'completed') {
|
||
$badge = "<span class='badge bg-success status-badge'>Completato</span>";
|
||
} elseif ($status === 'processing') {
|
||
$badge = "<span class='badge bg-warning text-dark status-badge'>In lavorazione</span>";
|
||
} elseif ($status === 'error') {
|
||
$badge = "<span class='badge bg-danger status-badge'>Errore</span>";
|
||
} else {
|
||
$badge = "<span class='badge bg-secondary status-badge'>Caricato</span>";
|
||
}
|
||
|
||
$areaMm2 = $job['area_mm2'] !== null ? number_format((float)$job['area_mm2'], 3, ',', '.') : '-';
|
||
$areaCm2 = $job['area_cm2'] !== null ? number_format((float)$job['area_cm2'], 4, ',', '.') : '-';
|
||
$fileUrl = htmlspecialchars($job['file_url'] ?? '', ENT_QUOTES, 'UTF-8');
|
||
$dxfUrl = htmlspecialchars($job['dxf_url'] ?? '', ENT_QUOTES, 'UTF-8');
|
||
?>
|
||
<tr data-id="<?= (int)$job['id']; ?>">
|
||
<td><?= (int)$job['id']; ?></td>
|
||
|
||
<td title="<?= htmlspecialchars($job['original_filename'], ENT_QUOTES, 'UTF-8'); ?>">
|
||
<?= htmlspecialchars($job['original_filename'], ENT_QUOTES, 'UTF-8'); ?>
|
||
</td>
|
||
|
||
<td><?= $badge; ?></td>
|
||
<td class="fw-semibold"><?= $areaMm2; ?></td>
|
||
<td class="fw-semibold"><?= $areaCm2; ?></td>
|
||
|
||
<td>
|
||
<div class="action-buttons">
|
||
<?php if (!empty($job['file_url'])): ?>
|
||
<a href="<?= $fileUrl; ?>" target="_blank" class="btn btn-sm btn-outline-dark" title="Apri PDF">
|
||
📄
|
||
</a>
|
||
|
||
<button
|
||
class="btn btn-sm btn-outline-success manual-trace"
|
||
data-id="<?= (int)$job['id']; ?>"
|
||
data-url="<?= $fileUrl; ?>"
|
||
title="Traccia Manuale">
|
||
✏️
|
||
</button>
|
||
<?php endif; ?>
|
||
|
||
<?php if (!empty($job['dxf_url'])): ?>
|
||
<a href="<?= $dxfUrl; ?>" download class="btn btn-sm btn-outline-primary">
|
||
📐 DXF
|
||
</a>
|
||
<?php endif; ?>
|
||
|
||
<button class="btn btn-sm btn-outline-danger delete-one" data-id="<?= (int)$job['id']; ?>">
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<?php include('include/footer.php'); ?>
|
||
</div>
|
||
|
||
<div class="processing-overlay" id="processingOverlay">
|
||
<div class="processing-box">
|
||
<div class="spinner-border text-primary mb-3" role="status"></div>
|
||
<h5 class="mb-1">Operazione in corso</h5>
|
||
<p class="text-muted mb-0">Attendere...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal fade" id="manualTraceModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-fullscreen modal-dialog-centered">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<div>
|
||
<h5 class="modal-title mb-0">Tracciamento Manuale Calibrato</h5>
|
||
<small class="text-muted">
|
||
1) ROI sezione → 2) calibra quota → 3) traccia profilo → 4) calcola e salva area 2D
|
||
</small>
|
||
</div>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
|
||
</div>
|
||
|
||
<div class="modal-body manual-modal-body">
|
||
<div class="manual-info-box mb-3 text-start">
|
||
<strong>Procedura:</strong>
|
||
seleziona la zona della sezione, calibra usando una quota reale nota, poi clicca i punti del contorno del profilo.
|
||
</div>
|
||
|
||
<div class="manual-toolbar mb-3">
|
||
<div class="toolbar-group">
|
||
<button type="button" id="toolRoiBtn" class="btn btn-sm btn-outline-primary active" title="Disegna un rettangolo attorno alla sezione">
|
||
🎯 ROI
|
||
</button>
|
||
|
||
<button type="button" id="zoomRoiBtn" class="btn btn-sm btn-outline-info" title="Ingrandisci la zona selezionata">
|
||
🔍 Zoom
|
||
</button>
|
||
|
||
<button type="button" id="fullPageBtn" class="btn btn-sm btn-outline-dark" disabled title="Torna alla pagina intera">
|
||
↩️ Pagina
|
||
</button>
|
||
|
||
<button type="button" id="toolCalibrationBtn" class="btn btn-sm btn-outline-danger" title="Clicca due punti su una quota nota">
|
||
📏 Calibra
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-group">
|
||
<button type="button" id="toolPolygonBtn" class="btn btn-sm btn-outline-success" title="Clicca i punti del contorno esterno">
|
||
✏️ Profilo
|
||
</button>
|
||
|
||
<button type="button" id="toolBrushBtn" class="btn btn-sm btn-outline-success" title="Trascina lungo il bordo per tracciare">
|
||
🖌️ Pennello
|
||
</button>
|
||
|
||
<button type="button" id="edgeSnapBtn" class="btn btn-sm btn-outline-warning" title="Aggancia i punti al bordo scuro più vicino">
|
||
🧲 OFF
|
||
</button>
|
||
|
||
<button type="button" id="toolHoleBtn" class="btn btn-sm btn-outline-danger" title="Traccia un foro/cavità da sottrarre">
|
||
➖ Esclusione
|
||
</button>
|
||
|
||
<button type="button" id="closeHoleBtn" class="btn btn-sm btn-outline-danger" title="Chiudi l'esclusione corrente">
|
||
✅ Chiudi
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-group">
|
||
<button type="button" id="toolEditBtn" class="btn btn-sm btn-outline-secondary" title="Sposta, elimina o inserisci punti">
|
||
✋ Modifica
|
||
</button>
|
||
|
||
<button type="button" id="undoPointBtn" class="btn btn-sm btn-outline-secondary" title="Rimuovi l'ultimo punto inserito">
|
||
↩️ Annulla
|
||
</button>
|
||
|
||
<button type="button" id="deleteSelectedPointBtn" class="btn btn-sm btn-outline-danger" title="Elimina il punto selezionato">
|
||
❌ Elimina
|
||
</button>
|
||
|
||
<button type="button" id="resetManualBtn" class="btn btn-sm btn-outline-warning" title="Cancella tutto e ricomincia">
|
||
🔄 Reset
|
||
</button>
|
||
</div>
|
||
|
||
<div class="toolbar-group">
|
||
<span id="manualViewBadge" class="manual-view-badge">
|
||
Vista: pagina intera
|
||
</span>
|
||
|
||
<button type="button" id="calculateManualBtn" class="btn btn-sm btn-add">
|
||
🧮 Calcola area
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="manual-result-box mb-3" id="manualStatusBox">
|
||
Modalità attiva: ROI. Disegna un rettangolo attorno alla sezione.
|
||
</div>
|
||
|
||
<div class="pdf-manual-wrapper" id="manualPdfWrapper">
|
||
<canvas id="manualPdfCanvas"></canvas>
|
||
<canvas id="manualOverlayCanvas"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<div class="me-auto text-muted" id="manualCoordsPreview">
|
||
Nessun dato calcolato.
|
||
</div>
|
||
|
||
<button type="button" class="btn btn-outline-dark" data-bs-dismiss="modal">
|
||
Chiudi
|
||
</button>
|
||
|
||
<button type="button" id="exportDxfBtn" class="btn btn-outline-primary" disabled>
|
||
📐 Esporta DXF
|
||
</button>
|
||
|
||
<button type="button" id="saveManualAreaBtn" class="btn btn-add" disabled>
|
||
💾 Salva area
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php include('jsinclude.php'); ?>
|
||
|
||
<script>
|
||
let selectedFiles = [];
|
||
|
||
const dropZone = document.getElementById('dropZone');
|
||
const fileInput = document.getElementById('pdfFiles');
|
||
const selectedFilesBox = document.getElementById('selectedFiles');
|
||
const uploadBtn = document.getElementById('uploadBtn');
|
||
|
||
let manualModal = null;
|
||
let currentManualJobId = null;
|
||
let currentPdfUrl = null;
|
||
|
||
let manualPdfCanvas = null;
|
||
let manualOverlayCanvas = null;
|
||
let manualPdfCtx = null;
|
||
let manualOverlayCtx = null;
|
||
|
||
let manualPdfDoc = null;
|
||
let manualPdfPage = null;
|
||
let manualBaseScale = 1;
|
||
let manualCurrentView = 'full';
|
||
let manualRoiPageRect = null;
|
||
|
||
const MANUAL_FULL_MAX_WIDTH = 1400;
|
||
const MANUAL_ROI_ZOOM_FACTOR = 3.5;
|
||
|
||
let currentTool = 'roi';
|
||
|
||
let isDrawingRoi = false;
|
||
let roiStartX = 0;
|
||
let roiStartY = 0;
|
||
let roiRect = null;
|
||
|
||
let isDrawingAutoContourRoi = false;
|
||
let autoContourRoiStartX = 0;
|
||
let autoContourRoiStartY = 0;
|
||
let autoContourRoiRect = null;
|
||
|
||
let calibrationPoints = [];
|
||
let calibrationMm = null;
|
||
let calibrationPx = null;
|
||
let mmPerPx = null;
|
||
|
||
let polygonPoints = [];
|
||
let currentHolePoints = [];
|
||
let holes = [];
|
||
|
||
let selectedEditPoint = null;
|
||
let isDraggingEditPoint = false;
|
||
let editDragMoved = false;
|
||
|
||
const POINT_HIT_RADIUS = 12;
|
||
const SEGMENT_HIT_RADIUS = 12;
|
||
|
||
let edgeSnapEnabled = false;
|
||
let edgeSnapPreviewPoint = null;
|
||
|
||
const EDGE_SNAP_RADIUS = 50;
|
||
const EDGE_DARK_THRESHOLD = 200;
|
||
|
||
let edgeGray = null;
|
||
let edgeGrayW = 0;
|
||
let edgeGrayH = 0;
|
||
let snapRafPending = false;
|
||
let lastSnapMousePos = null;
|
||
let edgeCacheDirty = true;
|
||
|
||
let isBrushing = false;
|
||
let lastBrushPoint = null;
|
||
let brushStartIndex = 0;
|
||
|
||
const BRUSH_STEP = 10;
|
||
const BRUSH_SIMPLIFY_EPSILON = 2.0;
|
||
const BRUSH_CONTINUITY_RATIO = 0.7;
|
||
|
||
let lastManualResult = null;
|
||
|
||
$(document).ready(function() {
|
||
$('#cadAreaTable').DataTable({
|
||
order: [
|
||
[0, 'desc']
|
||
],
|
||
pageLength: 25,
|
||
language: {
|
||
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json'
|
||
}
|
||
});
|
||
});
|
||
|
||
dropZone.addEventListener('click', function() {
|
||
fileInput.click();
|
||
});
|
||
|
||
dropZone.addEventListener('dragover', function(e) {
|
||
e.preventDefault();
|
||
dropZone.classList.add('dragover');
|
||
});
|
||
|
||
dropZone.addEventListener('dragleave', function() {
|
||
dropZone.classList.remove('dragover');
|
||
});
|
||
|
||
dropZone.addEventListener('drop', function(e) {
|
||
e.preventDefault();
|
||
dropZone.classList.remove('dragover');
|
||
handleFiles(e.dataTransfer.files);
|
||
});
|
||
|
||
fileInput.addEventListener('change', function(e) {
|
||
handleFiles(e.target.files);
|
||
});
|
||
|
||
function handleFiles(files) {
|
||
const incomingFiles = Array.from(files);
|
||
|
||
incomingFiles.forEach(file => {
|
||
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
|
||
|
||
if (!isPdf) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'File non valido',
|
||
text: `${file.name} non è un PDF.`
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (file.size > 25 * 1024 * 1024) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'File troppo grande',
|
||
text: `${file.name} supera il limite di 25 MB.`
|
||
});
|
||
return;
|
||
}
|
||
|
||
selectedFiles.push(file);
|
||
});
|
||
|
||
renderSelectedFiles();
|
||
}
|
||
|
||
function renderSelectedFiles() {
|
||
selectedFilesBox.innerHTML = '';
|
||
|
||
selectedFiles.forEach((file, index) => {
|
||
const sizeMb = (file.size / 1024 / 1024).toFixed(2);
|
||
|
||
const item = document.createElement('div');
|
||
item.className = 'file-preview';
|
||
item.innerHTML = `
|
||
<div>
|
||
<strong>📄 ${escapeHtml(file.name)}</strong><br>
|
||
<small>${sizeMb} MB</small>
|
||
</div>
|
||
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSelectedFile(${index})">
|
||
Rimuovi
|
||
</button>
|
||
`;
|
||
|
||
selectedFilesBox.appendChild(item);
|
||
});
|
||
|
||
uploadBtn.disabled = selectedFiles.length === 0;
|
||
}
|
||
|
||
function removeSelectedFile(index) {
|
||
selectedFiles.splice(index, 1);
|
||
renderSelectedFiles();
|
||
}
|
||
|
||
uploadBtn.addEventListener('click', function() {
|
||
if (selectedFiles.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
|
||
selectedFiles.forEach(file => {
|
||
formData.append('pdf_files[]', file);
|
||
});
|
||
|
||
showOverlay();
|
||
|
||
fetch('cad_area_upload.php', {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
hideOverlay();
|
||
|
||
if (!data.success) {
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Errore upload',
|
||
text: data.message || 'Upload non riuscito'
|
||
});
|
||
return;
|
||
}
|
||
|
||
selectedFiles = [];
|
||
renderSelectedFiles();
|
||
|
||
Swal.fire({
|
||
icon: 'success',
|
||
title: 'PDF caricati',
|
||
text: 'Ora usa Traccia Manuale per calcolare l’area.',
|
||
confirmButtonText: 'OK'
|
||
}).then(() => {
|
||
location.reload();
|
||
});
|
||
})
|
||
.catch(error => {
|
||
hideOverlay();
|
||
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Errore',
|
||
text: error.message || 'Errore durante upload'
|
||
});
|
||
});
|
||
});
|
||
|
||
$(document).on('click', '.delete-one', function() {
|
||
const id = parseInt($(this).data('id'), 10);
|
||
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Eliminare il PDF?',
|
||
text: 'Il file e il relativo risultato saranno rimossi.',
|
||
showCancelButton: true,
|
||
confirmButtonText: 'Sì, elimina',
|
||
cancelButtonText: 'Annulla'
|
||
}).then(result => {
|
||
if (!result.isConfirmed) {
|
||
return;
|
||
}
|
||
|
||
fetch('cad_area_delete.php', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
id: id
|
||
})
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
location.reload();
|
||
} else {
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Errore',
|
||
text: data.message || 'Eliminazione non riuscita'
|
||
});
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
$(document).on('click', '.manual-trace', function() {
|
||
currentManualJobId = parseInt($(this).data('id'), 10);
|
||
currentPdfUrl = $(this).data('url');
|
||
|
||
resetManualState();
|
||
|
||
manualModal = new bootstrap.Modal(document.getElementById('manualTraceModal'));
|
||
manualModal.show();
|
||
|
||
setTimeout(() => {
|
||
loadPdfForManualTracing(currentPdfUrl);
|
||
}, 300);
|
||
});
|
||
|
||
function loadPdfForManualTracing(pdfUrl) {
|
||
manualPdfCanvas = document.getElementById('manualPdfCanvas');
|
||
manualOverlayCanvas = document.getElementById('manualOverlayCanvas');
|
||
|
||
manualPdfCtx = manualPdfCanvas.getContext('2d');
|
||
manualOverlayCtx = manualOverlayCanvas.getContext('2d');
|
||
|
||
setManualStatus('Caricamento PDF...');
|
||
|
||
pdfjsLib.getDocument(pdfUrl).promise.then(function(pdfDoc) {
|
||
manualPdfDoc = pdfDoc;
|
||
return manualPdfDoc.getPage(1);
|
||
}).then(function(page) {
|
||
manualPdfPage = page;
|
||
|
||
const viewportOriginal = manualPdfPage.getViewport({
|
||
scale: 1
|
||
});
|
||
|
||
manualBaseScale = MANUAL_FULL_MAX_WIDTH / viewportOriginal.width;
|
||
manualCurrentView = 'full';
|
||
manualRoiPageRect = null;
|
||
|
||
return renderManualFullPage(false);
|
||
}).then(() => {
|
||
setupManualCanvasEvents();
|
||
redrawManualOverlay();
|
||
setTool('roi');
|
||
updateManualViewBadge();
|
||
}).catch(function(error) {
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Errore PDF',
|
||
text: error.message || 'Impossibile caricare il PDF'
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderManualFullPage(resetDrawing) {
|
||
if (!manualPdfPage) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
if (resetDrawing) {
|
||
resetDrawingAfterViewChange();
|
||
}
|
||
|
||
manualCurrentView = 'full';
|
||
manualRoiPageRect = null;
|
||
|
||
edgeCacheDirty = true;
|
||
|
||
const viewport = manualPdfPage.getViewport({
|
||
scale: manualBaseScale
|
||
});
|
||
|
||
manualPdfCanvas.width = viewport.width;
|
||
manualPdfCanvas.height = viewport.height;
|
||
|
||
manualOverlayCanvas.width = viewport.width;
|
||
manualOverlayCanvas.height = viewport.height;
|
||
|
||
manualPdfCtx.setTransform(1, 0, 0, 1, 0, 0);
|
||
manualPdfCtx.clearRect(0, 0, manualPdfCanvas.width, manualPdfCanvas.height);
|
||
|
||
return manualPdfPage.render({
|
||
canvasContext: manualPdfCtx,
|
||
viewport: viewport
|
||
}).promise.then(() => {
|
||
rebuildEdgeCache();
|
||
updateManualViewBadge();
|
||
redrawManualOverlay();
|
||
});
|
||
}
|
||
|
||
function renderManualRoiViewFromCurrentRoi() {
|
||
if (!manualPdfPage || !roiRect || roiRect.w <= 0 || roiRect.h <= 0) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'ROI mancante',
|
||
text: 'Prima disegna un rettangolo ROI intorno alla sezione da ingrandire.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (manualCurrentView !== 'full') {
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Già in zoom ROI',
|
||
text: 'Se vuoi scegliere una nuova ROI torna prima alla pagina intera.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const pageX = roiRect.x / manualBaseScale;
|
||
const pageY = roiRect.y / manualBaseScale;
|
||
const pageW = roiRect.w / manualBaseScale;
|
||
const pageH = roiRect.h / manualBaseScale;
|
||
|
||
if (pageW <= 0 || pageH <= 0) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'ROI non valida',
|
||
text: 'Disegna una ROI più ampia.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
manualRoiPageRect = {
|
||
x: pageX,
|
||
y: pageY,
|
||
w: pageW,
|
||
h: pageH
|
||
};
|
||
|
||
showOverlay();
|
||
|
||
renderManualRoiView(true).then(() => {
|
||
hideOverlay();
|
||
setTool('calibration');
|
||
setManualStatus('Zoom su ROI attivo. Ora calibra una quota nota sulla sezione ingrandita.');
|
||
updateManualPreview();
|
||
}).catch(error => {
|
||
hideOverlay();
|
||
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Errore zoom ROI',
|
||
text: error.message || 'Impossibile ingrandire la ROI.'
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderManualRoiView(resetDrawing) {
|
||
if (!manualPdfPage || !manualRoiPageRect) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
if (resetDrawing) {
|
||
resetDrawingAfterViewChange();
|
||
}
|
||
|
||
manualCurrentView = 'roi';
|
||
edgeCacheDirty = true;
|
||
|
||
const scale = manualBaseScale * MANUAL_ROI_ZOOM_FACTOR;
|
||
const viewport = manualPdfPage.getViewport({
|
||
scale: scale
|
||
});
|
||
|
||
const canvasWidth = Math.max(50, Math.ceil(manualRoiPageRect.w * scale));
|
||
const canvasHeight = Math.max(50, Math.ceil(manualRoiPageRect.h * scale));
|
||
|
||
manualPdfCanvas.width = canvasWidth;
|
||
manualPdfCanvas.height = canvasHeight;
|
||
|
||
manualOverlayCanvas.width = canvasWidth;
|
||
manualOverlayCanvas.height = canvasHeight;
|
||
|
||
manualPdfCtx.setTransform(1, 0, 0, 1, 0, 0);
|
||
manualPdfCtx.clearRect(0, 0, manualPdfCanvas.width, manualPdfCanvas.height);
|
||
|
||
manualPdfCtx.save();
|
||
|
||
manualPdfCtx.translate(
|
||
-manualRoiPageRect.x * scale,
|
||
-manualRoiPageRect.y * scale
|
||
);
|
||
|
||
return manualPdfPage.render({
|
||
canvasContext: manualPdfCtx,
|
||
viewport: viewport
|
||
}).promise.then(() => {
|
||
manualPdfCtx.restore();
|
||
rebuildEdgeCache();
|
||
updateManualViewBadge();
|
||
redrawManualOverlay();
|
||
}).catch(error => {
|
||
manualPdfCtx.restore();
|
||
throw error;
|
||
});
|
||
}
|
||
|
||
function resetDrawingAfterViewChange() {
|
||
isDrawingRoi = false;
|
||
roiStartX = 0;
|
||
roiStartY = 0;
|
||
roiRect = null;
|
||
|
||
isDrawingAutoContourRoi = false;
|
||
autoContourRoiStartX = 0;
|
||
autoContourRoiStartY = 0;
|
||
autoContourRoiRect = null;
|
||
|
||
calibrationPoints = [];
|
||
calibrationMm = null;
|
||
calibrationPx = null;
|
||
mmPerPx = null;
|
||
|
||
polygonPoints = [];
|
||
currentHolePoints = [];
|
||
holes = [];
|
||
|
||
selectedEditPoint = null;
|
||
isDraggingEditPoint = false;
|
||
editDragMoved = false;
|
||
|
||
lastManualResult = null;
|
||
|
||
document.getElementById('saveManualAreaBtn').disabled = true;
|
||
document.getElementById('manualCoordsPreview').innerText = 'Nessun dato calcolato.';
|
||
}
|
||
|
||
function updateManualViewBadge() {
|
||
const badge = document.getElementById('manualViewBadge');
|
||
const fullPageBtn = document.getElementById('fullPageBtn');
|
||
|
||
if (!badge || !fullPageBtn) {
|
||
return;
|
||
}
|
||
|
||
if (manualCurrentView === 'roi') {
|
||
badge.innerText = 'Vista: ROI ingrandita';
|
||
fullPageBtn.disabled = false;
|
||
} else {
|
||
badge.innerText = 'Vista: pagina intera';
|
||
fullPageBtn.disabled = true;
|
||
}
|
||
}
|
||
|
||
function resetManualState() {
|
||
currentTool = 'roi';
|
||
|
||
isDrawingRoi = false;
|
||
roiStartX = 0;
|
||
roiStartY = 0;
|
||
roiRect = null;
|
||
|
||
isDrawingAutoContourRoi = false;
|
||
autoContourRoiStartX = 0;
|
||
autoContourRoiStartY = 0;
|
||
autoContourRoiRect = null;
|
||
|
||
calibrationPoints = [];
|
||
calibrationMm = null;
|
||
calibrationPx = null;
|
||
mmPerPx = null;
|
||
|
||
polygonPoints = [];
|
||
currentHolePoints = [];
|
||
holes = [];
|
||
|
||
lastManualResult = null;
|
||
manualCurrentView = 'full';
|
||
manualRoiPageRect = null;
|
||
updateManualViewBadge();
|
||
|
||
document.getElementById('saveManualAreaBtn').disabled = true;
|
||
document.getElementById('manualCoordsPreview').innerText = 'Nessun dato calcolato.';
|
||
document.getElementById('exportDxfBtn').disabled = true;
|
||
|
||
edgeSnapEnabled = false;
|
||
|
||
const edgeSnapBtn = document.getElementById('edgeSnapBtn');
|
||
if (edgeSnapBtn) {
|
||
edgeSnapBtn.classList.remove('btn-warning', 'active');
|
||
edgeSnapBtn.classList.add('btn-outline-warning');
|
||
edgeSnapBtn.innerText = '🧲 OFF';
|
||
}
|
||
|
||
setTool('roi');
|
||
}
|
||
|
||
function setupManualCanvasEvents() {
|
||
manualOverlayCanvas.onmousedown = function(e) {
|
||
const pos = getCanvasMousePosition(e, manualOverlayCanvas);
|
||
|
||
if (currentTool === 'roi') {
|
||
isDrawingRoi = true;
|
||
roiStartX = pos.x;
|
||
roiStartY = pos.y;
|
||
|
||
roiRect = {
|
||
x: roiStartX,
|
||
y: roiStartY,
|
||
w: 0,
|
||
h: 0
|
||
};
|
||
|
||
return;
|
||
}
|
||
if (currentTool === 'brush') {
|
||
if (!mmPerPx) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Calibrazione mancante',
|
||
text: 'Prima devi calibrare una quota nota.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
isBrushing = true;
|
||
brushStartIndex = polygonPoints.length;
|
||
|
||
const startPos = snapToNearestEdge(pos, EDGE_SNAP_RADIUS) || pos;
|
||
polygonPoints.push(startPos);
|
||
lastBrushPoint = startPos;
|
||
|
||
markManualResultDirty();
|
||
redrawManualOverlay();
|
||
return;
|
||
}
|
||
if (currentTool === 'auto_roi') {
|
||
isDrawingAutoContourRoi = true;
|
||
autoContourRoiStartX = pos.x;
|
||
autoContourRoiStartY = pos.y;
|
||
|
||
autoContourRoiRect = {
|
||
x: autoContourRoiStartX,
|
||
y: autoContourRoiStartY,
|
||
w: 0,
|
||
h: 0
|
||
};
|
||
|
||
return;
|
||
}
|
||
|
||
if (currentTool === 'edit') {
|
||
selectedEditPoint = findNearestEditablePoint(pos, POINT_HIT_RADIUS);
|
||
|
||
if (selectedEditPoint) {
|
||
isDraggingEditPoint = true;
|
||
editDragMoved = false;
|
||
const finalPos = getSnappedPoint(pos);
|
||
updateSelectedPointPosition(finalPos);
|
||
markManualResultDirty();
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
} else {
|
||
redrawManualOverlay();
|
||
setManualStatus('Modifica punti: clicca un punto per selezionarlo, trascinalo per spostarlo, doppio click su un segmento per inserire un nuovo punto.');
|
||
}
|
||
|
||
return;
|
||
}
|
||
};
|
||
|
||
manualOverlayCanvas.onmousemove = function(e) {
|
||
const pos = getCanvasMousePosition(e, manualOverlayCanvas);
|
||
|
||
if (currentTool === 'roi' && isDrawingRoi) {
|
||
roiRect = {
|
||
x: Math.min(roiStartX, pos.x),
|
||
y: Math.min(roiStartY, pos.y),
|
||
w: Math.abs(pos.x - roiStartX),
|
||
h: Math.abs(pos.y - roiStartY)
|
||
};
|
||
|
||
redrawManualOverlay();
|
||
return;
|
||
}
|
||
|
||
if (currentTool === 'auto_roi' && isDrawingAutoContourRoi) {
|
||
autoContourRoiRect = {
|
||
x: Math.min(autoContourRoiStartX, pos.x),
|
||
y: Math.min(autoContourRoiStartY, pos.y),
|
||
w: Math.abs(pos.x - autoContourRoiStartX),
|
||
h: Math.abs(pos.y - autoContourRoiStartY)
|
||
};
|
||
|
||
redrawManualOverlay();
|
||
return;
|
||
}
|
||
if (currentTool === 'brush' && isBrushing) {
|
||
if (!lastBrushPoint || distance(pos, lastBrushPoint) < BRUSH_STEP) {
|
||
return;
|
||
}
|
||
|
||
let added = false;
|
||
let guard = 0;
|
||
|
||
while (guard < 25 && lastBrushPoint && distance(pos, lastBrushPoint) > BRUSH_STEP) {
|
||
guard++;
|
||
|
||
const next = brushWalkAlongEdge(lastBrushPoint, pos);
|
||
|
||
if (!next) {
|
||
break;
|
||
}
|
||
|
||
polygonPoints.push(next);
|
||
lastBrushPoint = next;
|
||
added = true;
|
||
}
|
||
|
||
if (added) {
|
||
redrawManualOverlay();
|
||
}
|
||
|
||
return;
|
||
}
|
||
if (currentTool === 'edit' && isDraggingEditPoint && selectedEditPoint) {
|
||
const finalPos = getSnappedPoint(pos);
|
||
updateSelectedPointPosition(finalPos);
|
||
editDragMoved = true;
|
||
markManualResultDirty();
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
return;
|
||
}
|
||
if (edgeSnapEnabled && ['polygon', 'hole', 'edit'].includes(currentTool)) {
|
||
lastSnapMousePos = pos;
|
||
|
||
if (!snapRafPending) {
|
||
snapRafPending = true;
|
||
|
||
requestAnimationFrame(() => {
|
||
snapRafPending = false;
|
||
edgeSnapPreviewPoint = snapToNearestEdge(lastSnapMousePos, EDGE_SNAP_RADIUS);
|
||
redrawManualOverlay();
|
||
});
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (edgeSnapPreviewPoint) {
|
||
edgeSnapPreviewPoint = null;
|
||
redrawManualOverlay();
|
||
}
|
||
};
|
||
|
||
manualOverlayCanvas.onmouseup = function() {
|
||
if (currentTool === 'roi' && isDrawingRoi) {
|
||
isDrawingRoi = false;
|
||
|
||
if (roiRect && (roiRect.w < 5 || roiRect.h < 5)) {
|
||
roiRect = null;
|
||
}
|
||
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
|
||
if (roiRect) {
|
||
setManualStatus('ROI definita. Ora puoi fare “Zoom su ROI” oppure calibrare una quota nota.');
|
||
}
|
||
|
||
return;
|
||
}
|
||
if (currentTool === 'brush' && isBrushing) {
|
||
isBrushing = false;
|
||
lastBrushPoint = null;
|
||
|
||
if (polygonPoints.length - brushStartIndex >= 3) {
|
||
const stroke = polygonPoints.slice(brushStartIndex);
|
||
const simplified = simplifyRdp(stroke, BRUSH_SIMPLIFY_EPSILON);
|
||
polygonPoints = polygonPoints.slice(0, brushStartIndex).concat(simplified);
|
||
}
|
||
|
||
markManualResultDirty();
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
|
||
setManualStatus(
|
||
'Tratto completato: ' + polygonPoints.length + ' punti totali. ' +
|
||
'Continua con un altro tratto, oppure passa a "Modifica punti" per rifinire e poi calcola l\u2019area.'
|
||
);
|
||
return;
|
||
}
|
||
if (currentTool === 'auto_roi' && isDrawingAutoContourRoi) {
|
||
isDrawingAutoContourRoi = false;
|
||
|
||
if (autoContourRoiRect && (autoContourRoiRect.w < 8 || autoContourRoiRect.h < 8)) {
|
||
autoContourRoiRect = null;
|
||
}
|
||
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
|
||
if (autoContourRoiRect) {
|
||
setManualStatus('ROI autocontorno definita. Ora clicca “Proponi contorno”: Python analizzerà solo questa zona, senza quote/testi esterni.');
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (currentTool === 'edit' && isDraggingEditPoint) {
|
||
isDraggingEditPoint = false;
|
||
|
||
if (selectedEditPoint) {
|
||
const label = getSelectedPointLabel(selectedEditPoint);
|
||
|
||
if (editDragMoved) {
|
||
setManualStatus(`Punto spostato (${label}). Puoi calcolare di nuovo oppure continuare a modificare.`);
|
||
} else {
|
||
setManualStatus(`Punto selezionato (${label}). Trascinalo per spostarlo, usa “Elimina punto”, oppure doppio click su un segmento per inserire un punto.`);
|
||
}
|
||
}
|
||
|
||
editDragMoved = false;
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
return;
|
||
}
|
||
};
|
||
|
||
manualOverlayCanvas.onclick = function(e) {
|
||
const pos = getCanvasMousePosition(e, manualOverlayCanvas);
|
||
|
||
if (currentTool === 'edit') {
|
||
selectedEditPoint = findNearestEditablePoint(pos, POINT_HIT_RADIUS);
|
||
redrawManualOverlay();
|
||
|
||
if (selectedEditPoint) {
|
||
setManualStatus(`Punto selezionato (${getSelectedPointLabel(selectedEditPoint)}). Trascina per spostarlo o clicca “Elimina punto”.`);
|
||
} else {
|
||
setManualStatus('Nessun punto selezionato. Clicca vicino a un punto oppure doppio click su un segmento per aggiungerne uno.');
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (currentTool === 'calibration') {
|
||
handleCalibrationClick(pos);
|
||
return;
|
||
}
|
||
|
||
if (currentTool === 'polygon') {
|
||
handlePolygonClick(pos);
|
||
return;
|
||
}
|
||
|
||
if (currentTool === 'hole') {
|
||
handleHoleClick(pos);
|
||
return;
|
||
}
|
||
};
|
||
|
||
manualOverlayCanvas.ondblclick = function(e) {
|
||
if (currentTool !== 'edit') {
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
|
||
const pos = getCanvasMousePosition(e, manualOverlayCanvas);
|
||
const inserted = insertPointOnNearestSegment(pos, SEGMENT_HIT_RADIUS);
|
||
|
||
if (inserted) {
|
||
markManualResultDirty();
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
setManualStatus(`Nuovo punto inserito (${getSelectedPointLabel(selectedEditPoint)}). Ora puoi trascinarlo per rifinire il profilo.`);
|
||
} else {
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Nessun segmento vicino',
|
||
text: 'Fai doppio click vicino a una linea del contorno esterno o di una esclusione.'
|
||
});
|
||
}
|
||
};
|
||
}
|
||
|
||
function getSnappedPoint(pos) {
|
||
if (!edgeSnapEnabled) {
|
||
return pos;
|
||
}
|
||
|
||
if (!manualPdfCanvas || !manualPdfCtx) {
|
||
return pos;
|
||
}
|
||
|
||
if (!['polygon', 'hole', 'edit'].includes(currentTool)) {
|
||
return pos;
|
||
}
|
||
|
||
const snapped = snapToNearestEdge(pos, EDGE_SNAP_RADIUS);
|
||
|
||
if (snapped) {
|
||
edgeSnapPreviewPoint = snapped;
|
||
return snapped;
|
||
}
|
||
|
||
edgeSnapPreviewPoint = null;
|
||
return pos;
|
||
}
|
||
|
||
function rebuildEdgeCache() {
|
||
edgeGray = null;
|
||
|
||
if (!manualPdfCanvas || !manualPdfCtx) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const w = manualPdfCanvas.width;
|
||
const h = manualPdfCanvas.height;
|
||
const d = manualPdfCtx.getImageData(0, 0, w, h).data;
|
||
|
||
edgeGray = new Uint8Array(w * h);
|
||
|
||
for (let i = 0, p = 0; i < edgeGray.length; i++, p += 4) {
|
||
edgeGray[i] = (d[p] + d[p + 1] + d[p + 2]) / 3;
|
||
}
|
||
|
||
edgeGrayW = w;
|
||
edgeGrayH = h;
|
||
edgeCacheDirty = false;
|
||
|
||
console.log('Edge cache OK:', w + 'x' + h);
|
||
} catch (e) {
|
||
console.warn('Edge cache failed:', e);
|
||
edgeGray = null;
|
||
}
|
||
}
|
||
|
||
function snapToNearestEdge(pos, radius) {
|
||
if (edgeCacheDirty || !edgeGray || edgeGrayW !== manualPdfCanvas.width || edgeGrayH !== manualPdfCanvas.height) {
|
||
rebuildEdgeCache();
|
||
}
|
||
|
||
if (!edgeGray) {
|
||
return null;
|
||
}
|
||
|
||
const w = edgeGrayW;
|
||
const h = edgeGrayH;
|
||
|
||
const cx = Math.round(pos.x);
|
||
const cy = Math.round(pos.y);
|
||
|
||
const x0 = Math.max(0, cx - radius);
|
||
const y0 = Math.max(0, cy - radius);
|
||
const x1 = Math.min(w - 1, cx + radius);
|
||
const y1 = Math.min(h - 1, cy + radius);
|
||
|
||
let best = null;
|
||
let bestScore = Infinity;
|
||
const r2 = radius * radius;
|
||
|
||
for (let y = y0; y <= y1; y++) {
|
||
const rowOffset = y * w;
|
||
|
||
for (let x = x0; x <= x1; x++) {
|
||
const v = edgeGray[rowOffset + x];
|
||
|
||
if (v > EDGE_DARK_THRESHOLD) {
|
||
continue;
|
||
}
|
||
|
||
const dx = x - pos.x;
|
||
const dy = y - pos.y;
|
||
const d2 = dx * dx + dy * dy;
|
||
|
||
if (d2 > r2) {
|
||
continue;
|
||
}
|
||
|
||
const score = Math.sqrt(d2) - (EDGE_DARK_THRESHOLD - v) * 0.03;
|
||
|
||
if (score < bestScore) {
|
||
bestScore = score;
|
||
best = {
|
||
x: x,
|
||
y: y
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!best) {
|
||
return null;
|
||
}
|
||
|
||
let sumX = 0;
|
||
let sumY = 0;
|
||
let sumW = 0;
|
||
|
||
for (let y = Math.max(0, best.y - 2); y <= Math.min(h - 1, best.y + 2); y++) {
|
||
for (let x = Math.max(0, best.x - 2); x <= Math.min(w - 1, best.x + 2); x++) {
|
||
const v = edgeGray[y * w + x];
|
||
|
||
if (v > EDGE_DARK_THRESHOLD) {
|
||
continue;
|
||
}
|
||
|
||
const weight = EDGE_DARK_THRESHOLD - v;
|
||
sumX += x * weight;
|
||
sumY += y * weight;
|
||
sumW += weight;
|
||
}
|
||
}
|
||
|
||
if (sumW > 0) {
|
||
return {
|
||
x: sumX / sumW,
|
||
y: sumY / sumW
|
||
};
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
function handleCalibrationClick(pos) {
|
||
if (calibrationPoints.length >= 2) {
|
||
calibrationPoints = [];
|
||
calibrationMm = null;
|
||
calibrationPx = null;
|
||
mmPerPx = null;
|
||
}
|
||
|
||
calibrationPoints.push(pos);
|
||
redrawManualOverlay();
|
||
|
||
if (calibrationPoints.length === 2) {
|
||
calibrationPx = distance(calibrationPoints[0], calibrationPoints[1]);
|
||
|
||
let rawValue = window.prompt(
|
||
'Inserisci il valore reale della quota in mm.\n\nEsempi: 16 oppure 9.2 oppure 9,2',
|
||
''
|
||
);
|
||
|
||
if (rawValue === null) {
|
||
calibrationPoints = [];
|
||
calibrationMm = null;
|
||
calibrationPx = null;
|
||
mmPerPx = null;
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
return;
|
||
}
|
||
|
||
rawValue = rawValue.trim().replace(',', '.');
|
||
const value = parseFloat(rawValue);
|
||
|
||
if (!rawValue || isNaN(value) || value <= 0) {
|
||
calibrationPoints = [];
|
||
calibrationMm = null;
|
||
calibrationPx = null;
|
||
mmPerPx = null;
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Valore non valido',
|
||
text: 'Inserisci un valore numerico valido in mm.'
|
||
});
|
||
|
||
return;
|
||
}
|
||
|
||
calibrationMm = value;
|
||
mmPerPx = calibrationMm / calibrationPx;
|
||
|
||
setManualStatus(
|
||
`Calibrazione completata: ${calibrationMm} mm = ${calibrationPx.toFixed(2)} px. Ora disegna il profilo.`
|
||
);
|
||
|
||
setTool('polygon');
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
}
|
||
}
|
||
|
||
function handlePolygonClick(pos) {
|
||
if (!mmPerPx) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Calibrazione mancante',
|
||
text: 'Prima devi calibrare una quota nota.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const finalPos = getSnappedPoint(pos);
|
||
polygonPoints.push(finalPos);
|
||
selectedEditPoint = {
|
||
type: 'outer',
|
||
pointIndex: polygonPoints.length - 1
|
||
};
|
||
|
||
markManualResultDirty();
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
}
|
||
|
||
function handleHoleClick(pos) {
|
||
if (!mmPerPx) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Calibrazione mancante',
|
||
text: 'Prima devi calibrare una quota nota.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (polygonPoints.length < 3) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Contorno esterno mancante',
|
||
text: 'Prima disegna il contorno esterno del profilo.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const finalPos = getSnappedPoint(pos);
|
||
currentHolePoints.push(finalPos);
|
||
selectedEditPoint = {
|
||
type: 'currentHole',
|
||
pointIndex: currentHolePoints.length - 1
|
||
};
|
||
|
||
markManualResultDirty();
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
}
|
||
|
||
function closeCurrentHole() {
|
||
if (currentHolePoints.length < 3) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Esclusione incompleta',
|
||
text: 'Servono almeno 3 punti per chiudere un’area da escludere.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
holes.push([...currentHolePoints]);
|
||
currentHolePoints = [];
|
||
selectedEditPoint = null;
|
||
|
||
markManualResultDirty();
|
||
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
|
||
setManualStatus(`Area da escludere salvata. Esclusioni presenti: ${holes.length}.`);
|
||
}
|
||
|
||
function redrawManualOverlay() {
|
||
if (!manualOverlayCtx || !manualOverlayCanvas) {
|
||
return;
|
||
}
|
||
|
||
manualOverlayCtx.clearRect(0, 0, manualOverlayCanvas.width, manualOverlayCanvas.height);
|
||
|
||
drawRoi();
|
||
drawAutoContourRoi();
|
||
drawCalibration();
|
||
drawPolygon();
|
||
drawHoles();
|
||
drawEdgeSnapPreview();
|
||
}
|
||
|
||
function drawEdgeSnapPreview() {
|
||
if (!edgeSnapEnabled || !edgeSnapPreviewPoint) {
|
||
return;
|
||
}
|
||
|
||
manualOverlayCtx.save();
|
||
|
||
manualOverlayCtx.strokeStyle = 'rgba(245, 158, 11, 1)';
|
||
manualOverlayCtx.fillStyle = 'rgba(245, 158, 11, 0.95)';
|
||
manualOverlayCtx.lineWidth = 2;
|
||
|
||
manualOverlayCtx.beginPath();
|
||
manualOverlayCtx.arc(edgeSnapPreviewPoint.x, edgeSnapPreviewPoint.y, 8, 0, Math.PI * 2);
|
||
manualOverlayCtx.stroke();
|
||
|
||
manualOverlayCtx.beginPath();
|
||
manualOverlayCtx.moveTo(edgeSnapPreviewPoint.x - 13, edgeSnapPreviewPoint.y);
|
||
manualOverlayCtx.lineTo(edgeSnapPreviewPoint.x + 13, edgeSnapPreviewPoint.y);
|
||
manualOverlayCtx.moveTo(edgeSnapPreviewPoint.x, edgeSnapPreviewPoint.y - 13);
|
||
manualOverlayCtx.lineTo(edgeSnapPreviewPoint.x, edgeSnapPreviewPoint.y + 13);
|
||
manualOverlayCtx.stroke();
|
||
|
||
manualOverlayCtx.restore();
|
||
}
|
||
|
||
function drawRoi() {
|
||
if (!roiRect) {
|
||
return;
|
||
}
|
||
|
||
manualOverlayCtx.fillStyle = 'rgba(13, 110, 253, 0.12)';
|
||
manualOverlayCtx.strokeStyle = 'rgba(13, 110, 253, 0.95)';
|
||
manualOverlayCtx.lineWidth = 2;
|
||
|
||
manualOverlayCtx.fillRect(roiRect.x, roiRect.y, roiRect.w, roiRect.h);
|
||
manualOverlayCtx.strokeRect(roiRect.x, roiRect.y, roiRect.w, roiRect.h);
|
||
}
|
||
|
||
function drawAutoContourRoi() {
|
||
if (!autoContourRoiRect) {
|
||
return;
|
||
}
|
||
|
||
manualOverlayCtx.fillStyle = 'rgba(168, 85, 247, 0.12)';
|
||
manualOverlayCtx.strokeStyle = 'rgba(168, 85, 247, 0.98)';
|
||
manualOverlayCtx.lineWidth = 2;
|
||
manualOverlayCtx.setLineDash([8, 4]);
|
||
|
||
manualOverlayCtx.fillRect(
|
||
autoContourRoiRect.x,
|
||
autoContourRoiRect.y,
|
||
autoContourRoiRect.w,
|
||
autoContourRoiRect.h
|
||
);
|
||
|
||
manualOverlayCtx.strokeRect(
|
||
autoContourRoiRect.x,
|
||
autoContourRoiRect.y,
|
||
autoContourRoiRect.w,
|
||
autoContourRoiRect.h
|
||
);
|
||
|
||
manualOverlayCtx.setLineDash([]);
|
||
|
||
manualOverlayCtx.fillStyle = 'rgba(168, 85, 247, 1)';
|
||
manualOverlayCtx.font = '13px Arial';
|
||
manualOverlayCtx.fillText(
|
||
'ROI autocontorno',
|
||
autoContourRoiRect.x + 6,
|
||
Math.max(14, autoContourRoiRect.y - 6)
|
||
);
|
||
}
|
||
|
||
function drawCalibration() {
|
||
if (calibrationPoints.length === 0) {
|
||
return;
|
||
}
|
||
|
||
manualOverlayCtx.strokeStyle = 'rgba(220, 38, 38, 0.95)';
|
||
manualOverlayCtx.fillStyle = 'rgba(220, 38, 38, 1)';
|
||
manualOverlayCtx.lineWidth = 3;
|
||
|
||
calibrationPoints.forEach(point => {
|
||
drawPoint(point, 'rgba(220, 38, 38, 1)', 5);
|
||
});
|
||
|
||
if (calibrationPoints.length === 2) {
|
||
manualOverlayCtx.beginPath();
|
||
manualOverlayCtx.moveTo(calibrationPoints[0].x, calibrationPoints[0].y);
|
||
manualOverlayCtx.lineTo(calibrationPoints[1].x, calibrationPoints[1].y);
|
||
manualOverlayCtx.stroke();
|
||
}
|
||
}
|
||
|
||
function drawPolygon() {
|
||
if (polygonPoints.length === 0) {
|
||
return;
|
||
}
|
||
|
||
manualOverlayCtx.strokeStyle = 'rgba(22, 163, 74, 0.95)';
|
||
manualOverlayCtx.fillStyle = 'rgba(22, 163, 74, 0.16)';
|
||
manualOverlayCtx.lineWidth = 2;
|
||
|
||
manualOverlayCtx.beginPath();
|
||
manualOverlayCtx.moveTo(polygonPoints[0].x, polygonPoints[0].y);
|
||
|
||
for (let i = 1; i < polygonPoints.length; i++) {
|
||
manualOverlayCtx.lineTo(polygonPoints[i].x, polygonPoints[i].y);
|
||
}
|
||
|
||
if (polygonPoints.length >= 3) {
|
||
manualOverlayCtx.closePath();
|
||
manualOverlayCtx.fill();
|
||
}
|
||
|
||
manualOverlayCtx.stroke();
|
||
|
||
polygonPoints.forEach((point, index) => {
|
||
const selected = isSelectedPoint('outer', null, index);
|
||
drawPoint(point, selected ? 'rgba(245, 158, 11, 1)' : 'rgba(22, 163, 74, 1)', selected ? 7 : 4);
|
||
|
||
if (selected) {
|
||
drawPointRing(point, 'rgba(245, 158, 11, 1)', 11);
|
||
}
|
||
});
|
||
}
|
||
|
||
function drawHoles() {
|
||
holes.forEach((hole, holeIndex) => {
|
||
drawHolePolygon(hole, true, holeIndex, 'hole');
|
||
});
|
||
|
||
if (currentHolePoints.length > 0) {
|
||
drawHolePolygon(currentHolePoints, false, null, 'currentHole');
|
||
}
|
||
}
|
||
|
||
function drawHolePolygon(points, closed, holeIndex = null, type = 'hole') {
|
||
if (!points || points.length === 0) {
|
||
return;
|
||
}
|
||
|
||
manualOverlayCtx.strokeStyle = 'rgba(220, 38, 38, 0.95)';
|
||
manualOverlayCtx.fillStyle = closed ? 'rgba(220, 38, 38, 0.22)' : 'rgba(220, 38, 38, 0.08)';
|
||
manualOverlayCtx.lineWidth = 2;
|
||
|
||
manualOverlayCtx.beginPath();
|
||
manualOverlayCtx.moveTo(points[0].x, points[0].y);
|
||
|
||
for (let i = 1; i < points.length; i++) {
|
||
manualOverlayCtx.lineTo(points[i].x, points[i].y);
|
||
}
|
||
|
||
if (closed && points.length >= 3) {
|
||
manualOverlayCtx.closePath();
|
||
manualOverlayCtx.fill();
|
||
}
|
||
|
||
manualOverlayCtx.stroke();
|
||
|
||
points.forEach((point, index) => {
|
||
const selected = isSelectedPoint(type, holeIndex, index);
|
||
drawPoint(point, selected ? 'rgba(245, 158, 11, 1)' : 'rgba(220, 38, 38, 1)', selected ? 7 : 4);
|
||
|
||
if (selected) {
|
||
drawPointRing(point, 'rgba(245, 158, 11, 1)', 11);
|
||
}
|
||
});
|
||
}
|
||
|
||
function drawPoint(point, color, radius) {
|
||
manualOverlayCtx.beginPath();
|
||
manualOverlayCtx.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
||
manualOverlayCtx.fillStyle = color;
|
||
manualOverlayCtx.fill();
|
||
}
|
||
|
||
function drawPointRing(point, color, radius) {
|
||
manualOverlayCtx.beginPath();
|
||
manualOverlayCtx.arc(point.x, point.y, radius, 0, Math.PI * 2);
|
||
manualOverlayCtx.strokeStyle = color;
|
||
manualOverlayCtx.lineWidth = 2;
|
||
manualOverlayCtx.stroke();
|
||
}
|
||
|
||
function markManualResultDirty() {
|
||
lastManualResult = null;
|
||
|
||
const saveBtn = document.getElementById('saveManualAreaBtn');
|
||
|
||
if (saveBtn) {
|
||
saveBtn.disabled = true;
|
||
}
|
||
|
||
const dxfBtn = document.getElementById('exportDxfBtn');
|
||
|
||
if (dxfBtn) {
|
||
dxfBtn.disabled = true;
|
||
}
|
||
}
|
||
|
||
function isSelectedPoint(type, holeIndex, pointIndex) {
|
||
if (!selectedEditPoint) {
|
||
return false;
|
||
}
|
||
|
||
return (
|
||
selectedEditPoint.type === type &&
|
||
selectedEditPoint.pointIndex === pointIndex &&
|
||
(
|
||
type !== 'hole' ||
|
||
selectedEditPoint.holeIndex === holeIndex
|
||
)
|
||
);
|
||
}
|
||
|
||
function getSelectedPointLabel(selection) {
|
||
if (!selection) {
|
||
return 'nessun punto';
|
||
}
|
||
|
||
if (selection.type === 'outer') {
|
||
return `contorno esterno, punto ${selection.pointIndex + 1}`;
|
||
}
|
||
|
||
if (selection.type === 'hole') {
|
||
return `esclusione ${selection.holeIndex + 1}, punto ${selection.pointIndex + 1}`;
|
||
}
|
||
|
||
if (selection.type === 'currentHole') {
|
||
return `esclusione attiva, punto ${selection.pointIndex + 1}`;
|
||
}
|
||
|
||
return 'punto';
|
||
}
|
||
|
||
function getEditablePointList() {
|
||
const refs = [];
|
||
|
||
polygonPoints.forEach((point, pointIndex) => {
|
||
refs.push({
|
||
type: 'outer',
|
||
holeIndex: null,
|
||
pointIndex: pointIndex,
|
||
point: point
|
||
});
|
||
});
|
||
|
||
holes.forEach((hole, holeIndex) => {
|
||
hole.forEach((point, pointIndex) => {
|
||
refs.push({
|
||
type: 'hole',
|
||
holeIndex: holeIndex,
|
||
pointIndex: pointIndex,
|
||
point: point
|
||
});
|
||
});
|
||
});
|
||
|
||
currentHolePoints.forEach((point, pointIndex) => {
|
||
refs.push({
|
||
type: 'currentHole',
|
||
holeIndex: null,
|
||
pointIndex: pointIndex,
|
||
point: point
|
||
});
|
||
});
|
||
|
||
return refs;
|
||
}
|
||
|
||
function findNearestEditablePoint(pos, radius) {
|
||
let best = null;
|
||
let bestDistance = radius;
|
||
|
||
getEditablePointList().forEach(ref => {
|
||
const d = distance(pos, ref.point);
|
||
|
||
if (d <= bestDistance) {
|
||
bestDistance = d;
|
||
best = {
|
||
type: ref.type,
|
||
holeIndex: ref.holeIndex,
|
||
pointIndex: ref.pointIndex
|
||
};
|
||
}
|
||
});
|
||
|
||
return best;
|
||
}
|
||
|
||
function getPointArrayBySelection(selection) {
|
||
if (!selection) {
|
||
return null;
|
||
}
|
||
|
||
if (selection.type === 'outer') {
|
||
return polygonPoints;
|
||
}
|
||
|
||
if (selection.type === 'hole') {
|
||
return holes[selection.holeIndex] || null;
|
||
}
|
||
|
||
if (selection.type === 'currentHole') {
|
||
return currentHolePoints;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function updateSelectedPointPosition(pos) {
|
||
const arr = getPointArrayBySelection(selectedEditPoint);
|
||
|
||
if (!arr || !selectedEditPoint) {
|
||
return;
|
||
}
|
||
|
||
arr[selectedEditPoint.pointIndex] = {
|
||
x: pos.x,
|
||
y: pos.y
|
||
};
|
||
}
|
||
|
||
function deleteSelectedPoint() {
|
||
if (!selectedEditPoint) {
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Nessun punto selezionato',
|
||
text: 'Clicca prima un punto in modalità “Modifica punti”.'
|
||
});
|
||
|
||
return;
|
||
}
|
||
|
||
const arr = getPointArrayBySelection(selectedEditPoint);
|
||
|
||
if (!arr) {
|
||
selectedEditPoint = null;
|
||
redrawManualOverlay();
|
||
return;
|
||
}
|
||
|
||
if (selectedEditPoint.type === 'outer' && arr.length <= 3) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Non posso eliminare',
|
||
text: 'Il contorno esterno deve avere almeno 3 punti.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (selectedEditPoint.type === 'hole' && arr.length <= 3) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Non posso eliminare',
|
||
text: 'Una esclusione chiusa deve avere almeno 3 punti. Usa “Annulla punto” in modalità esclusione per rimuovere l’intera esclusione.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
arr.splice(selectedEditPoint.pointIndex, 1);
|
||
|
||
selectedEditPoint = null;
|
||
markManualResultDirty();
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
|
||
setManualStatus('Punto eliminato. Ricontrolla il profilo e calcola di nuovo l’area.');
|
||
}
|
||
|
||
function pointToSegmentDistance(p, a, b) {
|
||
const dx = b.x - a.x;
|
||
const dy = b.y - a.y;
|
||
|
||
if (dx === 0 && dy === 0) {
|
||
return distance(p, a);
|
||
}
|
||
|
||
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy);
|
||
t = Math.max(0, Math.min(1, t));
|
||
|
||
const projection = {
|
||
x: a.x + t * dx,
|
||
y: a.y + t * dy
|
||
};
|
||
|
||
return distance(p, projection);
|
||
}
|
||
|
||
function findNearestSegment(pos, radius) {
|
||
const candidates = [];
|
||
|
||
function addSegments(points, type, holeIndex, closed) {
|
||
if (!points || points.length < 2) {
|
||
return;
|
||
}
|
||
|
||
const limit = closed ? points.length : points.length - 1;
|
||
|
||
for (let i = 0; i < limit; i++) {
|
||
const nextIndex = (i + 1) % points.length;
|
||
|
||
if (!closed && nextIndex === 0) {
|
||
continue;
|
||
}
|
||
|
||
candidates.push({
|
||
type: type,
|
||
holeIndex: holeIndex,
|
||
insertIndex: i + 1,
|
||
a: points[i],
|
||
b: points[nextIndex],
|
||
distance: pointToSegmentDistance(pos, points[i], points[nextIndex])
|
||
});
|
||
}
|
||
}
|
||
|
||
addSegments(polygonPoints, 'outer', null, polygonPoints.length >= 3);
|
||
|
||
holes.forEach((hole, holeIndex) => {
|
||
addSegments(hole, 'hole', holeIndex, hole.length >= 3);
|
||
});
|
||
|
||
addSegments(currentHolePoints, 'currentHole', null, false);
|
||
|
||
let best = null;
|
||
|
||
candidates.forEach(candidate => {
|
||
if (candidate.distance <= radius && (!best || candidate.distance < best.distance)) {
|
||
best = candidate;
|
||
}
|
||
});
|
||
|
||
return best;
|
||
}
|
||
|
||
function insertPointOnNearestSegment(pos, radius) {
|
||
const segment = findNearestSegment(pos, radius);
|
||
|
||
if (!segment) {
|
||
return false;
|
||
}
|
||
|
||
let arr = null;
|
||
|
||
if (segment.type === 'outer') {
|
||
arr = polygonPoints;
|
||
} else if (segment.type === 'hole') {
|
||
arr = holes[segment.holeIndex];
|
||
} else if (segment.type === 'currentHole') {
|
||
arr = currentHolePoints;
|
||
}
|
||
|
||
if (!arr) {
|
||
return false;
|
||
}
|
||
|
||
const finalPos = getSnappedPoint(pos);
|
||
|
||
arr.splice(segment.insertIndex, 0, {
|
||
x: finalPos.x,
|
||
y: finalPos.y
|
||
});
|
||
|
||
selectedEditPoint = {
|
||
type: segment.type,
|
||
holeIndex: segment.holeIndex,
|
||
pointIndex: segment.insertIndex
|
||
};
|
||
|
||
return true;
|
||
}
|
||
|
||
|
||
|
||
function proposeAutoContour() {
|
||
if (!manualPdfCanvas || !manualOverlayCanvas) {
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Canvas non pronto',
|
||
text: 'Apri prima il PDF nel tracciamento manuale.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (manualCurrentView !== 'roi') {
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Prima fai Zoom su ROI',
|
||
text: 'Per l’autocontorno devi prima disegnare la ROI principale e cliccare “Zoom su ROI”.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!autoContourRoiRect || autoContourRoiRect.w <= 0 || autoContourRoiRect.h <= 0) {
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Definisci ROI autocontorno',
|
||
text: 'Clicca “ROI autocontorno” e disegna un rettangolo stretto solo attorno al profilo, senza quote, testi o frecce.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
sendAutoContourRoiToPython();
|
||
}
|
||
|
||
function sendAutoContourRoiToPython() {
|
||
if (!mmPerPx) {
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Prima calibra la scala',
|
||
text: 'Consiglio: calibra prima una quota, poi proponi il contorno. Il contorno può essere generato anche senza scala, ma il calcolo area richiede calibrazione.'
|
||
});
|
||
}
|
||
|
||
setManualStatus('Analisi automatica del contorno sulla ROI autocontorno...');
|
||
showOverlay();
|
||
|
||
const cropCanvas = document.createElement('canvas');
|
||
const cropCtx = cropCanvas.getContext('2d');
|
||
|
||
const sx = Math.max(0, Math.floor(autoContourRoiRect.x));
|
||
const sy = Math.max(0, Math.floor(autoContourRoiRect.y));
|
||
const sw = Math.min(
|
||
manualPdfCanvas.width - sx,
|
||
Math.max(1, Math.ceil(autoContourRoiRect.w))
|
||
);
|
||
const sh = Math.min(
|
||
manualPdfCanvas.height - sy,
|
||
Math.max(1, Math.ceil(autoContourRoiRect.h))
|
||
);
|
||
|
||
cropCanvas.width = sw;
|
||
cropCanvas.height = sh;
|
||
|
||
cropCtx.drawImage(
|
||
manualPdfCanvas,
|
||
sx,
|
||
sy,
|
||
sw,
|
||
sh,
|
||
0,
|
||
0,
|
||
sw,
|
||
sh
|
||
);
|
||
|
||
cropCanvas.toBlob(function(blob) {
|
||
if (!blob) {
|
||
hideOverlay();
|
||
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Errore immagine',
|
||
text: 'Impossibile generare il crop della ROI autocontorno.'
|
||
});
|
||
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('image', blob, 'auto_contour_roi.png');
|
||
formData.append('max_points', '90');
|
||
|
||
fetch('http://127.0.0.1:5055/auto-contour-image', {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
.then(async response => {
|
||
const text = await response.text();
|
||
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch (e) {
|
||
console.error('Risposta non JSON da Python auto-contour:', text);
|
||
|
||
throw new Error(
|
||
'Il servizio Python non ha restituito JSON. Prima parte risposta: ' +
|
||
text.substring(0, 180)
|
||
);
|
||
}
|
||
})
|
||
.then(data => {
|
||
hideOverlay();
|
||
|
||
if (!data.success) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Contorno non trovato',
|
||
text: data.message || 'Python non è riuscito a proporre un contorno affidabile.'
|
||
});
|
||
|
||
setManualStatus('Contorno automatico non trovato. Prova una ROI autocontorno più stretta/pulita oppure procedi manualmente.');
|
||
return;
|
||
}
|
||
|
||
if (!Array.isArray(data.outer_polygon) || data.outer_polygon.length < 3) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Contorno non valido',
|
||
text: 'Il contorno proposto non contiene abbastanza punti.'
|
||
});
|
||
|
||
setManualStatus('Contorno automatico non valido. Procedi con il tracciamento manuale.');
|
||
return;
|
||
}
|
||
|
||
applyAutoContourProposal(data);
|
||
})
|
||
.catch(error => {
|
||
hideOverlay();
|
||
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Errore auto-contorno',
|
||
text: error.message || 'Errore durante la proposta del contorno.'
|
||
});
|
||
|
||
setManualStatus('Errore durante l’auto-contorno. Procedi manualmente.');
|
||
});
|
||
}, 'image/png');
|
||
}
|
||
|
||
function applyAutoContourProposal(data) {
|
||
const previousPolygon = polygonPoints.map(p => ({
|
||
x: p.x,
|
||
y: p.y
|
||
}));
|
||
const previousSelected = selectedEditPoint ? {
|
||
...selectedEditPoint
|
||
} : null;
|
||
|
||
const baseRect = autoContourRoiRect || {
|
||
x: 0,
|
||
y: 0,
|
||
w: manualOverlayCanvas.width,
|
||
h: manualOverlayCanvas.height
|
||
};
|
||
|
||
const proposedPolygon = data.outer_polygon.map(point => ({
|
||
x: baseRect.x + point.x * baseRect.w,
|
||
y: baseRect.y + point.y * baseRect.h
|
||
}));
|
||
|
||
polygonPoints = proposedPolygon;
|
||
selectedEditPoint = null;
|
||
currentHolePoints = [];
|
||
lastManualResult = null;
|
||
document.getElementById('saveManualAreaBtn').disabled = true;
|
||
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
|
||
const diagnostics = data.diagnostics || {};
|
||
const pointsCount = polygonPoints.length;
|
||
const method = diagnostics.method || 'opencv';
|
||
|
||
Swal.fire({
|
||
icon: 'question',
|
||
title: 'Contorno proposto',
|
||
html: `Ho trovato un possibile contorno esterno con <strong>${pointsCount}</strong> punti.<br>` +
|
||
`Metodo: <strong>${escapeHtml(String(method))}</strong><br><br>` +
|
||
`Vuoi usarlo come profilo esterno e poi modificarlo con “Modifica punti”?`,
|
||
showCancelButton: true,
|
||
confirmButtonText: 'Sì, usa questo contorno',
|
||
cancelButtonText: 'No, annulla'
|
||
}).then(result => {
|
||
if (!result.isConfirmed) {
|
||
polygonPoints = previousPolygon;
|
||
selectedEditPoint = previousSelected;
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
setManualStatus('Proposta contorno annullata. Puoi tracciare manualmente o riprovare.');
|
||
return;
|
||
}
|
||
|
||
markManualResultDirty();
|
||
setTool('edit');
|
||
setManualStatus('Contorno automatico caricato. Usa “Modifica punti” per spostare/eliminare/inserire punti, poi calcola l’area.');
|
||
});
|
||
}
|
||
|
||
|
||
function calculateManualArea() {
|
||
if (!mmPerPx || !calibrationMm || !calibrationPx) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Calibrazione mancante',
|
||
text: 'Prima calibra una quota reale.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (polygonPoints.length < 3) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Profilo incompleto',
|
||
text: 'Servono almeno 3 punti per calcolare l’area esterna.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (currentHolePoints.length > 0) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Esclusione non chiusa',
|
||
text: 'Hai iniziato un’area da escludere ma non l’hai chiusa. Clicca “Chiudi esclusione” oppure annulla i punti.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const outerAreaPx2 = polygonAreaPx2(polygonPoints);
|
||
|
||
let holesAreaPx2 = 0;
|
||
|
||
holes.forEach(hole => {
|
||
if (hole.length >= 3) {
|
||
holesAreaPx2 += polygonAreaPx2(hole);
|
||
}
|
||
});
|
||
|
||
const finalAreaPx2 = outerAreaPx2 - holesAreaPx2;
|
||
|
||
if (finalAreaPx2 <= 0) {
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Area non valida',
|
||
text: 'Le aree da escludere sono maggiori o uguali all’area esterna.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const outerAreaMm2 = outerAreaPx2 * mmPerPx * mmPerPx;
|
||
const holesAreaMm2 = holesAreaPx2 * mmPerPx * mmPerPx;
|
||
const finalAreaMm2 = finalAreaPx2 * mmPerPx * mmPerPx;
|
||
const finalAreaCm2 = finalAreaMm2 / 100;
|
||
|
||
const bbox = polygonBoundingBox(polygonPoints);
|
||
const widthMm = bbox.width * mmPerPx;
|
||
const heightMm = bbox.height * mmPerPx;
|
||
|
||
lastManualResult = {
|
||
outer_area_px2: outerAreaPx2,
|
||
holes_area_px2: holesAreaPx2,
|
||
final_area_px2: finalAreaPx2,
|
||
|
||
outer_area_mm2: outerAreaMm2,
|
||
holes_area_mm2: holesAreaMm2,
|
||
|
||
area_mm2: finalAreaMm2,
|
||
area_cm2: finalAreaCm2,
|
||
|
||
width_mm: widthMm,
|
||
height_mm: heightMm
|
||
};
|
||
|
||
document.getElementById('saveManualAreaBtn').disabled = false;
|
||
document.getElementById('exportDxfBtn').disabled = false;
|
||
|
||
setManualStatus(
|
||
`Area finale: ${finalAreaMm2.toFixed(3)} mm² = ${finalAreaCm2.toFixed(4)} cm². ` +
|
||
`Esterna: ${outerAreaMm2.toFixed(3)} mm² - Esclusioni: ${holesAreaMm2.toFixed(3)} mm².`
|
||
);
|
||
|
||
document.getElementById('manualCoordsPreview').innerText =
|
||
`Area finale: ${finalAreaMm2.toFixed(3)} mm² | Esclusioni: ${holes.length} | Calibrazione: ${mmPerPx.toFixed(6)} mm/px`;
|
||
}
|
||
|
||
function saveManualArea() {
|
||
if (!lastManualResult) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Area non calcolata',
|
||
text: 'Prima calcola l’area.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
let dxfContent = '';
|
||
|
||
try {
|
||
dxfContent = buildDxfString();
|
||
} catch (e) {
|
||
console.warn('DXF build failed:', e);
|
||
}
|
||
|
||
const payload = {
|
||
id: currentManualJobId,
|
||
dxf_content: dxfContent,
|
||
|
||
area_mm2: +lastManualResult.area_mm2.toFixed(6),
|
||
area_cm2: +lastManualResult.area_cm2.toFixed(6),
|
||
|
||
manual_outer_area_mm2: +lastManualResult.outer_area_mm2.toFixed(6),
|
||
manual_holes_area_mm2: +lastManualResult.holes_area_mm2.toFixed(6),
|
||
|
||
width_mm: +lastManualResult.width_mm.toFixed(6),
|
||
height_mm: +lastManualResult.height_mm.toFixed(6),
|
||
|
||
manual_calibration_px: +calibrationPx.toFixed(6),
|
||
manual_calibration_mm: +calibrationMm.toFixed(6),
|
||
manual_mm_per_px: +mmPerPx.toFixed(10),
|
||
|
||
manual_polygon: normalizePoints(polygonPoints),
|
||
manual_holes: holes.map(hole => normalizePoints(hole)),
|
||
|
||
calibration: normalizePoints(calibrationPoints),
|
||
roi: getNormalizedRoi(),
|
||
canvas: {
|
||
width: manualOverlayCanvas.width,
|
||
height: manualOverlayCanvas.height
|
||
}
|
||
};
|
||
|
||
showOverlay();
|
||
|
||
fetch('cad_area_save_manual_area.php', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(payload)
|
||
})
|
||
.then(async response => {
|
||
const text = await response.text();
|
||
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch (e) {
|
||
console.error('Risposta non JSON:', text);
|
||
|
||
throw new Error(
|
||
'Il server non ha restituito JSON. Prima parte risposta: ' +
|
||
text.substring(0, 180)
|
||
);
|
||
}
|
||
})
|
||
.then(data => {
|
||
hideOverlay();
|
||
|
||
if (!data.success) {
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Errore salvataggio',
|
||
text: data.message || 'Impossibile salvare area manuale.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
Swal.fire({
|
||
icon: 'success',
|
||
title: 'Area salvata',
|
||
html: `Area finale: <strong>${parseFloat(data.area_mm2).toFixed(3)} mm²</strong><br>` +
|
||
`Area esclusioni: <strong>${parseFloat(data.holes_area_mm2 || 0).toFixed(3)} mm²</strong>`
|
||
}).then(() => {
|
||
location.reload();
|
||
});
|
||
})
|
||
.catch(error => {
|
||
hideOverlay();
|
||
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Errore',
|
||
text: error.message || 'Errore durante il salvataggio.'
|
||
});
|
||
});
|
||
}
|
||
|
||
function toMmPoint(p) {
|
||
return {
|
||
x: p.x * mmPerPx,
|
||
y: (manualOverlayCanvas.height - p.y) * mmPerPx
|
||
};
|
||
}
|
||
|
||
function dxfPolyline(points, layer) {
|
||
let s = '0\nPOLYLINE\n8\n' + layer + '\n66\n1\n70\n1\n';
|
||
|
||
points.forEach(p => {
|
||
const m = toMmPoint(p);
|
||
s += '0\nVERTEX\n8\n' + layer +
|
||
'\n10\n' + m.x.toFixed(4) +
|
||
'\n20\n' + m.y.toFixed(4) +
|
||
'\n30\n0.0\n';
|
||
});
|
||
|
||
return s + '0\nSEQEND\n';
|
||
}
|
||
|
||
function buildDxfString() {
|
||
let dxf = '0\nSECTION\n2\nHEADER\n9\n$INSUNITS\n70\n4\n0\nENDSEC\n';
|
||
dxf += '0\nSECTION\n2\nENTITIES\n';
|
||
dxf += dxfPolyline(polygonPoints, 'PROFILO');
|
||
|
||
holes.forEach(hole => {
|
||
if (hole.length >= 3) {
|
||
dxf += dxfPolyline(hole, 'ESCLUSIONI');
|
||
}
|
||
});
|
||
|
||
dxf += '0\nENDSEC\n0\nEOF\n';
|
||
return dxf;
|
||
}
|
||
|
||
function exportDxf() {
|
||
if (!lastManualResult || !mmPerPx || polygonPoints.length < 3) {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Prima calcola l\u2019area',
|
||
text: 'Il DXF viene generato dal profilo calibrato.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
const dxf = buildDxfString();
|
||
|
||
const blob = new Blob([dxf], {
|
||
type: 'application/dxf'
|
||
});
|
||
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'profilo_job_' + currentManualJobId + '.dxf';
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
}
|
||
|
||
document.getElementById('exportDxfBtn').addEventListener('click', function() {
|
||
exportDxf();
|
||
});
|
||
|
||
function polygonAreaPx2(points) {
|
||
let area = 0;
|
||
|
||
for (let i = 0; i < points.length; i++) {
|
||
const j = (i + 1) % points.length;
|
||
area += points[i].x * points[j].y;
|
||
area -= points[j].x * points[i].y;
|
||
}
|
||
|
||
return Math.abs(area / 2);
|
||
}
|
||
|
||
function polygonBoundingBox(points) {
|
||
const xs = points.map(p => p.x);
|
||
const ys = points.map(p => p.y);
|
||
|
||
const minX = Math.min(...xs);
|
||
const maxX = Math.max(...xs);
|
||
const minY = Math.min(...ys);
|
||
const maxY = Math.max(...ys);
|
||
|
||
return {
|
||
x: minX,
|
||
y: minY,
|
||
width: maxX - minX,
|
||
height: maxY - minY
|
||
};
|
||
}
|
||
|
||
function normalizePoints(points) {
|
||
return points.map(point => ({
|
||
x: +(point.x / manualOverlayCanvas.width).toFixed(8),
|
||
y: +(point.y / manualOverlayCanvas.height).toFixed(8)
|
||
}));
|
||
}
|
||
|
||
function getNormalizedRoi() {
|
||
if (manualCurrentView === 'roi' && manualRoiPageRect && manualPdfPage) {
|
||
const viewportOriginal = manualPdfPage.getViewport({
|
||
scale: 1
|
||
});
|
||
|
||
return {
|
||
x: +(manualRoiPageRect.x / viewportOriginal.width).toFixed(8),
|
||
y: +(manualRoiPageRect.y / viewportOriginal.height).toFixed(8),
|
||
width: +(manualRoiPageRect.w / viewportOriginal.width).toFixed(8),
|
||
height: +(manualRoiPageRect.h / viewportOriginal.height).toFixed(8)
|
||
};
|
||
}
|
||
|
||
if (!roiRect) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
x: +(roiRect.x / manualOverlayCanvas.width).toFixed(8),
|
||
y: +(roiRect.y / manualOverlayCanvas.height).toFixed(8),
|
||
width: +(roiRect.w / manualOverlayCanvas.width).toFixed(8),
|
||
height: +(roiRect.h / manualOverlayCanvas.height).toFixed(8)
|
||
};
|
||
}
|
||
|
||
function updateManualPreview() {
|
||
const parts = [];
|
||
|
||
if (manualCurrentView === 'roi') {
|
||
parts.push('Vista ROI ingrandita');
|
||
} else if (roiRect) {
|
||
parts.push(`ROI: ${Math.round(roiRect.w)}x${Math.round(roiRect.h)} px`);
|
||
}
|
||
|
||
if (autoContourRoiRect) {
|
||
parts.push(`ROI autocontorno: ${Math.round(autoContourRoiRect.w)}x${Math.round(autoContourRoiRect.h)} px`);
|
||
}
|
||
|
||
if (mmPerPx) {
|
||
parts.push(`Scala: ${mmPerPx.toFixed(6)} mm/px`);
|
||
}
|
||
|
||
if (polygonPoints.length > 0) {
|
||
parts.push(`Punti esterno: ${polygonPoints.length}`);
|
||
}
|
||
|
||
if (holes.length > 0) {
|
||
parts.push(`Esclusioni chiuse: ${holes.length}`);
|
||
}
|
||
|
||
if (currentHolePoints.length > 0) {
|
||
parts.push(`Punti esclusione attiva: ${currentHolePoints.length}`);
|
||
}
|
||
|
||
if (selectedEditPoint) {
|
||
parts.push(`Selezionato: ${getSelectedPointLabel(selectedEditPoint)}`);
|
||
}
|
||
|
||
document.getElementById('manualCoordsPreview').innerText =
|
||
parts.length ? parts.join(' | ') : 'Nessun dato calcolato.';
|
||
}
|
||
|
||
function segmentDarkRatio(a, b) {
|
||
const steps = Math.max(4, Math.round(Math.hypot(b.x - a.x, b.y - a.y)));
|
||
let dark = 0;
|
||
|
||
for (let i = 1; i <= steps; i++) {
|
||
const t = i / steps;
|
||
const x = Math.round(a.x + (b.x - a.x) * t);
|
||
const y = Math.round(a.y + (b.y - a.y) * t);
|
||
|
||
let found = false;
|
||
|
||
for (let yy = -1; yy <= 1 && !found; yy++) {
|
||
for (let xx = -1; xx <= 1 && !found; xx++) {
|
||
const px = x + xx;
|
||
const py = y + yy;
|
||
|
||
if (px < 0 || py < 0 || px >= edgeGrayW || py >= edgeGrayH) {
|
||
continue;
|
||
}
|
||
|
||
if (edgeGray[py * edgeGrayW + px] <= EDGE_DARK_THRESHOLD) {
|
||
found = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (found) {
|
||
dark++;
|
||
}
|
||
}
|
||
|
||
return dark / steps;
|
||
}
|
||
|
||
function brushWalkAlongEdge(fromPoint, mousePos) {
|
||
if (edgeCacheDirty || !edgeGray) {
|
||
rebuildEdgeCache();
|
||
}
|
||
|
||
if (!edgeGray) {
|
||
return null;
|
||
}
|
||
|
||
const w = edgeGrayW;
|
||
const h = edgeGrayH;
|
||
|
||
const dirX = mousePos.x - fromPoint.x;
|
||
const dirY = mousePos.y - fromPoint.y;
|
||
const dirLen = Math.hypot(dirX, dirY);
|
||
|
||
if (dirLen < 0.001) {
|
||
return null;
|
||
}
|
||
|
||
const ndx = dirX / dirLen;
|
||
const ndy = dirY / dirLen;
|
||
|
||
const fx = Math.round(fromPoint.x);
|
||
const fy = Math.round(fromPoint.y);
|
||
|
||
let sx = -1;
|
||
let sy = -1;
|
||
let sBest = Infinity;
|
||
|
||
for (let y = Math.max(0, fy - 6); y <= Math.min(h - 1, fy + 6); y++) {
|
||
for (let x = Math.max(0, fx - 6); x <= Math.min(w - 1, fx + 6); x++) {
|
||
if (edgeGray[y * w + x] > EDGE_DARK_THRESHOLD) {
|
||
continue;
|
||
}
|
||
|
||
const d = (x - fromPoint.x) * (x - fromPoint.x) + (y - fromPoint.y) * (y - fromPoint.y);
|
||
|
||
if (d < sBest) {
|
||
sBest = d;
|
||
sx = x;
|
||
sy = y;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (sx < 0) {
|
||
return null;
|
||
}
|
||
|
||
const R = Math.ceil(BRUSH_STEP * 1.8);
|
||
const x0 = Math.max(0, sx - R);
|
||
const y0 = Math.max(0, sy - R);
|
||
const x1 = Math.min(w - 1, sx + R);
|
||
const y1 = Math.min(h - 1, sy + R);
|
||
const lw = x1 - x0 + 1;
|
||
|
||
const visited = new Uint8Array(lw * (y1 - y0 + 1));
|
||
let queue = [sx, sy];
|
||
visited[(sy - y0) * lw + (sx - x0)] = 1;
|
||
|
||
let best = null;
|
||
let bestScore = -Infinity;
|
||
const minDist = BRUSH_STEP * 0.75;
|
||
|
||
while (queue.length) {
|
||
const next = [];
|
||
|
||
for (let q = 0; q < queue.length; q += 2) {
|
||
const cx = queue[q];
|
||
const cy = queue[q + 1];
|
||
|
||
const ddx = cx - fromPoint.x;
|
||
const ddy = cy - fromPoint.y;
|
||
const dist = Math.hypot(ddx, ddy);
|
||
|
||
if (dist >= minDist) {
|
||
const forward = (ddx * ndx + ddy * ndy) / dist;
|
||
|
||
if (forward > 0.05) {
|
||
const score = forward * 10 - Math.abs(dist - BRUSH_STEP) * 0.5;
|
||
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
best = {
|
||
x: cx,
|
||
y: cy
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
for (let oy = -1; oy <= 1; oy++) {
|
||
for (let ox = -1; ox <= 1; ox++) {
|
||
if (ox === 0 && oy === 0) {
|
||
continue;
|
||
}
|
||
|
||
const nx = cx + ox;
|
||
const ny = cy + oy;
|
||
|
||
if (nx < x0 || ny < y0 || nx > x1 || ny > y1) {
|
||
continue;
|
||
}
|
||
|
||
const vi = (ny - y0) * lw + (nx - x0);
|
||
|
||
if (visited[vi]) {
|
||
continue;
|
||
}
|
||
|
||
if (edgeGray[ny * w + nx] > EDGE_DARK_THRESHOLD) {
|
||
continue;
|
||
}
|
||
|
||
visited[vi] = 1;
|
||
next.push(nx, ny);
|
||
}
|
||
}
|
||
}
|
||
|
||
queue = next;
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
function simplifyRdp(points, epsilon) {
|
||
if (points.length < 3) {
|
||
return points;
|
||
}
|
||
|
||
let maxDist = 0;
|
||
let maxIndex = 0;
|
||
|
||
for (let i = 1; i < points.length - 1; i++) {
|
||
const d = pointToSegmentDistance(points[i], points[0], points[points.length - 1]);
|
||
|
||
if (d > maxDist) {
|
||
maxDist = d;
|
||
maxIndex = i;
|
||
}
|
||
}
|
||
|
||
if (maxDist > epsilon) {
|
||
const left = simplifyRdp(points.slice(0, maxIndex + 1), epsilon);
|
||
const right = simplifyRdp(points.slice(maxIndex), epsilon);
|
||
return left.slice(0, -1).concat(right);
|
||
}
|
||
|
||
return [points[0], points[points.length - 1]];
|
||
}
|
||
|
||
function setTool(tool) {
|
||
currentTool = tool;
|
||
|
||
document.getElementById('toolRoiBtn').classList.remove('active');
|
||
document.getElementById('toolCalibrationBtn').classList.remove('active');
|
||
document.getElementById('toolPolygonBtn').classList.remove('active');
|
||
|
||
const autoRoiBtn = document.getElementById('toolAutoContourRoiBtn');
|
||
if (autoRoiBtn) {
|
||
autoRoiBtn.classList.remove('active');
|
||
}
|
||
const brushBtn = document.getElementById('toolBrushBtn');
|
||
if (brushBtn) {
|
||
brushBtn.classList.remove('active');
|
||
}
|
||
|
||
isBrushing = false;
|
||
lastBrushPoint = null;
|
||
const holeBtn = document.getElementById('toolHoleBtn');
|
||
if (holeBtn) {
|
||
holeBtn.classList.remove('active');
|
||
}
|
||
|
||
const editBtn = document.getElementById('toolEditBtn');
|
||
if (editBtn) {
|
||
editBtn.classList.remove('active');
|
||
}
|
||
|
||
if (tool !== 'edit') {
|
||
selectedEditPoint = null;
|
||
isDraggingEditPoint = false;
|
||
editDragMoved = false;
|
||
}
|
||
|
||
if (tool === 'roi') {
|
||
document.getElementById('toolRoiBtn').classList.add('active');
|
||
|
||
if (manualCurrentView === 'roi') {
|
||
setManualStatus('Vista ROI ingrandita: puoi calibrare una quota o disegnare una sotto-ROI se necessario.');
|
||
} else {
|
||
setManualStatus('Modalità ROI: disegna un rettangolo intorno alla sezione.');
|
||
}
|
||
}
|
||
|
||
if (tool === 'calibration') {
|
||
document.getElementById('toolCalibrationBtn').classList.add('active');
|
||
setManualStatus('Modalità calibrazione: clicca due punti su una quota nota, poi inserisci il valore reale in mm.');
|
||
}
|
||
|
||
if (tool === 'polygon') {
|
||
document.getElementById('toolPolygonBtn').classList.add('active');
|
||
setManualStatus('Modalità profilo esterno: clicca i punti del contorno esterno. Il poligono viene chiuso automaticamente.');
|
||
}
|
||
if (tool === 'brush') {
|
||
document.getElementById('toolBrushBtn').classList.add('active');
|
||
setManualStatus('Pennello magnetico: tieni premuto e trascina lungo il bordo. I punti si agganciano al tratto scuro. Rilascia per chiudere il tratto, poi rifinisci con "Modifica punti".');
|
||
}
|
||
if (tool === 'auto_roi') {
|
||
document.getElementById('toolAutoContourRoiBtn').classList.add('active');
|
||
setManualStatus('Modalità ROI autocontorno: disegna un rettangolo stretto solo attorno al profilo, escludendo quote, testi e frecce.');
|
||
}
|
||
|
||
if (tool === 'hole') {
|
||
document.getElementById('toolHoleBtn').classList.add('active');
|
||
setManualStatus('Modalità area da escludere: clicca i punti del foro/cavità da sottrarre, poi clicca “Chiudi esclusione”.');
|
||
}
|
||
|
||
if (tool === 'edit') {
|
||
document.getElementById('toolEditBtn').classList.add('active');
|
||
setManualStatus('Modalità modifica punti: clicca e trascina un punto per spostarlo. Doppio click su un segmento per aggiungere un punto. Usa “Elimina punto” per cancellare quello selezionato.');
|
||
}
|
||
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
}
|
||
|
||
function setManualStatus(text) {
|
||
document.getElementById('manualStatusBox').innerText = text;
|
||
}
|
||
|
||
function getCanvasMousePosition(event, canvas) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
|
||
const scaleX = canvas.width / rect.width;
|
||
const scaleY = canvas.height / rect.height;
|
||
|
||
return {
|
||
x: (event.clientX - rect.left) * scaleX,
|
||
y: (event.clientY - rect.top) * scaleY
|
||
};
|
||
}
|
||
|
||
function distance(a, b) {
|
||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||
}
|
||
|
||
document.getElementById('toolRoiBtn').addEventListener('click', function() {
|
||
setTool('roi');
|
||
});
|
||
|
||
document.getElementById('toolCalibrationBtn').addEventListener('click', function() {
|
||
setTool('calibration');
|
||
});
|
||
|
||
document.getElementById('toolPolygonBtn').addEventListener('click', function() {
|
||
if (!mmPerPx) {
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Prima calibra la scala',
|
||
text: 'Clicca “Calibra quota” e seleziona due punti su una quota nota.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
setTool('polygon');
|
||
});
|
||
document.getElementById('toolBrushBtn').addEventListener('click', function() {
|
||
if (!mmPerPx) {
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Prima calibra la scala',
|
||
text: 'Clicca \u201cCalibra quota\u201d e seleziona due punti su una quota nota.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
rebuildEdgeCache();
|
||
setTool('brush');
|
||
});
|
||
document.getElementById('edgeSnapBtn').addEventListener('click', function() {
|
||
edgeSnapEnabled = !edgeSnapEnabled;
|
||
|
||
const btn = document.getElementById('edgeSnapBtn');
|
||
|
||
if (edgeSnapEnabled) {
|
||
btn.classList.remove('btn-outline-warning');
|
||
btn.classList.add('btn-warning', 'active');
|
||
btn.innerText = '🧲 ON';
|
||
|
||
rebuildEdgeCache();
|
||
|
||
setManualStatus(
|
||
'Magnete bordo attivo: quando disegni o sposti punti, il sistema prova ad agganciarli al bordo scuro più vicino.'
|
||
);
|
||
} else {
|
||
btn.classList.remove('btn-warning', 'active');
|
||
btn.classList.add('btn-outline-warning');
|
||
btn.innerText = '🧲 OFF';
|
||
|
||
edgeSnapPreviewPoint = null;
|
||
redrawManualOverlay();
|
||
|
||
setManualStatus(
|
||
'Magnete bordo disattivato: i punti verranno inseriti esattamente dove clicchi.'
|
||
);
|
||
}
|
||
});
|
||
|
||
document.getElementById('undoPointBtn').addEventListener('click', function() {
|
||
if (currentTool === 'hole' && currentHolePoints.length > 0) {
|
||
currentHolePoints.pop();
|
||
lastManualResult = null;
|
||
document.getElementById('saveManualAreaBtn').disabled = true;
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
setManualStatus('Ultimo punto dell’esclusione rimosso.');
|
||
return;
|
||
}
|
||
|
||
if (holes.length > 0 && currentTool === 'hole') {
|
||
holes.pop();
|
||
lastManualResult = null;
|
||
document.getElementById('saveManualAreaBtn').disabled = true;
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
setManualStatus('Ultima esclusione chiusa rimossa.');
|
||
return;
|
||
}
|
||
|
||
if (polygonPoints.length > 0) {
|
||
polygonPoints.pop();
|
||
lastManualResult = null;
|
||
document.getElementById('saveManualAreaBtn').disabled = true;
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
setManualStatus('Ultimo punto del profilo esterno rimosso.');
|
||
return;
|
||
}
|
||
|
||
if (calibrationPoints.length > 0) {
|
||
calibrationPoints.pop();
|
||
calibrationMm = null;
|
||
calibrationPx = null;
|
||
mmPerPx = null;
|
||
lastManualResult = null;
|
||
document.getElementById('saveManualAreaBtn').disabled = true;
|
||
redrawManualOverlay();
|
||
updateManualPreview();
|
||
setManualStatus('Punto di calibrazione rimosso.');
|
||
return;
|
||
}
|
||
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Nessun punto da annullare',
|
||
text: 'Non ci sono punti da rimuovere.'
|
||
});
|
||
});
|
||
|
||
document.getElementById('toolEditBtn').addEventListener('click', function() {
|
||
if (polygonPoints.length < 3 && holes.length === 0 && currentHolePoints.length === 0) {
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Nessun profilo da modificare',
|
||
text: 'Disegna prima il contorno esterno oppure una esclusione.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
setTool('edit');
|
||
selectedEditPoint = null;
|
||
redrawManualOverlay();
|
||
});
|
||
|
||
document.getElementById('deleteSelectedPointBtn').addEventListener('click', function() {
|
||
deleteSelectedPoint();
|
||
});
|
||
|
||
document.getElementById('toolHoleBtn').addEventListener('click', function() {
|
||
if (!mmPerPx) {
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Prima calibra la scala',
|
||
text: 'Clicca “Calibra quota” e seleziona due punti su una quota nota.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (polygonPoints.length < 3) {
|
||
Swal.fire({
|
||
icon: 'info',
|
||
title: 'Prima disegna il contorno esterno',
|
||
text: 'Disegna prima il profilo esterno, poi potrai indicare le aree da escludere.'
|
||
});
|
||
return;
|
||
}
|
||
|
||
setTool('hole');
|
||
});
|
||
|
||
document.getElementById('closeHoleBtn').addEventListener('click', function() {
|
||
closeCurrentHole();
|
||
});
|
||
|
||
document.getElementById('resetManualBtn').addEventListener('click', function() {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Reset tracciamento?',
|
||
text: 'Verranno cancellati ROI, calibrazione e profilo.',
|
||
showCancelButton: true,
|
||
confirmButtonText: 'Sì, reset',
|
||
cancelButtonText: 'Annulla'
|
||
}).then(result => {
|
||
if (!result.isConfirmed) {
|
||
return;
|
||
}
|
||
|
||
resetManualState();
|
||
redrawManualOverlay();
|
||
});
|
||
});
|
||
|
||
document.getElementById('zoomRoiBtn').addEventListener('click', function() {
|
||
renderManualRoiViewFromCurrentRoi();
|
||
});
|
||
|
||
|
||
|
||
document.getElementById('fullPageBtn').addEventListener('click', function() {
|
||
Swal.fire({
|
||
icon: 'warning',
|
||
title: 'Tornare alla pagina intera?',
|
||
text: 'Il tracciamento corrente verrà cancellato perché cambiano le coordinate del canvas.',
|
||
showCancelButton: true,
|
||
confirmButtonText: 'Sì, torna alla pagina intera',
|
||
cancelButtonText: 'Annulla'
|
||
}).then(result => {
|
||
if (!result.isConfirmed) {
|
||
return;
|
||
}
|
||
|
||
showOverlay();
|
||
|
||
renderManualFullPage(true).then(() => {
|
||
hideOverlay();
|
||
setTool('roi');
|
||
setManualStatus('Pagina intera ripristinata. Disegna una nuova ROI oppure ripeti il tracciamento.');
|
||
updateManualPreview();
|
||
}).catch(error => {
|
||
hideOverlay();
|
||
|
||
Swal.fire({
|
||
icon: 'error',
|
||
title: 'Errore',
|
||
text: error.message || 'Impossibile tornare alla pagina intera.'
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
document.getElementById('calculateManualBtn').addEventListener('click', function() {
|
||
calculateManualArea();
|
||
});
|
||
|
||
document.getElementById('saveManualAreaBtn').addEventListener('click', function() {
|
||
saveManualArea();
|
||
});
|
||
|
||
function showOverlay() {
|
||
$('#processingOverlay').css('display', 'flex');
|
||
}
|
||
|
||
function hideOverlay() {
|
||
$('#processingOverlay').hide();
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
return text.replace(/[&<>"']/g, function(match) {
|
||
return ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
})[match];
|
||
});
|
||
}
|
||
</script>
|
||
|
||
</body>
|
||
|
||
</html>
|