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'); ?> + + + + + + + + + + + + +
+ + + +
+
+ +
+
+
+
Calcolo Area da PDF CAD
+ Upload PDF vettoriali e calcolo automatico della superficie del profilo +
+ + +
+ +
+ +
+
📐
+
Trascina qui uno o più PDF CAD
+

Oppure clicca per selezionare i file

+ Formati accettati: PDF - massimo 25 MB per file + +
+ +
+ +
+ + + +
+ +
+
+ +
+
+
Risultati Elaborazione
+ +
+ +
+
+ + + + + + + + + + + + + + + + + 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'); + ?> + + + + + + + + + + + + + + + + +
+ + Nome PDFStatoArea mm²Area cm²ScalaConfidenzaAzioni
+ + + + + + + + + 📄 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