diff --git a/db/migrations/20260610123547_create_cad_area_jobs_table.php b/db/migrations/20260610123547_create_cad_area_jobs_table.php
new file mode 100644
index 0000000..0ce6be6
--- /dev/null
+++ b/db/migrations/20260610123547_create_cad_area_jobs_table.php
@@ -0,0 +1,95 @@
+table('cad_area_jobs');
+
+ $table
+ ->addColumn('iduser', 'integer', [
+ 'null' => true,
+ 'signed' => false,
+ 'limit' => 10,
+ ])
+ ->addColumn('original_filename', 'string', [
+ 'limit' => 255,
+ 'null' => false,
+ ])
+ ->addColumn('stored_filename', 'string', [
+ 'limit' => 255,
+ 'null' => false,
+ ])
+ ->addColumn('file_path', 'string', [
+ 'limit' => 500,
+ 'null' => false,
+ ])
+ ->addColumn('file_url', 'string', [
+ 'limit' => 500,
+ 'null' => true,
+ ])
+ ->addColumn('file_size', 'integer', [
+ 'null' => true,
+ 'signed' => false,
+ ])
+ ->addColumn('status', 'enum', [
+ 'values' => [
+ 'uploaded',
+ 'processing',
+ 'completed',
+ 'error',
+ ],
+ 'default' => 'uploaded',
+ 'null' => false,
+ ])
+ ->addColumn('area_mm2', 'decimal', [
+ 'precision' => 18,
+ 'scale' => 6,
+ 'null' => true,
+ ])
+ ->addColumn('area_cm2', 'decimal', [
+ 'precision' => 18,
+ 'scale' => 6,
+ 'null' => true,
+ ])
+ ->addColumn('area_m2', 'decimal', [
+ 'precision' => 18,
+ 'scale' => 9,
+ 'null' => true,
+ ])
+ ->addColumn('scale_detected', 'string', [
+ 'limit' => 50,
+ 'null' => true,
+ ])
+ ->addColumn('confidence', 'string', [
+ 'limit' => 50,
+ 'null' => true,
+ ])
+ ->addColumn('message', 'text', [
+ 'null' => true,
+ ])
+ ->addColumn('python_response', 'text', [
+ 'null' => true,
+ ])
+ ->addColumn('created_at', 'timestamp', [
+ 'default' => 'CURRENT_TIMESTAMP',
+ 'null' => true,
+ ])
+ ->addColumn('updated_at', 'timestamp', [
+ 'default' => 'CURRENT_TIMESTAMP',
+ 'update' => 'CURRENT_TIMESTAMP',
+ 'null' => true,
+ ])
+ ->addIndex(['iduser'], [
+ 'name' => 'idx_cad_area_jobs_iduser',
+ ])
+ ->addIndex(['status'], [
+ 'name' => 'idx_cad_area_jobs_status',
+ ])
+ ->create();
+ }
+}
diff --git a/public/userarea/cad-area.php b/public/userarea/cad-area.php
new file mode 100644
index 0000000..758b500
--- /dev/null
+++ b/public/userarea/cad-area.php
@@ -0,0 +1,655 @@
+
+getConnection();
+
+$iduser = $iduserlogin ?? null;
+
+if ($iduser === null) {
+ $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);
+?>
+
+
+
+
+
+
+
+
+ Calcolo Area CAD - = htmlspecialchars($titlewebsite, ENT_QUOTES, 'UTF-8'); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📐
+
Trascina qui uno o più PDF CAD
+
Oppure clicca per selezionare i file
+
Formati accettati: PDF - massimo 25 MB per file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ |
+ Nome PDF |
+ Stato |
+ Area mm² |
+ Area cm² |
+ Scala |
+ Confidenza |
+ Azioni |
+
+
+
+
+
+ Completato";
+ } elseif ($status === 'processing') {
+ $badge = "In lavorazione";
+ } elseif ($status === 'error') {
+ $badge = "Errore";
+ } else {
+ $badge = "Caricato";
+ }
+
+ $areaMm2 = $job['area_mm2'] !== null ? number_format((float)$job['area_mm2'], 3, ',', '.') : '-';
+ $areaCm2 = $job['area_cm2'] !== null ? number_format((float)$job['area_cm2'], 4, ',', '.') : '-';
+ $scale = $job['scale_detected'] ?: '-';
+ $confidence = $job['confidence'] ?: '-';
+ $fileUrl = htmlspecialchars($job['file_url'] ?? '', ENT_QUOTES, 'UTF-8');
+ ?>
+
+ |
+
+
+
+ |
+
+
+ = htmlspecialchars($job['original_filename'], ENT_QUOTES, 'UTF-8'); ?>
+ |
+
+ = $badge; ?> |
+ = $areaMm2; ?> |
+ = $areaCm2; ?> |
+ = htmlspecialchars($scale, ENT_QUOTES, 'UTF-8'); ?> |
+ = htmlspecialchars($confidence, ENT_QUOTES, 'UTF-8'); ?> |
+
+
+
+
+ 📄 Apri PDF
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Elaborazione in corso
+
Invio dei PDF al servizio di calcolo area...
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/userarea/cad_area_delete.php b/public/userarea/cad_area_delete.php
new file mode 100644
index 0000000..4483aeb
--- /dev/null
+++ b/public/userarea/cad_area_delete.php
@@ -0,0 +1,71 @@
+getConnection();
+
+ $iduser = $iduserlogin ?? null;
+
+ $input = json_decode(file_get_contents('php://input'), true);
+ $id = (int)($input['id'] ?? 0);
+
+ if ($id <= 0) {
+ throw new Exception('ID non valido.');
+ }
+
+ if ($iduser === null) {
+ $stmt = $pdo->prepare("
+ SELECT *
+ FROM cad_area_jobs
+ WHERE id = :id
+ LIMIT 1
+ ");
+
+ $stmt->execute([
+ ':id' => $id
+ ]);
+ } else {
+ $stmt = $pdo->prepare("
+ SELECT *
+ FROM cad_area_jobs
+ WHERE id = :id
+ AND iduser = :iduser
+ LIMIT 1
+ ");
+
+ $stmt->execute([
+ ':id' => $id,
+ ':iduser' => $iduser
+ ]);
+ }
+
+ $job = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if (!$job) {
+ throw new Exception('Record non trovato.');
+ }
+
+ if (!empty($job['file_path']) && file_exists($job['file_path'])) {
+ unlink($job['file_path']);
+ }
+
+ $stmtDelete = $pdo->prepare("
+ DELETE FROM cad_area_jobs
+ WHERE id = :id
+ ");
+ $stmtDelete->execute([':id' => $id]);
+
+ echo json_encode([
+ 'success' => true
+ ]);
+} catch (Throwable $e) {
+ error_log('CAD area delete error: ' . $e->getMessage());
+
+ echo json_encode([
+ 'success' => false,
+ 'message' => $e->getMessage()
+ ]);
+}
diff --git a/public/userarea/cad_area_process.php b/public/userarea/cad_area_process.php
new file mode 100644
index 0000000..ff720ff
--- /dev/null
+++ b/public/userarea/cad_area_process.php
@@ -0,0 +1,282 @@
+getConnection();
+
+ $iduser = $iduserlogin ?? null;
+
+ $input = json_decode(file_get_contents('php://input'), true);
+
+ if (empty($input['ids']) || !is_array($input['ids'])) {
+ throw new Exception('Nessun file selezionato.');
+ }
+
+ $ids = array_values(array_filter(array_map('intval', $input['ids'])));
+
+ if (empty($ids)) {
+ throw new Exception('ID non validi.');
+ }
+
+ /*
+ * Local Python service URL.
+ * In produzione su cPanel/Keliweb lo sostituiremo con l'URL reale del servizio Python.
+ */
+ $pythonServiceUrl = 'http://127.0.0.1:5055/calculate';
+
+ $results = [];
+
+ foreach ($ids as $id) {
+
+ /*
+ * Recupero job.
+ * Uso placeholder ? per evitare errori PDO HY093.
+ */
+ if ($iduser === null || $iduser === '') {
+ $stmt = $pdo->prepare("
+ SELECT *
+ FROM cad_area_jobs
+ WHERE id = ?
+ LIMIT 1
+ ");
+
+ $stmt->execute([
+ $id
+ ]);
+ } else {
+ $stmt = $pdo->prepare("
+ SELECT *
+ FROM cad_area_jobs
+ WHERE id = ?
+ AND iduser = ?
+ LIMIT 1
+ ");
+
+ $stmt->execute([
+ $id,
+ $iduser
+ ]);
+ }
+
+ $job = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if (!$job) {
+ $results[] = [
+ 'id' => $id,
+ 'success' => false,
+ 'message' => 'Record non trovato.'
+ ];
+ continue;
+ }
+
+ if (empty($job['file_path']) || !file_exists($job['file_path'])) {
+ updateJobError($pdo, $id, 'File PDF non trovato sul server.');
+
+ $results[] = [
+ 'id' => $id,
+ 'success' => false,
+ 'message' => 'File PDF non trovato sul server.'
+ ];
+ continue;
+ }
+
+ $stmtProcessing = $pdo->prepare("
+ UPDATE cad_area_jobs
+ SET
+ status = ?,
+ message = ?
+ WHERE id = ?
+ ");
+
+ $stmtProcessing->execute([
+ 'processing',
+ null,
+ $id
+ ]);
+
+ $pythonResponse = callPythonAreaService(
+ $pythonServiceUrl,
+ $job['file_path'],
+ $job['original_filename']
+ );
+
+ if (empty($pythonResponse['success'])) {
+ updateJobError(
+ $pdo,
+ $id,
+ $pythonResponse['message'] ?? 'Errore servizio Python.',
+ $pythonResponse
+ );
+
+ $results[] = [
+ 'id' => $id,
+ 'success' => false,
+ 'message' => $pythonResponse['message'] ?? 'Errore servizio Python.',
+ 'python_response' => $pythonResponse
+ ];
+ continue;
+ }
+
+ $areaMm2 = isset($pythonResponse['area_mm2']) && $pythonResponse['area_mm2'] !== null
+ ? (float)$pythonResponse['area_mm2']
+ : null;
+
+ $areaCm2 = isset($pythonResponse['area_cm2']) && $pythonResponse['area_cm2'] !== null
+ ? (float)$pythonResponse['area_cm2']
+ : ($areaMm2 !== null ? $areaMm2 / 100 : null);
+
+ $areaM2 = isset($pythonResponse['area_m2']) && $pythonResponse['area_m2'] !== null
+ ? (float)$pythonResponse['area_m2']
+ : ($areaMm2 !== null ? $areaMm2 / 1000000 : null);
+
+ $stmtUpdate = $pdo->prepare("
+ UPDATE cad_area_jobs
+ SET
+ status = ?,
+ area_mm2 = ?,
+ area_cm2 = ?,
+ area_m2 = ?,
+ scale_detected = ?,
+ confidence = ?,
+ message = ?,
+ python_response = ?
+ WHERE id = ?
+ ");
+
+ $stmtUpdate->execute([
+ 'completed',
+ $areaMm2,
+ $areaCm2,
+ $areaM2,
+ $pythonResponse['scale_detected'] ?? null,
+ $pythonResponse['confidence'] ?? null,
+ $pythonResponse['message'] ?? null,
+ json_encode($pythonResponse, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
+ $id
+ ]);
+
+ $results[] = [
+ 'id' => $id,
+ 'success' => true,
+ 'area_mm2' => $areaMm2,
+ 'area_cm2' => $areaCm2,
+ 'area_m2' => $areaM2,
+ 'message' => $pythonResponse['message'] ?? null
+ ];
+ }
+
+ echo json_encode([
+ 'success' => true,
+ 'results' => $results
+ ]);
+} catch (Throwable $e) {
+ error_log('CAD area process error: ' . $e->getMessage());
+ error_log('File: ' . $e->getFile());
+ error_log('Line: ' . $e->getLine());
+
+ echo json_encode([
+ 'success' => false,
+ 'message' => $e->getMessage(),
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine()
+ ]);
+}
+
+
+function callPythonAreaService(string $url, string $filePath, string $originalFilename): array
+{
+ if (!class_exists('CURLFile')) {
+ return [
+ 'success' => false,
+ 'message' => 'CURLFile non disponibile sul server PHP.'
+ ];
+ }
+
+ if (!function_exists('curl_init')) {
+ return [
+ 'success' => false,
+ 'message' => 'Estensione PHP cURL non disponibile.'
+ ];
+ }
+
+ $curlFile = new CURLFile($filePath, 'application/pdf', $originalFilename);
+
+ $postFields = [
+ 'file' => $curlFile,
+ 'mode' => 'pdf_vector',
+ 'scale_ratio' => '1'
+ ];
+
+ $ch = curl_init();
+
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $postFields,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 180,
+ CURLOPT_CONNECTTIMEOUT => 20,
+ CURLOPT_SSL_VERIFYPEER => false,
+ CURLOPT_SSL_VERIFYHOST => false
+ ]);
+
+ $response = curl_exec($ch);
+
+ if ($response === false) {
+ $error = curl_error($ch);
+ curl_close($ch);
+
+ return [
+ 'success' => false,
+ 'message' => 'Errore cURL verso Python: ' . $error
+ ];
+ }
+
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ $decoded = json_decode($response, true);
+
+ if ($httpCode < 200 || $httpCode >= 300) {
+ return [
+ 'success' => false,
+ 'message' => 'Servizio Python HTTP ' . $httpCode,
+ 'raw_response' => $response
+ ];
+ }
+
+ if (!is_array($decoded)) {
+ return [
+ 'success' => false,
+ 'message' => 'Risposta Python non valida.',
+ 'raw_response' => $response
+ ];
+ }
+
+ return $decoded;
+}
+
+
+function updateJobError(PDO $pdo, int $id, string $message, ?array $pythonResponse = null): void
+{
+ $stmt = $pdo->prepare("
+ UPDATE cad_area_jobs
+ SET
+ status = ?,
+ message = ?,
+ python_response = ?
+ WHERE id = ?
+ ");
+
+ $stmt->execute([
+ 'error',
+ $message,
+ $pythonResponse
+ ? json_encode($pythonResponse, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
+ : null,
+ $id
+ ]);
+}
diff --git a/public/userarea/cad_area_upload.php b/public/userarea/cad_area_upload.php
new file mode 100644
index 0000000..2eb59e5
--- /dev/null
+++ b/public/userarea/cad_area_upload.php
@@ -0,0 +1,106 @@
+getConnection();
+
+ $iduser = $iduserlogin ?? null;
+
+ $uploadDir = __DIR__ . '/uploads/cad_area/originals/';
+ $publicBaseUrl = 'uploads/cad_area/originals/';
+
+ if (!is_dir($uploadDir)) {
+ mkdir($uploadDir, 0755, true);
+ }
+
+ if (empty($_FILES['pdf_files'])) {
+ throw new Exception('Nessun file ricevuto.');
+ }
+
+ $files = $_FILES['pdf_files'];
+ $insertedIds = [];
+
+ for ($i = 0; $i < count($files['name']); $i++) {
+ if ($files['error'][$i] !== UPLOAD_ERR_OK) {
+ continue;
+ }
+
+ $originalName = $files['name'][$i];
+ $tmpName = $files['tmp_name'][$i];
+ $size = (int)$files['size'][$i];
+
+ $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
+
+ if ($extension !== 'pdf') {
+ continue;
+ }
+
+ if ($size > 25 * 1024 * 1024) {
+ continue;
+ }
+
+ $safeBaseName = preg_replace('/[^a-zA-Z0-9_\-]/', '_', pathinfo($originalName, PATHINFO_FILENAME));
+ $storedName = date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '_' . $safeBaseName . '.pdf';
+
+ $targetPath = $uploadDir . $storedName;
+
+ if (!move_uploaded_file($tmpName, $targetPath)) {
+ continue;
+ }
+
+ $relativeUrl = $publicBaseUrl . $storedName;
+
+ $stmt = $pdo->prepare("
+ INSERT INTO cad_area_jobs
+ (
+ iduser,
+ original_filename,
+ stored_filename,
+ file_path,
+ file_url,
+ file_size,
+ status
+ )
+ VALUES
+ (
+ :iduser,
+ :original_filename,
+ :stored_filename,
+ :file_path,
+ :file_url,
+ :file_size,
+ 'uploaded'
+ )
+ ");
+
+ $stmt->execute([
+ ':iduser' => $iduser,
+ ':original_filename' => $originalName,
+ ':stored_filename' => $storedName,
+ ':file_path' => $targetPath,
+ ':file_url' => $relativeUrl,
+ ':file_size' => $size
+ ]);
+
+ $insertedIds[] = (int)$pdo->lastInsertId();
+ }
+
+ if (empty($insertedIds)) {
+ throw new Exception('Nessun PDF valido caricato.');
+ }
+
+ echo json_encode([
+ 'success' => true,
+ 'ids' => $insertedIds
+ ]);
+} catch (Throwable $e) {
+ error_log('CAD area upload error: ' . $e->getMessage());
+
+ echo json_encode([
+ 'success' => false,
+ 'message' => $e->getMessage()
+ ]);
+}
diff --git a/public/userarea/uploads/cad_area/originals/20260610_125351_bf71a957_7_Virgis.pdf b/public/userarea/uploads/cad_area/originals/20260610_125351_bf71a957_7_Virgis.pdf
new file mode 100644
index 0000000..21ae99d
Binary files /dev/null and b/public/userarea/uploads/cad_area/originals/20260610_125351_bf71a957_7_Virgis.pdf differ
diff --git a/public/userarea/uploads/cad_area/originals/20260610_130235_c33b7b5e_7_Marni.pdf b/public/userarea/uploads/cad_area/originals/20260610_130235_c33b7b5e_7_Marni.pdf
new file mode 100644
index 0000000..4415d50
Binary files /dev/null and b/public/userarea/uploads/cad_area/originals/20260610_130235_c33b7b5e_7_Marni.pdf differ
diff --git a/public/userarea/uploads/cad_area/originals/20260610_152210_568a3012_9_Gimathech.pdf b/public/userarea/uploads/cad_area/originals/20260610_152210_568a3012_9_Gimathech.pdf
new file mode 100644
index 0000000..7e97cb5
Binary files /dev/null and b/public/userarea/uploads/cad_area/originals/20260610_152210_568a3012_9_Gimathech.pdf differ
diff --git a/public/userarea/uploads/cad_area/originals/20260610_152247_49c9831c_5_Jaeger.pdf b/public/userarea/uploads/cad_area/originals/20260610_152247_49c9831c_5_Jaeger.pdf
new file mode 100644
index 0000000..95355e0
Binary files /dev/null and b/public/userarea/uploads/cad_area/originals/20260610_152247_49c9831c_5_Jaeger.pdf differ
diff --git a/public/userarea/uploads/cad_area/originals/20260610_152312_4aca30a8_1_Givi.pdf b/public/userarea/uploads/cad_area/originals/20260610_152312_4aca30a8_1_Givi.pdf
new file mode 100644
index 0000000..6585e74
Binary files /dev/null and b/public/userarea/uploads/cad_area/originals/20260610_152312_4aca30a8_1_Givi.pdf differ
diff --git a/public/userarea/uploads/cad_area/originals/20260610_152312_4d181d48_3_DRS.pdf b/public/userarea/uploads/cad_area/originals/20260610_152312_4d181d48_3_DRS.pdf
new file mode 100644
index 0000000..3c2027b
Binary files /dev/null and b/public/userarea/uploads/cad_area/originals/20260610_152312_4d181d48_3_DRS.pdf differ
diff --git a/public/userarea/uploads/cad_area/originals/20260610_152312_88e6c337_4__Kremer.pdf b/public/userarea/uploads/cad_area/originals/20260610_152312_88e6c337_4__Kremer.pdf
new file mode 100644
index 0000000..cbdb69f
Binary files /dev/null and b/public/userarea/uploads/cad_area/originals/20260610_152312_88e6c337_4__Kremer.pdf differ
diff --git a/public/userarea/uploads/cad_area/originals/20260610_152312_fc91914c_2_Zodiac.pdf b/public/userarea/uploads/cad_area/originals/20260610_152312_fc91914c_2_Zodiac.pdf
new file mode 100644
index 0000000..cdbd755
Binary files /dev/null and b/public/userarea/uploads/cad_area/originals/20260610_152312_fc91914c_2_Zodiac.pdf differ
diff --git a/public/userarea/uploads/cad_area/originals/20260610_153032_d130b438_2_Zodiac.pdf b/public/userarea/uploads/cad_area/originals/20260610_153032_d130b438_2_Zodiac.pdf
new file mode 100644
index 0000000..cdbd755
Binary files /dev/null and b/public/userarea/uploads/cad_area/originals/20260610_153032_d130b438_2_Zodiac.pdf differ
diff --git a/public/userarea/uploads/cad_area/originals/20260610_153032_ef4401b3_1_Givi.pdf b/public/userarea/uploads/cad_area/originals/20260610_153032_ef4401b3_1_Givi.pdf
new file mode 100644
index 0000000..6585e74
Binary files /dev/null and b/public/userarea/uploads/cad_area/originals/20260610_153032_ef4401b3_1_Givi.pdf differ
diff --git a/python-cad-area/__pycache__/cad_vector_area.cpython-314.pyc b/python-cad-area/__pycache__/cad_vector_area.cpython-314.pyc
new file mode 100644
index 0000000..e191b06
Binary files /dev/null and b/python-cad-area/__pycache__/cad_vector_area.cpython-314.pyc differ
diff --git a/python-cad-area/app.py b/python-cad-area/app.py
new file mode 100644
index 0000000..04bf354
--- /dev/null
+++ b/python-cad-area/app.py
@@ -0,0 +1,76 @@
+from flask import Flask, request, jsonify
+from flask_cors import CORS
+import traceback
+
+from cad_vector_area import calculate_pdf_vector_area
+
+app = Flask(__name__)
+CORS(app)
+
+
+@app.route("/health", methods=["GET"])
+def health():
+ return jsonify({
+ "success": True,
+ "message": "Python CAD Area service is running"
+ })
+
+
+@app.route("/calculate", methods=["POST"])
+def calculate():
+ try:
+ if "file" not in request.files:
+ return jsonify({
+ "success": False,
+ "message": "No PDF file received"
+ }), 400
+
+ uploaded_file = request.files["file"]
+
+ if uploaded_file.filename == "":
+ return jsonify({
+ "success": False,
+ "message": "Empty filename"
+ }), 400
+
+ if not uploaded_file.filename.lower().endswith(".pdf"):
+ return jsonify({
+ "success": False,
+ "message": "Only PDF files are allowed"
+ }), 400
+
+ pdf_bytes = uploaded_file.read()
+
+ scale_ratio = request.form.get("scale_ratio", "1")
+
+ try:
+ scale_ratio = float(scale_ratio)
+ if scale_ratio <= 0:
+ scale_ratio = 1.0
+ except ValueError:
+ scale_ratio = 1.0
+
+ result = calculate_pdf_vector_area(
+ pdf_bytes=pdf_bytes,
+ filename=uploaded_file.filename,
+ scale_ratio=scale_ratio
+ )
+
+ status_code = 200 if result.get("success") else 422
+
+ return jsonify(result), status_code
+
+ except Exception as e:
+ return jsonify({
+ "success": False,
+ "message": str(e),
+ "trace": traceback.format_exc()
+ }), 500
+
+
+if __name__ == "__main__":
+ app.run(
+ host="127.0.0.1",
+ port=5055,
+ debug=True
+ )
\ No newline at end of file
diff --git a/python-cad-area/cad_vector_area.py b/python-cad-area/cad_vector_area.py
new file mode 100644
index 0000000..65627cc
--- /dev/null
+++ b/python-cad-area/cad_vector_area.py
@@ -0,0 +1,375 @@
+import fitz
+from shapely.geometry import Polygon
+from shapely.validation import make_valid
+import math
+
+
+POINT_TO_MM = 25.4 / 72.0
+
+
+def point_to_tuple(point):
+ return float(point.x), float(point.y)
+
+
+def distance(p1, p2):
+ return math.hypot(p1[0] - p2[0], p1[1] - p2[1])
+
+
+def rect_to_polygon(rect):
+ return [
+ (float(rect.x0), float(rect.y0)),
+ (float(rect.x1), float(rect.y0)),
+ (float(rect.x1), float(rect.y1)),
+ (float(rect.x0), float(rect.y1)),
+ (float(rect.x0), float(rect.y0)),
+ ]
+
+
+def cubic_bezier_points(p0, p1, p2, p3, steps=32):
+ points = []
+
+ for i in range(1, steps + 1):
+ t = i / steps
+
+ x = (
+ (1 - t) ** 3 * p0[0]
+ + 3 * (1 - t) ** 2 * t * p1[0]
+ + 3 * (1 - t) * t ** 2 * p2[0]
+ + t ** 3 * p3[0]
+ )
+
+ y = (
+ (1 - t) ** 3 * p0[1]
+ + 3 * (1 - t) ** 2 * t * p1[1]
+ + 3 * (1 - t) * t ** 2 * p2[1]
+ + t ** 3 * p3[1]
+ )
+
+ points.append((x, y))
+
+ return points
+
+
+def polygon_area_mm2(points, scale_ratio=1.0):
+ polygon = Polygon(points)
+
+ if not polygon.is_valid:
+ polygon = make_valid(polygon)
+
+ if polygon.is_empty:
+ return None
+
+ area_points2 = abs(float(polygon.area))
+ area_mm2 = area_points2 * (POINT_TO_MM ** 2)
+ area_mm2 = area_mm2 / (scale_ratio ** 2)
+
+ return area_mm2
+
+
+def get_bounds_mm(points, scale_ratio=1.0):
+ polygon = Polygon(points)
+ bounds = polygon.bounds
+
+ x_min, y_min, x_max, y_max = bounds
+
+ width_points = x_max - x_min
+ height_points = y_max - y_min
+
+ width_mm = width_points * POINT_TO_MM / scale_ratio
+ height_mm = height_points * POINT_TO_MM / scale_ratio
+
+ return {
+ "x_min": x_min,
+ "y_min": y_min,
+ "x_max": x_max,
+ "y_max": y_max,
+ "width_mm": width_mm,
+ "height_mm": height_mm,
+ }
+
+
+def extract_points_from_drawing(drawing):
+ points = []
+ source_type = "path"
+
+ for item in drawing.get("items", []):
+ command = item[0]
+
+ if command == "l":
+ p1 = point_to_tuple(item[1])
+ p2 = point_to_tuple(item[2])
+
+ if not points:
+ points.append(p1)
+
+ if distance(points[-1], p1) > 0.01:
+ points.append(p1)
+
+ points.append(p2)
+
+ elif command == "re":
+ rect = item[1]
+ source_type = "rectangle"
+ return rect_to_polygon(rect), source_type
+
+ elif command == "c":
+ # PyMuPDF cubic item is normally:
+ # ("c", start_point, control_1, control_2, end_point)
+ if len(item) >= 5:
+ p0 = point_to_tuple(item[1])
+ p1 = point_to_tuple(item[2])
+ p2 = point_to_tuple(item[3])
+ p3 = point_to_tuple(item[4])
+
+ if not points:
+ points.append(p0)
+ elif distance(points[-1], p0) > 0.01:
+ points.append(p0)
+
+ points.extend(cubic_bezier_points(p0, p1, p2, p3, steps=32))
+
+ return points, source_type
+
+
+def is_closed(points, tolerance_points=1.5):
+ if len(points) < 4:
+ return False
+
+ return distance(points[0], points[-1]) <= tolerance_points
+
+
+def is_simple_rectangle(points, source_type):
+ if source_type == "rectangle":
+ return True
+
+ # Most CAD frames, dimension boxes and table lines become 5-point rectangles.
+ if len(points) <= 5:
+ return True
+
+ return False
+
+
+def reject_reason(points, page_rect, source_type, area_mm2, scale_ratio=1.0):
+ if len(points) < 6:
+ return "too_few_points"
+
+ if not is_closed(points):
+ return "not_closed"
+
+ if is_simple_rectangle(points, source_type):
+ return "rectangle_or_box"
+
+ if area_mm2 is None or area_mm2 <= 0:
+ return "zero_area"
+
+ bounds = get_bounds_mm(points, scale_ratio)
+ width_mm = bounds["width_mm"]
+ height_mm = bounds["height_mm"]
+
+ if width_mm <= 0 or height_mm <= 0:
+ return "invalid_bounds"
+
+ # Reject thin long rectangles/lines:
+ # this is exactly what was happening on Zodiac:
+ # a long frame/table line was selected as area.
+ min_side = min(width_mm, height_mm)
+ max_side = max(width_mm, height_mm)
+
+ if min_side < 1.0:
+ return "thin_line_or_stroke"
+
+ if max_side / min_side > 80:
+ return "extreme_aspect_ratio"
+
+ # Reject page frames / title blocks.
+ page_area_mm2 = (page_rect.width * POINT_TO_MM) * (page_rect.height * POINT_TO_MM)
+
+ if area_mm2 > page_area_mm2 * 0.05:
+ return "too_large_page_element"
+
+ # Reject text glyphs / arrows / tiny symbols.
+ if area_mm2 < 20:
+ return "too_small_detail"
+
+ # Reasonable technical-section limits for this first version.
+ # We can later make these user-configurable.
+ if width_mm > 250 or height_mm > 250:
+ return "too_large_for_profile"
+
+ return None
+
+
+def candidate_score(candidate):
+ """
+ Higher score = more plausible rubber/profile section.
+ This does not guarantee correctness, but avoids obvious false positives.
+ """
+ area = candidate["area_mm2"]
+ width = candidate["width_mm"]
+ height = candidate["height_mm"]
+
+ min_side = min(width, height)
+ max_side = max(width, height)
+
+ aspect = max_side / min_side if min_side > 0 else 9999
+
+ score = 0
+
+ # Prefer meaningful areas.
+ if area >= 50:
+ score += 20
+ if area >= 100:
+ score += 20
+ if area >= 300:
+ score += 10
+
+ # Penalize strange aspect ratios.
+ if aspect <= 10:
+ score += 20
+ elif aspect <= 25:
+ score += 5
+ else:
+ score -= 20
+
+ # Penalize very large bounding boxes.
+ if width > 120 or height > 120:
+ score -= 10
+
+ return score
+
+
+def calculate_pdf_vector_area(pdf_bytes, filename="uploaded.pdf", scale_ratio=1.0):
+ doc = fitz.open(stream=pdf_bytes, filetype="pdf")
+
+ if len(doc) == 0:
+ return {
+ "success": False,
+ "message": "PDF has no pages"
+ }
+
+ page = doc[0]
+ drawings = page.get_drawings()
+
+ diagnostics = {
+ "filename": filename,
+ "pages": len(doc),
+ "page_width_points": float(page.rect.width),
+ "page_height_points": float(page.rect.height),
+ "drawings_count": len(drawings),
+ "scale_ratio_used": scale_ratio,
+ "raw_closed_candidates_count": 0,
+ "accepted_candidates_count": 0,
+ "rejected_candidates_count": 0,
+ }
+
+ if len(drawings) == 0:
+ return {
+ "success": False,
+ "message": "No vector drawings found. This PDF may be raster/scanned.",
+ "confidence": "low",
+ "diagnostics": diagnostics
+ }
+
+ accepted_candidates = []
+ rejected_candidates = []
+
+ for index, drawing in enumerate(drawings):
+ points, source_type = extract_points_from_drawing(drawing)
+
+ if len(points) < 4:
+ continue
+
+ closed = is_closed(points)
+
+ if closed:
+ diagnostics["raw_closed_candidates_count"] += 1
+
+ area_mm2 = None
+
+ if closed:
+ area_mm2 = polygon_area_mm2(points, scale_ratio=scale_ratio)
+
+ bounds_data = None
+
+ if closed and area_mm2 is not None and area_mm2 > 0:
+ bounds_data = get_bounds_mm(points, scale_ratio=scale_ratio)
+
+ reason = reject_reason(
+ points=points,
+ page_rect=page.rect,
+ source_type=source_type,
+ area_mm2=area_mm2,
+ scale_ratio=scale_ratio
+ )
+
+ candidate = {
+ "drawing_index": index,
+ "source_type": source_type,
+ "drawing_type": drawing.get("type"),
+ "points_count": len(points),
+ "area_mm2": round(area_mm2, 6),
+ "area_cm2": round(area_mm2 / 100.0, 6),
+ "area_m2": round(area_mm2 / 1_000_000.0, 9),
+ "width_mm": round(bounds_data["width_mm"], 3),
+ "height_mm": round(bounds_data["height_mm"], 3),
+ "bounds_points": {
+ "x_min": bounds_data["x_min"],
+ "y_min": bounds_data["y_min"],
+ "x_max": bounds_data["x_max"],
+ "y_max": bounds_data["y_max"],
+ },
+ "fill": drawing.get("fill"),
+ "color": drawing.get("color"),
+ }
+
+ if reason is None:
+ candidate["score"] = candidate_score(candidate)
+ accepted_candidates.append(candidate)
+ else:
+ candidate["rejected_reason"] = reason
+
+ # Keep only useful rejected diagnostics, not thousands of tiny glyphs.
+ if len(rejected_candidates) < 80:
+ rejected_candidates.append(candidate)
+
+ diagnostics["accepted_candidates_count"] = len(accepted_candidates)
+ diagnostics["rejected_candidates_count"] = len(rejected_candidates)
+
+ accepted_candidates.sort(key=lambda item: item["score"], reverse=True)
+
+ if not accepted_candidates:
+ return {
+ "success": False,
+ "message": (
+ "No reliable closed profile found. "
+ "False positives such as rectangles, frames, dimension lines and text were rejected. "
+ "This PDF probably needs stitched-contour reconstruction."
+ ),
+ "confidence": "low",
+ "diagnostics": diagnostics,
+ "rejected_candidates_preview": rejected_candidates[:30]
+ }
+
+ best = accepted_candidates[0]
+ area_mm2 = best["area_mm2"]
+
+ # In this MVP, even accepted candidates need validation.
+ # We do not want to present a wrong number as final production data.
+ confidence = "needs_validation"
+
+ return {
+ "success": True,
+ "message": (
+ "Candidate found after rejecting rectangles, frames and thin lines. "
+ "Validate the selected candidate before using it as final area."
+ ),
+ "area_mm2": round(area_mm2, 6),
+ "area_cm2": round(area_mm2 / 100.0, 6),
+ "area_m2": round(area_mm2 / 1_000_000.0, 9),
+ "scale_detected": f"{scale_ratio}:1 manual/default",
+ "confidence": confidence,
+ "selected_candidate": best,
+ "diagnostics": diagnostics,
+ "accepted_candidates_preview": accepted_candidates[:20],
+ "rejected_candidates_preview": rejected_candidates[:30]
+ }
\ No newline at end of file
diff --git a/python-cad-area/requirements.txt b/python-cad-area/requirements.txt
new file mode 100644
index 0000000..64326cd
Binary files /dev/null and b/python-cad-area/requirements.txt differ