
{"id":11068,"date":"2025-09-23T00:38:55","date_gmt":"2025-09-23T00:38:55","guid":{"rendered":"https:\/\/tunisia3dprint.com\/?page_id=11068"},"modified":"2026-05-04T00:05:59","modified_gmt":"2026-05-04T00:05:59","slug":"upload-get-quote","status":"publish","type":"page","link":"https:\/\/tunisia3dprint.com\/fr\/upload-get-quote\/","title":{"rendered":"Upload &amp; Get Quote"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"11068\" class=\"elementor elementor-11068\" data-elementor-post-type=\"page\">\n\t\t\t\t<div class=\"wd-negative-gap elementor-element elementor-element-ac7328a e-flex e-con-boxed e-con e-parent\" data-id=\"ac7328a\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t\t\t<div class=\"elementor-element elementor-element-62bf3e5 elementor-widget elementor-widget-shortcode\" data-id=\"62bf3e5\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"shortcode.default\">\n\t\t\t\t\t\t\t<div class=\"elementor-shortcode\">\r\n<style type=\"text\/css\">\r\n    .stl-upload-container-pro {\r\n        padding: 25px;\r\n        background: #ffffff;\r\n        border-radius: 8px;\r\n        border: 1px solid #e8e8e8;\r\n        border-left: 4px solid #d32f2f;\r\n        box-shadow: 0 2px 10px rgba(0,0,0,0.05);\r\n        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\r\n    }\r\n    \r\n    .stl-upload-container-pro h3 {\r\n        text-align: left;\r\n        margin: 0 0 22px;\r\n        color: #1a1a1a;\r\n        font-size: 13px;\r\n        font-weight: 800;\r\n        text-transform: uppercase;\r\n        letter-spacing: .7px;\r\n        padding-bottom: 12px;\r\n        border-bottom: 1px solid #f0f0f0;\r\n    }\r\n    \r\n    .upload-area-pro {\r\n        border: 2px dashed #d0d5dd;\r\n        border-radius: 10px;\r\n        padding: 40px 20px;\r\n        text-align: center;\r\n        background: #fafbfc;\r\n        margin-bottom: 25px;\r\n        transition: border-color .2s, background .2s;\r\n    }\r\n\r\n    .upload-area-pro:hover {\r\n        border-color: #d32f2f;\r\n        background: #fff8f8;\r\n    }\r\n\r\n    .upload-area-pro.dragover {\r\n        background: #fff8f8;\r\n        border-color: #d32f2f;\r\n        border-style: solid;\r\n    }\r\n    \r\n    \r\n    .upload-button-pro {\r\n        display: inline-flex;\r\n        align-items: center;\r\n        gap: 8px;\r\n        padding: 12px 26px;\r\n        background: #fff;\r\n        color: #d32f2f;\r\n        border-radius: 7px;\r\n        font-size: 13px;\r\n        font-weight: 800;\r\n        letter-spacing: .6px;\r\n        text-transform: uppercase;\r\n        cursor: pointer;\r\n        transition: background .2s, color .2s, transform .15s, box-shadow .2s;\r\n        border: 2px solid #d32f2f;\r\n        box-shadow: 0 2px 8px rgba(211,47,47,.12);\r\n        font-family: inherit;\r\n    }\r\n\r\n    .upload-button-pro::before {\r\n        content: '';\r\n        display: inline-block;\r\n        width: 15px;\r\n        height: 15px;\r\n        background-image: url(\"data:image\/svg+xml,%3Csvg xmlns='http:\/\/www.w3.org\/2000\/svg' viewBox='0 0 24 24' fill='none' stroke='%23d32f2f' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4'\/%3E%3Cpolyline points='17 8 12 3 7 8'\/%3E%3Cline x1='12' y1='3' x2='12' y2='15'\/%3E%3C\/svg%3E\");\r\n        background-size: contain;\r\n        background-repeat: no-repeat;\r\n        transition: background-image .2s;\r\n    }\r\n\r\n    .upload-button-pro:hover {\r\n        background: #d32f2f;\r\n        color: #fff;\r\n        transform: translateY(-1px);\r\n        box-shadow: 0 4px 16px rgba(211,47,47,.28);\r\n    }\r\n\r\n    .upload-button-pro:hover::before {\r\n        background-image: url(\"data:image\/svg+xml,%3Csvg xmlns='http:\/\/www.w3.org\/2000\/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4'\/%3E%3Cpolyline points='17 8 12 3 7 8'\/%3E%3Cline x1='12' y1='3' x2='12' y2='15'\/%3E%3C\/svg%3E\");\r\n    }\r\n    \r\n    .upload-info-pro {\r\n        margin-top: 15px;\r\n        color: #666;\r\n        font-size: 14px;\r\n    }\r\n    \r\n    .stl-file-card-pro {\r\n        background: #ffffff;\r\n        border-radius: 8px;\r\n        padding: 20px;\r\n        margin-bottom: 20px;\r\n        border: 2px solid #e9ecef;\r\n        box-shadow: 0 2px 8px rgba(0,0,0,0.08);\r\n        transition: all 0.3s ease;\r\n        position: relative;\r\n    }\r\n    \r\n    .file-header-pro {\r\n        display: flex;\r\n        justify-content: space-between;\r\n        align-items: flex-start;\r\n        margin-bottom: 15px;\r\n        padding-bottom: 10px;\r\n        border-bottom: 2px solid #f0f0f0;\r\n        position: relative;\r\n        padding-right: 50px;\r\n    }\r\n    \r\n    .file-name-pro {\r\n        font-size: 18px;\r\n        font-weight: 600;\r\n        color: #000000;\r\n        margin: 0;\r\n        flex: 1;\r\n    }\r\n    \r\n    .file-status-pro {\r\n        padding: 6px 12px;\r\n        border-radius: 20px;\r\n        font-size: 12px;\r\n        font-weight: 600;\r\n        min-width: 80px;\r\n        text-align: center;\r\n        position: absolute;\r\n        right: 50px;\r\n        top: 0;\r\n    }\r\n    \r\n    .status-processing-pro {\r\n        background: #fff3cd;\r\n        color: #856404;\r\n    }\r\n    \r\n    .status-success-pro {\r\n        background: #d4edda;\r\n        color: #155724;\r\n    }\r\n    \r\n    .status-error-pro {\r\n        background: #f8d7da;\r\n        color: #721c24;\r\n    }\r\n    \r\n    .remove-file-pro {\r\n        position: absolute;\r\n        top: 15px;\r\n        right: 15px;\r\n        background: #dc3545;\r\n        color: white;\r\n        border: none;\r\n        border-radius: 50%;\r\n        width: 30px;\r\n        height: 30px;\r\n        cursor: pointer;\r\n        font-size: 16px;\r\n        display: flex;\r\n        align-items: center;\r\n        justify-content: center;\r\n        transition: all 0.3s ease;\r\n        z-index: 10;\r\n    }\r\n    \r\n    .remove-file-pro:hover {\r\n        background: #c82333;\r\n        transform: scale(1.1);\r\n    }\r\n    \r\n    .preview-section-pro {\r\n        display: grid;\r\n        grid-template-columns: 4fr 1fr;\r\n        gap: 15px;\r\n        margin: 20px 0;\r\n    }\r\n    \r\n    .viewer-container-pro {\r\n        height: 320px;\r\n        border: 2px solid #e9ecef;\r\n        border-radius: 6px;\r\n        background: #f8f9fa;\r\n        position: relative;\r\n        overflow: hidden;\r\n    }\r\n    \r\n    .viewer-placeholder-pro {\r\n        width: 100%;\r\n        height: 100%;\r\n        display: flex;\r\n        flex-direction: column;\r\n        align-items: center;\r\n        justify-content: center;\r\n        text-align: center;\r\n        color: #666;\r\n        padding: 20px;\r\n    }\r\n    \r\n    .info-panel-pro {\r\n        background: #f8f9fa;\r\n        padding: 12px;\r\n        border-radius: 6px;\r\n        border: 2px solid #e9ecef;\r\n        display: flex;\r\n        flex-direction: column;\r\n        gap: 10px;\r\n        min-width: 200px;\r\n        height: 320px;\r\n        overflow-y: auto;\r\n    }\r\n    \r\n    .dimension-box-pro {\r\n        background: #f8f9fa;\r\n        padding: 10px;\r\n        border-radius: 6px;\r\n        border-left: 4px solid #000000;\r\n        font-size: 12px;\r\n        border: 2px solid #e9ecef;\r\n    }\r\n    \r\n    .volume-box-pro {\r\n        background: #f8f9fa;\r\n        padding: 12px;\r\n        border-radius: 6px;\r\n        text-align: center;\r\n        border-left: 4px solid #28a745;\r\n        border: 2px solid #e9ecef;\r\n    }\r\n    \r\n    .volume-value-pro {\r\n        font-size: 20px;\r\n        font-weight: 700;\r\n        color: #000000;\r\n        margin: 5px 0;\r\n    }\r\n    \r\n    .settings-grid-pro {\r\n        display: grid;\r\n        grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));\r\n        gap: 10px;\r\n        margin-top: 20px;\r\n    }\r\n    \r\n    .form-group-pro {\r\n        display: flex;\r\n        flex-direction: column;\r\n        position: relative;\r\n    }\r\n    \r\n    .form-group-pro label {\r\n        font-size: 11px;\r\n        font-weight: 600;\r\n        color: #000000;\r\n        margin-bottom: 4px;\r\n    }\r\n    \r\n    .form-group-pro select,\r\n    .form-group-pro input {\r\n        padding: 8px 10px;\r\n        border: 2px solid #e9ecef;\r\n        border-radius: 6px;\r\n        font-size: 12px;\r\n        background: #ffffff;\r\n        color: #000000;\r\n    }\r\n    \r\n    .form-group-pro select {\r\n        appearance: none;\r\n        background-image: url(\"data:image\/svg+xml;charset=UTF-8,%3csvg xmlns='http:\/\/www.w3.org\/2000\/svg' viewBox='0 0 24 24' fill='none' stroke='%23000000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c\/polyline%3e%3c\/svg%3e\");\r\n        background-repeat: no-repeat;\r\n        background-position: right 8px center;\r\n        background-size: 16px;\r\n        \/* Reserve room for the arrow on both sides \u2014 in RTL the text starts from the right\r\n           edge and would otherwise collide with the arrow (e.g. PLA in the Material select\r\n           on Arabic). 30px = 16px arrow width + 8px right offset + 6px gap. *\/\r\n        padding-right: 30px;\r\n    }\r\n    \/* RTL: move the arrow to the visual LEFT (end of the RTL text flow) for proper UX,\r\n       and swap the padding side so the dropdown reads naturally on Arabic pages. *\/\r\n    [dir=\"rtl\"] .form-group-pro select,\r\n    .rtl .form-group-pro select {\r\n        background-position: left 8px center;\r\n        padding-right: 10px;\r\n        padding-left: 30px;\r\n    }\r\n    \r\n    .form-group-pro input[readonly] {\r\n        background: #f8f9fa;\r\n        font-weight: 600;\r\n    }\r\n    \r\n    .cost-breakdown-pro {\r\n        background: #f8f9fa;\r\n        padding: 15px;\r\n        border-radius: 8px;\r\n        margin: 15px 0;\r\n        border: 2px solid #e9ecef;\r\n    }\r\n    \r\n    .cost-item-pro {\r\n        display: flex;\r\n        justify-content: space-between;\r\n        padding: 5px 0;\r\n        font-size: 12px;\r\n        border-bottom: 1px solid #e9ecef;\r\n    }\r\n    \r\n    .cost-item-pro:last-child {\r\n        border-bottom: none;\r\n        font-weight: 600;\r\n        color: #dc3545;\r\n    }\r\n    \r\n    .total-section-pro {\r\n        background: #fff;\r\n        padding: 22px 24px;\r\n        border-radius: 10px;\r\n        margin: 25px 0;\r\n        text-align: left;\r\n        border: 1px solid #e8e8e8;\r\n        border-left: 4px solid #d32f2f;\r\n        box-shadow: 0 2px 10px rgba(0,0,0,.05);\r\n    }\r\n\r\n    \/* label: \"Total Project Cost (incl. tax)\" *\/\r\n    .total-section-pro > div:first-child {\r\n        font-size: 11px !important;\r\n        font-weight: 800 !important;\r\n        text-transform: uppercase;\r\n        letter-spacing: .6px;\r\n        color: #d32f2f !important;\r\n        margin-bottom: 4px !important;\r\n    }\r\n\r\n    .total-amount-pro {\r\n        font-size: 30px;\r\n        font-weight: 900;\r\n        margin: 4px 0 8px;\r\n        letter-spacing: -.5px;\r\n        color: #1a1a1a;\r\n    }\r\n    \r\n    .submit-section-pro {\r\n        text-align: center;\r\n    }\r\n    \r\n    .submit-btn-pro {\r\n        padding: 14px 32px;\r\n        background: #d32f2f;\r\n        color: #fff;\r\n        border: 2px solid #d32f2f;\r\n        border-radius: 7px;\r\n        font-size: 13px;\r\n        font-weight: 800;\r\n        letter-spacing: .6px;\r\n        text-transform: uppercase;\r\n        cursor: pointer;\r\n        margin: 8px 5px;\r\n        transition: background .2s, transform .15s, box-shadow .2s;\r\n        box-shadow: 0 4px 14px rgba(211,47,47,.22);\r\n    }\r\n\r\n    .cart-btn-pro {\r\n        padding: 14px 32px;\r\n        background: #fff;\r\n        color: #1a1a1a;\r\n        border: 2px solid #1a1a1a;\r\n        border-radius: 7px;\r\n        font-size: 13px;\r\n        font-weight: 800;\r\n        letter-spacing: .6px;\r\n        text-transform: uppercase;\r\n        cursor: pointer;\r\n        margin: 8px 5px;\r\n        transition: background .2s, color .2s, transform .15s, box-shadow .2s;\r\n    }\r\n\r\n    .submit-btn-pro:hover {\r\n        background: #b71c1c;\r\n        border-color: #b71c1c;\r\n        transform: translateY(-1px);\r\n        box-shadow: 0 6px 20px rgba(211,47,47,.32);\r\n    }\r\n\r\n    .cart-btn-pro:hover {\r\n        background: #1a1a1a;\r\n        color: #fff;\r\n        transform: translateY(-1px);\r\n        box-shadow: 0 6px 20px rgba(0,0,0,.18);\r\n    }\r\n\r\n    .submit-btn-pro:disabled,\r\n    .cart-btn-pro:disabled {\r\n        background: #f0f0f0;\r\n        color: #aaa;\r\n        border-color: #e0e0e0;\r\n        cursor: not-allowed;\r\n        transform: none;\r\n        box-shadow: none;\r\n    }\r\n    \r\n    .cart-btn-pro.loading {\r\n        background: #f0f0f0 !important;\r\n        color: #aaa !important;\r\n        border-color: #e0e0e0 !important;\r\n        cursor: not-allowed !important;\r\n        position: relative;\r\n    }\r\n    \r\n    .cart-btn-pro.loading::after {\r\n        content: '';\r\n        position: absolute;\r\n        top: 50%;\r\n        left: 50%;\r\n        margin: -8px 0 0 -8px;\r\n        width: 16px;\r\n        height: 16px;\r\n        border: 2px solid transparent;\r\n        border-top: 2px solid #ffffff;\r\n        border-radius: 50%;\r\n        animation: spin 1s linear infinite;\r\n    }\r\n    \r\n    @keyframes spin {\r\n        0% { transform: rotate(0deg); }\r\n        100% { transform: rotate(360deg); }\r\n    }\r\n    \r\n    .file-counter-pro {\r\n        text-align: center;\r\n        margin: 10px 0;\r\n        font-size: 14px;\r\n        color: #666;\r\n    }\r\n    \r\n    .contact-section-pro {\r\n        text-align: center;\r\n        margin-top: 20px;\r\n        padding: 16px 20px;\r\n        background: #fafbfc;\r\n        border-radius: 8px;\r\n        border: 1px solid #e8e8e8;\r\n    }\r\n\r\n    .contact-section-label-pro {\r\n        display: block;\r\n        font-size: 11px;\r\n        font-weight: 800;\r\n        text-transform: uppercase;\r\n        letter-spacing: .6px;\r\n        color: #999;\r\n        margin-bottom: 10px;\r\n    }\r\n\r\n    .contact-btn-pro {\r\n        display: inline-block;\r\n        padding: 9px 18px;\r\n        background: #fff;\r\n        color: #d32f2f;\r\n        text-decoration: none;\r\n        border-radius: 6px;\r\n        font-size: 13px;\r\n        font-weight: 700;\r\n        margin: 4px;\r\n        transition: background .2s, color .2s, transform .15s;\r\n        border: 1.5px solid #d32f2f;\r\n    }\r\n\r\n    .contact-btn-pro:hover {\r\n        background: #d32f2f;\r\n        color: #fff;\r\n        transform: translateY(-1px);\r\n        text-decoration: none;\r\n    }\r\n\r\n    .contact-btn-pro.whatsapp-btn-pro {\r\n        color: #25a244;\r\n        border-color: #25a244;\r\n    }\r\n\r\n    .contact-btn-pro.whatsapp-btn-pro:hover {\r\n        background: #25a244;\r\n        color: #fff;\r\n    }\r\n\r\n    .printability-bar-pro {\r\n        width: 100%;\r\n        height: 6px;\r\n        background: #e9ecef;\r\n        border-radius: 4px;\r\n        margin: 6px 0;\r\n        overflow: hidden;\r\n    }\r\n    \r\n    .printability-fill-pro {\r\n        height: 100%;\r\n        border-radius: 4px;\r\n        transition: all 0.3s ease;\r\n    }\r\n    \r\n    .printability-excellent-pro {\r\n        background: #28a745;\r\n        width: 90%;\r\n    }\r\n    .printability-good-pro {\r\n        background: #ffc107;\r\n        width: 70%;\r\n    }\r\n    .printability-fair-pro {\r\n        background: #fd7e14;\r\n        width: 50%;\r\n    }\r\n    .printability-poor-pro {\r\n        background: #dc3545;\r\n        width: 30%;\r\n    }\r\n    \r\n    .printability-label-pro {\r\n        font-size: 10px;\r\n        font-weight: 600;\r\n        text-align: center;\r\n        margin-top: 4px;\r\n    }\r\n    \r\n    .printability-excellent-text-pro {\r\n        color: #28a745;\r\n    }\r\n    .printability-good-text-pro {\r\n        color: #ffc107;\r\n    }\r\n    .printability-fair-text-pro {\r\n        color: #fd7e14;\r\n    }\r\n    .printability-poor-text-pro {\r\n        color: #dc3545;\r\n    }\r\n    .printability-unreadable-text-pro {\r\n        color: #6b6b6b;\r\n        font-style: italic;\r\n    }\r\n    \r\n    .quotation-modal-pro {\r\n        display: none;\r\n        position: fixed;\r\n        top: 0; left: 0;\r\n        width: 100%; height: 100%;\r\n        background: rgba(0,0,0,.6);\r\n        z-index: 1000;\r\n        align-items: flex-start;\r\n        justify-content: center;\r\n        padding: 20px 0;\r\n        overflow-y: auto;\r\n    }\r\n\r\n    .quotation-content-pro {\r\n        background: #fff;\r\n        border-radius: 10px;\r\n        max-width: 860px;\r\n        width: 95%;\r\n        box-shadow: 0 20px 60px rgba(0,0,0,.35);\r\n        overflow: hidden;\r\n    }\r\n\r\n    .quote-modal-actions {\r\n        display: flex;\r\n        justify-content: space-between;\r\n        align-items: center;\r\n        padding: 14px 20px;\r\n        background: #f8f8f8;\r\n        border-bottom: 1px solid #e0e0e0;\r\n    }\r\n\r\n    .quote-action-btn {\r\n        display: inline-flex;\r\n        align-items: center;\r\n        gap: 6px;\r\n        padding: 9px 18px;\r\n        border: none;\r\n        border-radius: 6px;\r\n        font-size: 13px;\r\n        font-weight: 700;\r\n        cursor: pointer;\r\n        transition: background .2s;\r\n    }\r\n\r\n    .quote-print-btn {\r\n        background: #1a1a1a;\r\n        color: #fff;\r\n    }\r\n\r\n    .quote-print-btn:hover { background: #d32f2f; }\r\n\r\n    .close-quotation-pro {\r\n        background: none;\r\n        color: #666;\r\n        border: 1.5px solid #ccc;\r\n        border-radius: 6px;\r\n        padding: 8px 14px;\r\n        font-size: 13px;\r\n        font-weight: 700;\r\n        cursor: pointer;\r\n        transition: border-color .2s, color .2s;\r\n    }\r\n\r\n    .close-quotation-pro:hover { border-color: #d32f2f; color: #d32f2f; }\r\n\r\n    #quotationContentPro {\r\n        padding: 32px 36px;\r\n    }\r\n    \r\n    .alert-message-pro {\r\n        padding: 12px 15px;\r\n        border-radius: 6px;\r\n        margin: 10px 0;\r\n        font-weight: 600;\r\n        border: 2px solid;\r\n    }\r\n    \r\n    .alert-success-pro {\r\n        background: #d4edda;\r\n        color: #155724;\r\n        border-color: #c3e6cb;\r\n    }\r\n    \r\n    .alert-error-pro {\r\n        background: #f8d7da;\r\n        color: #721c24;\r\n        border-color: #f5c6cb;\r\n    }\r\n    \r\n    .info-row-pro {\r\n        display: flex;\r\n        justify-content: space-between;\r\n        font-size: 11px;\r\n        padding: 3px 0;\r\n    }\r\n    \r\n    .info-label-pro {\r\n        color: #666;\r\n    }\r\n    \r\n    .info-value-pro {\r\n        font-weight: 600;\r\n        color: #000000;\r\n    }\r\n    \r\n    @media (max-width: 768px) {\r\n        .preview-section-pro {\r\n            grid-template-columns: 1fr;\r\n        }\r\n        .settings-grid-pro {\r\n            grid-template-columns: 1fr;\r\n        }\r\n        .contact-btn-pro {\r\n            display: block;\r\n            margin: 5px auto;\r\n            max-width: 200px;\r\n        }\r\n        .submit-btn-pro,\r\n        .cart-btn-pro {\r\n            display: block;\r\n            width: 100%;\r\n            margin: 5px 0;\r\n        }\r\n        .file-header-pro {\r\n            padding-right: 40px;\r\n        }\r\n        .file-status-pro {\r\n            right: 40px;\r\n        }\r\n    }\r\n    \r\n    \/* print handled via JS new-window \u2014 no @media print needed here *\/\r\n\r\n    \/* Guest Capture Modal *\/\r\n    .guest-capture-modal-pro {\r\n        display: none;\r\n        position: fixed;\r\n        top: 0;\r\n        left: 0;\r\n        width: 100%;\r\n        height: 100%;\r\n        background: rgba(0,0,0,0.5);\r\n        z-index: 1100;\r\n        align-items: center;\r\n        justify-content: center;\r\n    }\r\n\r\n    .guest-capture-content-pro {\r\n        background: white;\r\n        padding: 30px;\r\n        border-radius: 10px;\r\n        max-width: 460px;\r\n        width: 90%;\r\n        box-shadow: 0 10px 30px rgba(0,0,0,0.3);\r\n    }\r\n\r\n    .guest-capture-content-pro h3 {\r\n        margin-top: 0;\r\n        color: #dc3545;\r\n        font-size: 20px;\r\n    }\r\n\r\n    .form-group-pro {\r\n        margin-bottom: 15px;\r\n    }\r\n\r\n    .form-group-pro label {\r\n        display: block;\r\n        font-weight: 600;\r\n        margin-bottom: 5px;\r\n        color: #333;\r\n        font-size: 14px;\r\n    }\r\n\r\n    .form-group-pro input[type=\"text\"],\r\n    .form-group-pro input[type=\"email\"],\r\n    .form-group-pro input[type=\"tel\"] {\r\n        width: 100%;\r\n        padding: 10px 12px;\r\n        border: 2px solid #e9ecef;\r\n        border-radius: 6px;\r\n        font-size: 14px;\r\n        box-sizing: border-box;\r\n        transition: border-color 0.2s;\r\n    }\r\n\r\n    .form-group-pro input:focus {\r\n        border-color: #dc3545;\r\n        outline: none;\r\n    }\r\n\r\n    .close-btn {\r\n        padding: 10px 20px;\r\n        background: #6c757d;\r\n        color: white;\r\n        border: none;\r\n        border-radius: 6px;\r\n        font-size: 14px;\r\n        cursor: pointer;\r\n        margin-left: 10px;\r\n        transition: background 0.2s;\r\n    }\r\n\r\n    .close-btn:hover {\r\n        background: #5a6268;\r\n    }\r\n\r\n    \/* Rate limit banner *\/\r\n    .rate-limit-banner-pro {\r\n        background: #fff3cd;\r\n        border: 2px solid #ffc107;\r\n        border-radius: 6px;\r\n        padding: 12px 15px;\r\n        margin: 10px 0;\r\n        color: #856404;\r\n        font-weight: 600;\r\n        display: none;\r\n    }\r\n\r\n    \/* \u2500\u2500\u2500 PPP PAGE LAYOUT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 *\/\r\n    .ppp-page {\r\n        max-width: 1200px;\r\n        margin: 0 auto;\r\n        padding: 0 20px 60px;\r\n        font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;\r\n        color: #2d2d2d;\r\n    }\r\n\r\n    .ppp-hero {\r\n        padding: 40px 0 32px;\r\n        text-align: center;\r\n    }\r\n\r\n    .ppp-hero-title {\r\n        font-size: 32px;\r\n        font-weight: 900;\r\n        color: #1a1a1a;\r\n        margin: 0 0 10px;\r\n        letter-spacing: -.5px;\r\n        line-height: 1.1;\r\n    }\r\n\r\n    .ppp-hero-title span { color: #d32f2f; }\r\n\r\n    .ppp-hero-sub {\r\n        font-size: 15px;\r\n        color: #777;\r\n        margin: 0 auto;\r\n        max-width: 560px;\r\n        line-height: 1.6;\r\n    }\r\n\r\n    .ppp-layout {\r\n        display: grid;\r\n        grid-template-columns: 1fr 292px;\r\n        gap: 28px;\r\n        align-items: start;\r\n    }\r\n\r\n    .ppp-aside {\r\n        position: sticky;\r\n        top: 90px;\r\n        display: flex;\r\n        flex-direction: column;\r\n        gap: 16px;\r\n    }\r\n\r\n    .ppp-info-block {\r\n        background: #fff;\r\n        border: 1px solid #eee;\r\n        border-left: 4px solid #d32f2f;\r\n        border-radius: 10px;\r\n        padding: 16px 18px;\r\n        box-shadow: 0 2px 8px rgba(0,0,0,.05);\r\n    }\r\n\r\n    .ppp-info-block-blue   { border-left-color: #1976D2; }\r\n    .ppp-info-block-green  { border-left-color: #4CAF50; }\r\n    .ppp-info-block-orange { border-left-color: #f57c00; }\r\n\r\n    .ppp-info-block-header {\r\n        display: flex;\r\n        align-items: center;\r\n        gap: 8px;\r\n        margin-bottom: 10px;\r\n        padding-bottom: 8px;\r\n        border-bottom: 1px solid #f0f0f0;\r\n    }\r\n\r\n    .ppp-info-block-header .ppp-icon { font-size: 16px; line-height: 1; }\r\n\r\n    .ppp-info-block h4 {\r\n        font-size: 11px;\r\n        font-weight: 800;\r\n        text-transform: uppercase;\r\n        letter-spacing: .6px;\r\n        color: #d32f2f;\r\n        margin: 0;\r\n    }\r\n\r\n    .ppp-info-block-blue   h4 { color: #1565c0; }\r\n    .ppp-info-block-green  h4 { color: #388e3c; }\r\n    .ppp-info-block-orange h4 { color: #e65100; }\r\n\r\n    .ppp-info-block p {\r\n        font-size: 13px;\r\n        color: #666;\r\n        margin: 0;\r\n        line-height: 1.6;\r\n    }\r\n\r\n    .ppp-info-block ul {\r\n        margin: 0;\r\n        padding: 0 0 0 16px;\r\n    }\r\n\r\n    .ppp-info-block li {\r\n        font-size: 13px;\r\n        color: #666;\r\n        padding: 3px 0;\r\n        line-height: 1.5;\r\n    }\r\n\r\n    @media (max-width: 960px) {\r\n        .ppp-layout { grid-template-columns: 1fr; }\r\n        .ppp-aside  { position: static; }\r\n    }\r\n\r\n    @media (max-width: 700px) {\r\n        .ppp-hero-title { font-size: 24px; }\r\n        .ppp-hero { padding: 24px 0 20px; }\r\n    }\r\n\r\n    \/* === TDP Design Tips === *\/\r\n    .tdp-design-tip {\r\n        margin: 12px 0 8px 0;\r\n        padding: 14px 16px;\r\n        border-radius: 8px;\r\n        border-left: 4px solid #e0a800;\r\n        background: #fffdf0;\r\n        font-size: 14px;\r\n        line-height: 1.6;\r\n    }\r\n    .tdp-tip-excellent { border-left-color: #28a745; background: #f0fff4; }\r\n    .tdp-tip-good      { border-left-color: #17a2b8; background: #f0faff; }\r\n    .tdp-tip-fair      { border-left-color: #e0a800; background: #fffdf0; }\r\n    .tdp-tip-poor      { border-left-color: #dc3545; background: #fff5f5; }\r\n\r\n    .tdp-tip-header {\r\n        display: flex;\r\n        align-items: center;\r\n        gap: 8px;\r\n        margin-bottom: 6px;\r\n        font-weight: 600;\r\n        font-size: 14px;\r\n    }\r\n    .tdp-tip-body { margin: 0 0 10px 0; color: #444; }\r\n\r\n    .tdp-tip-cta { margin-top: 12px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.08); }\r\n    .tdp-tip-cta-title { font-weight: 600; margin: 0 0 8px 0; }\r\n    .tdp-tip-phone-label { display: block; font-size: 13px; margin-bottom: 4px; color: #555; }\r\n    .tdp-tip-phone-input {\r\n        width: 100%;\r\n        padding: 7px 10px;\r\n        border: 1px solid #ddd;\r\n        border-radius: 5px;\r\n        font-size: 13px;\r\n        margin-bottom: 10px;\r\n        box-sizing: border-box;\r\n    }\r\n    .tdp-tip-guest-note {\r\n        font-size: 13px;\r\n        color: #666;\r\n        margin: 0 0 8px 0;\r\n    }\r\n    .tdp-tip-send-btn {\r\n        display: inline-block;\r\n        padding: 8px 18px;\r\n        background: #333;\r\n        color: #fff;\r\n        border: none;\r\n        border-radius: 5px;\r\n        font-size: 13px;\r\n        cursor: pointer;\r\n        transition: background .2s, opacity .2s;\r\n    }\r\n    .tdp-tip-send-btn:hover:not(:disabled) { background: #555; }\r\n    .tdp-tip-send-btn:disabled { opacity: .45; cursor: default; }\r\n    .tdp-tip-send-msg {\r\n        display: none;\r\n        margin-top: 10px;\r\n        padding: 10px 14px;\r\n        border-radius: 6px;\r\n        font-size: 13px;\r\n        font-weight: 600;\r\n        line-height: 1.4;\r\n    }\r\n    .tdp-tip-send-msg.tdp-send-success {\r\n        background: #eafaf1;\r\n        border: 1px solid #a9dfbf;\r\n        color: #1a7f4b;\r\n    }\r\n    .tdp-tip-send-msg.tdp-send-error {\r\n        background: #fdf3f2;\r\n        border: 1px solid #f5b7b1;\r\n        color: #c0392b;\r\n    }\r\n\r\n    \/* RTL support for Arabic *\/\r\n    [lang=\"ar\"] .tdp-design-tip { direction: rtl; text-align: right; }\r\n    [lang=\"ar\"] .tdp-tip-header { flex-direction: row-reverse; }\r\n    [lang=\"ar\"] .tdp-design-tip { border-left: none; border-right: 4px solid #e0a800; }\r\n    [lang=\"ar\"] .tdp-tip-excellent { border-right-color: #28a745; }\r\n    [lang=\"ar\"] .tdp-tip-good      { border-right-color: #17a2b8; }\r\n    [lang=\"ar\"] .tdp-tip-fair      { border-right-color: #e0a800; }\r\n    [lang=\"ar\"] .tdp-tip-poor      { border-right-color: #dc3545; }\r\n<\/style>\r\n\r\n<!-- Modal pour le devis -->\r\n<div class=\"quotation-modal-pro\" id=\"quotationModalPro\">\r\n    <div class=\"quotation-content-pro\">\r\n        <div class=\"quote-modal-actions\">\r\n            <button class=\"quote-action-btn quote-print-btn\" onclick=\"printQuotePro()\">\r\n                \ud83d\udda8\ufe0f Imprimer \/ Enregistrer en PDF            <\/button>\r\n            <button class=\"close-quotation-pro\" onclick=\"closeQuotationPro()\">\u2715 Fermer<\/button>\r\n        <\/div>\r\n        <div id=\"quotationContentPro\"><\/div>\r\n    <\/div>\r\n<\/div>\r\n\r\n<div class=\"ppp-page\">\r\n\r\n    <!-- PAGE HERO -->\r\n    <div class=\"ppp-hero\">\r\n        <h1 class=\"ppp-hero-title\">T\u00e9l\u00e9chargez et commandez votre <span>design imprim\u00e9 en 3D<\/span><\/h1>\r\n        <p class=\"ppp-hero-sub\">Services d&#039;impression 3D professionnels avec des d\u00e9lais rapides et des tarifs comp\u00e9titifs. T\u00e9l\u00e9chargez vos fichiers STL pour obtenir un devis instantan\u00e9.<\/p>\r\n    <\/div>\r\n\r\n    <div class=\"ppp-layout\">\r\n\r\n        <!-- MAIN FORM COLUMN -->\r\n        <main class=\"ppp-main\">\r\n            <div class=\"stl-upload-container-pro\">\r\n                <h3>Calculateur de devis d&#039;impression 3D<\/h3>\r\n    \r\n    <div class=\"upload-area-pro\" id=\"uploadAreaPro\">\r\n        <input type=\"file\" id=\"stlFilesPro\" accept=\".stl,.obj\" multiple style=\"display:none;\" \/>\r\n        <button type=\"button\" class=\"upload-button-pro\" onclick=\"document.getElementById('stlFilesPro').click()\">\r\n            T\u00e9l\u00e9chargez vos fichiers 3D        <\/button>\r\n        <div class=\"upload-info-pro\">\r\n            S\u00e9lectionnez jusqu&#039;\u00e0 10 fichiers (STL, OBJ) \u2022 Max 100 Mo par fichier \u2022 Max 200 Mo au total        <\/div>\r\n    <\/div>\r\n    \r\n    <div id=\"alertContainerPro\"><\/div>\r\n    <div class=\"rate-limit-banner-pro\" id=\"rateLimitBannerPro\"><\/div>\r\n    <div class=\"file-counter-pro\" id=\"fileCounterPro\"><\/div>\r\n    <div id=\"stlFilesListPro\"><\/div>\r\n    \r\n    <div class=\"total-section-pro\">\r\n        <div id=\"totalProjectCostLabel\">Co\u00fbt total du projet (TTC)<\/div>\r\n        <div class=\"total-amount-pro\"><span id=\"totalPricePro\">0.000<\/span> TND<\/div>\r\n        <div style=\"font-size: 12px; color: #888; margin-top: 4px;\">\r\n            <div id=\"priceBreakdownPro\">\r\n                <!-- The label is in a span so JS can swap it from i18n.totalHT (raw .mo read,\r\n                     no TRP gettext interception). Same trick prevents the same string from\r\n                     showing up in two languages on one page (static HTML vs cost-item-pro). -->\r\n                <div><span id=\"breakdownHTLabel\">Total excl. tax:<\/span> <span id=\"breakdownHT\">0.000 TND<\/span><\/div>\r\n            <\/div>\r\n        <\/div>\r\n    <\/div>\r\n    \r\n    <div class=\"submit-section-pro\">\r\n        <button class=\"submit-btn-pro\" id=\"submitStlOrderPro\" disabled>\r\n            Voir le devis d\u00e9taill\u00e9        <\/button>\r\n        \r\n        <button class=\"cart-btn-pro\" id=\"addToCartPro\" disabled>\r\n            Ajouter au panier        <\/button>\r\n        \r\n        <div class=\"contact-section-pro\">\r\n            <span class=\"contact-section-label-pro\">Besoin d&#039;aide ? Contactez-nous<\/span>\r\n            <div>\r\n                <a href=\"tel:+21656401310\" class=\"contact-btn-pro\">\ud83d\udcde Appeler: <bdi dir=\"ltr\">56 401 310<\/bdi><\/a>\r\n                <a href=\"https:\/\/wa.me\/21656401310\" class=\"contact-btn-pro whatsapp-btn-pro\" target=\"_blank\">\ud83d\udcac WhatsApp<\/a>\r\n            <\/div>\r\n        <\/div>\r\n    <\/div>\r\n<\/div><!-- \/.stl-upload-container-pro -->\r\n        <\/main><!-- \/.ppp-main -->\r\n\r\n        <!-- SIDEBAR -->\r\n        <aside class=\"ppp-aside\">\r\n\r\n            <div class=\"ppp-info-block\">\r\n                <div class=\"ppp-info-block-header\">\r\n                    <span class=\"ppp-icon\">\u23f1<\/span>\r\n                    <h4>D\u00e9lai rapide<\/h4>\r\n                <\/div>\r\n                <p>La plupart des impressions sont r\u00e9alis\u00e9es en 3 \u00e0 5 jours ouvrables. Commandes urgentes disponibles.<\/p>\r\n            <\/div>\r\n\r\n            <div class=\"ppp-info-block ppp-info-block-blue\">\r\n                <div class=\"ppp-info-block-header\">\r\n                    <span class=\"ppp-icon\">\u2705<\/span>\r\n                    <h4>Garantie qualit\u00e9<\/h4>\r\n                <\/div>\r\n                <p>Impressions et mat\u00e9riaux de qualit\u00e9 professionnelle. Satisfaction 100% garantie.<\/p>\r\n            <\/div>\r\n\r\n            <div class=\"ppp-info-block ppp-info-block-orange\">\r\n                <div class=\"ppp-info-block-header\">\r\n                    <span class=\"ppp-icon\">\ud83e\uddf1<\/span>\r\n                    <h4>Mat\u00e9riaux disponibles<\/h4>\r\n                <\/div>\r\n                <!-- Material names contain Latin acronyms (PLA, PETG, ABS, TPU, Resin) that\r\n                     must NEVER be transliterated. We use $_tdp_t() (raw .mo read, bypasses\r\n                     TRP's gettext filter) AND notranslate flags (block TRP's HTML post-\r\n                     processor) so neither layer can replace \"Resin\" with \"\u0627\u0644\u0631\u0627\u062a\u0646\u062c\" or any\r\n                     other Auto-Translate cache entry. Same defense pattern as the JS i18n. -->\r\n                <ul class=\"notranslate\" translate=\"no\" data-no-translation>\r\n                    <li class=\"notranslate\" translate=\"no\" data-no-translation>PLA, PLA+ (Standard &amp; Premium)<\/li>\r\n                    <li class=\"notranslate\" translate=\"no\" data-no-translation>PETG (R\u00e9sistant aux produits chimiques)<\/li>\r\n                    <li class=\"notranslate\" translate=\"no\" data-no-translation>ABS (Haute temp\u00e9rature)<\/li>\r\n                    <li class=\"notranslate\" translate=\"no\" data-no-translation>TPU (Flexible)<\/li>\r\n                    <li class=\"notranslate\" translate=\"no\" data-no-translation>SLA - R\u00e9sine (Haute pr\u00e9cision)<\/li>\r\n                <\/ul>\r\n            <\/div>\r\n\r\n            <div class=\"ppp-info-block ppp-info-block-green\">\r\n                <div class=\"ppp-info-block-header\">\r\n                    <span class=\"ppp-icon\">\ud83d\udcc1<\/span>\r\n                    <h4>Exigences du fichier<\/h4>\r\n                <\/div>\r\n                <ul>\r\n                    <li>Format STL, OBJ<\/li>\r\n                    <li>Max 100 Mo par fichier \u2022 Max 200 Mo au total<\/li>\r\n                    <li>Maillages \u00e9tanches<\/li>\r\n                    <li>D\u00e9tails minimum 0,4 mm<\/li>\r\n                <\/ul>\r\n            <\/div>\r\n\r\n        <\/aside><!-- \/.ppp-aside -->\r\n\r\n    <\/div><!-- \/.ppp-layout -->\r\n<\/div><!-- \/.ppp-page -->\r\n\r\n<script>\r\n\/\/ Server-detected active language \u2014 consumed by external tdp-pricing.js\r\nwindow.tdpActiveLang = \"fr_FR\";\r\nconsole.log('[3dp-pricing] inline: tdpActiveLang =', window.tdpActiveLang || '(empty)');\r\n\r\n\/\/ Global store: maps fileId \u2192 original File object for the \"Send to our team\" feature.\r\nconst tdpFileStore = {};\r\n\r\n\/\/ Configuration\r\nconst materialConfigPro = [];\r\nconst qualityConfigPro = [];\r\nconst machineConfigPro = [];\r\nconst generalConfigPro = [];\r\nconst quantityConfigPro = [];\r\n\r\n\/\/ Translatable strings for JavaScript.\r\n\/\/\r\n\/\/ IMPORTANT: All defaults below are HARDCODED ENGLISH literals \u2014 never use PHP __()\r\n\/\/ here. Why? TranslatePress's Auto-Translate Addon hooks the 'gettext' filter, and\r\n\/\/ that filter fires REGARDLESS of which page you're on. On the English page, TRP can\r\n\/\/ still return a cached French translation (e.g. \"Imprimabilit\u00e9 : Faible\") for a key\r\n\/\/ that was once translated during a French session. Hardcoded JS literals bypass\r\n\/\/ gettext entirely. The JS overlay (tdp-pricing.js \u2192 tdpApplyI18n) replaces these\r\n\/\/ defaults with the correct AR or FR strings ONLY when detectLang() returns 'ar' or\r\n\/\/ 'fr_FR' \u2014 i.e. when the URL is \/ar\/... or \/fr\/... On the English URL the overlay\r\n\/\/ is skipped and these literals stay = English. \u2713\r\nconst i18n = {\r\n    processing:        \"Processing...\",\r\n    ready:             \"Ready\",\r\n    error:             \"Error\",\r\n    calculating:       \"Calculating...\",\r\n    calculatingCosts:  \"Calculating costs...\",\r\n    allFilesProcessed: \"\u2705 All files processed successfully!\",\r\n    noFiles:           \"Please upload at least one 3D file (STL or OBJ).\",\r\n    orderCreated:      \"\u2705 Order created successfully! Redirecting to cart...\",\r\n    orderError:        \"\u274c Error: Unable to create order.\",\r\n    connectionError:   \"\u274c Connection error. Please contact us directly.\",\r\n    fileCountPrefix:   \"\ud83d\udcca\",\r\n    noFileSelected:    \"\ud83d\udcc1 No file selected\",\r\n    loadingViewer:     \"Loading 3D viewer...\",\r\n    uploadPlaceholder: \"\ud83d\udce4 Upload 3D file (STL or OBJ)\",\r\n    dimensions: {\r\n        width:  \"Width\",\r\n        height: \"Height\",\r\n        depth:  \"Depth\"\r\n    },\r\n    volume:               \"Volume\",\r\n    printability:         \"Printability\",\r\n    totalHT:              \"Total excl. tax:\",\r\n    totalTTC:             \"Total incl. tax:\",\r\n    filesSelectedSuffix:  \"file(s) selected\",\r\n    fileUnreadable:    \"Unable to read file\",\r\n    fileUnreadableMsg: \"We're sorry, we couldn't read this file. Please contact us directly and we'll take care of it.\",\r\n    viewerDragHint:    \"\ud83d\uddb1\ufe0f Drag to rotate \u2022 Scroll to zoom\",\r\n    viewerLoadError:   \"Failed to load 3D model\",\r\n    viewerError:       \"3D viewer error\",\r\n    dimensionsMm:      \"Dimensions (mm)\",\r\n    cartProcessing:    \"\u23f3 Processing...\",\r\n    \/\/ Form labels\r\n    labelTechnology:   \"Technology\",\r\n    labelMaterial:     \"Material\",\r\n    labelColor:        \"Color\",\r\n    labelQuality:      \"Quality\",\r\n    labelQuantity:     \"Quantity\",\r\n    \/\/ Quality options\r\n    qualityLow:        \"Low\",\r\n    qualityStandard:   \"Standard\",\r\n    qualityHigh:       \"High\",\r\n    \/\/ Color names\r\n    colorWhite:  \"White\",  colorBlack:  \"Black\",  colorRed:    \"Red\",\r\n    colorGreen:  \"Green\",  colorBlue:   \"Blue\",   colorGray:   \"Gray\",\r\n    colorYellow: \"Yellow\",\r\n    \/\/ SLA material display names\r\n    matStandardResin: \"Standard Resin\",\r\n    matHighResResin:  \"High Resolution Resin\",\r\n    \/\/ Printability scores\r\n    printabilityExcellent:     \"Printability: Excellent\",\r\n    printabilityGood:          \"Printability: Good\",\r\n    printabilityFair:          \"Printability: Fair\",\r\n    printabilityPoor:          \"Printability: Poor\",\r\n    printabilityFairEstimated: \"Printability: Fair (Estimated)\",\r\n    \/\/ Per-locale translations \u2014 only applied when JS detectLang() returns 'ar' or\r\n    \/\/ 'fr_FR' (i.e. URL has \/ar\/ or \/fr\/ prefix). Built from raw .mo file reads,\r\n    \/\/ so no gettext filter (and hence no TRP) can pollute these values.\r\n    _i18n: {\"fr_FR\":{\"processing\":\"Traitement en cours...\",\"ready\":\"Pr\\u00eat\",\"error\":\"Erreur\",\"calculating\":\"Calcul en cours...\",\"calculatingCosts\":\"Calcul des co\\u00fbts...\",\"allFilesProcessed\":\"\\u2705 Tous les fichiers ont \\u00e9t\\u00e9 trait\\u00e9s avec succ\\u00e8s !\",\"noFiles\":\"Veuillez t\\u00e9l\\u00e9charger au moins un fichier 3D (STL ou OBJ).\",\"orderCreated\":\"\\u2705 Commande cr\\u00e9\\u00e9e avec succ\\u00e8s ! Redirection vers le panier...\",\"orderError\":\"\\u274c Erreur : Impossible de cr\\u00e9er la commande.\",\"connectionError\":\"\\u274c Erreur de connexion. Veuillez nous contacter directement.\",\"noFileSelected\":\"\\ud83d\\udcc1 Aucun fichier s\\u00e9lectionn\\u00e9\",\"loadingViewer\":\"Chargement du visualiseur 3D...\",\"uploadPlaceholder\":\"\\ud83d\\udce4 T\\u00e9l\\u00e9chargez un fichier 3D (STL ou OBJ)\",\"dimensions\":{\"width\":\"Largeur\",\"height\":\"Hauteur\",\"depth\":\"Profondeur\"},\"volume\":\"Volume\",\"printability\":\"Imprimabilit\\u00e9\",\"totalHT\":\"Total HT :\",\"totalTTC\":\"Total TTC :\",\"filesSelectedSuffix\":\"fichier(s) s\\u00e9lectionn\\u00e9(s)\",\"fileUnreadable\":\"Impossible de lire le fichier\",\"fileUnreadableMsg\":\"Nous sommes d\\u00e9sol\\u00e9s, nous n'avons pas pu lire ce fichier. Veuillez nous contacter directement et nous nous en occuperons.\",\"viewerDragHint\":\"\\ud83d\\uddb1\\ufe0f Faites glisser pour pivoter \\u2022 D\\u00e9filez pour zoomer\",\"viewerLoadError\":\"Impossible de charger le mod\\u00e8le 3D\",\"viewerError\":\"Erreur du visualiseur 3D\",\"dimensionsMm\":\"Dimensions (mm)\",\"cartProcessing\":\"\\u23f3 Traitement en cours...\",\"labelTechnology\":\"Technologie\",\"labelMaterial\":\"Mat\\u00e9riau\",\"labelColor\":\"Couleur\",\"labelQuality\":\"Qualit\\u00e9\",\"labelQuantity\":\"Quantit\\u00e9\",\"qualityLow\":\"Basse\",\"qualityStandard\":\"Standard\",\"qualityHigh\":\"Haute\",\"colorWhite\":\"Blanc\",\"colorBlack\":\"Noir\",\"colorRed\":\"Rouge\",\"colorGreen\":\"Vert\",\"colorBlue\":\"Bleu\",\"colorGray\":\"Gris\",\"colorYellow\":\"Jaune\",\"matStandardResin\":\"R\\u00e9sine Standard\",\"matHighResResin\":\"R\\u00e9sine Haute R\\u00e9solution\",\"printabilityExcellent\":\"Imprimabilit\\u00e9 : Excellente\",\"printabilityGood\":\"Imprimabilit\\u00e9 : Bonne\",\"printabilityFair\":\"Imprimabilit\\u00e9 : Moyenne\",\"printabilityPoor\":\"Imprimabilit\\u00e9 : Faible\",\"printabilityFairEstimated\":\"Imprimabilit\\u00e9 : Moyenne (estim\\u00e9e)\"},\"ar\":{\"processing\":\"\\u062c\\u0627\\u0631\\u064d \\u0627\\u0644\\u0645\\u0639\\u0627\\u0644\\u062c\\u0629...\",\"ready\":\"\\u062c\\u0627\\u0647\\u0632\",\"error\":\"\\u062e\\u0637\\u0623\",\"calculating\":\"\\u062c\\u0627\\u0631\\u064d \\u0627\\u0644\\u062d\\u0633\\u0627\\u0628...\",\"calculatingCosts\":\"\\u062c\\u0627\\u0631\\u064d \\u062d\\u0633\\u0627\\u0628 \\u0627\\u0644\\u062a\\u0643\\u0627\\u0644\\u064a\\u0641...\",\"allFilesProcessed\":\"\\u2705 \\u062a\\u0645\\u062a \\u0645\\u0639\\u0627\\u0644\\u062c\\u0629 \\u062c\\u0645\\u064a\\u0639 \\u0627\\u0644\\u0645\\u0644\\u0641\\u0627\\u062a \\u0628\\u0646\\u062c\\u0627\\u062d!\",\"noFiles\":\"\\u064a\\u0631\\u062c\\u0649 \\u062a\\u062d\\u0645\\u064a\\u0644 \\u0645\\u0644\\u0641 3D \\u0648\\u0627\\u062d\\u062f \\u0639\\u0644\\u0649 \\u0627\\u0644\\u0623\\u0642\\u0644 (STL \\u0623\\u0648 OBJ).\",\"orderCreated\":\"\\u2705 \\u062a\\u0645 \\u0625\\u0646\\u0634\\u0627\\u0621 \\u0627\\u0644\\u0637\\u0644\\u0628 \\u0628\\u0646\\u062c\\u0627\\u062d! \\u062c\\u0627\\u0631\\u064d \\u0627\\u0644\\u062a\\u062d\\u0648\\u064a\\u0644 \\u0625\\u0644\\u0649 \\u0627\\u0644\\u0633\\u0644\\u0629...\",\"orderError\":\"\\u274c \\u062e\\u0637\\u0623: \\u062a\\u0639\\u0630\\u0631 \\u0625\\u0646\\u0634\\u0627\\u0621 \\u0627\\u0644\\u0637\\u0644\\u0628.\",\"connectionError\":\"\\u274c \\u062e\\u0637\\u0623 \\u0641\\u064a \\u0627\\u0644\\u0627\\u062a\\u0635\\u0627\\u0644. \\u064a\\u0631\\u062c\\u0649 \\u0627\\u0644\\u062a\\u0648\\u0627\\u0635\\u0644 \\u0645\\u0639\\u0646\\u0627 \\u0645\\u0628\\u0627\\u0634\\u0631\\u0629.\",\"noFileSelected\":\"\\ud83d\\udcc1 \\u0644\\u0645 \\u064a\\u064f\\u062d\\u062f\\u062f \\u0623\\u064a \\u0645\\u0644\\u0641\",\"loadingViewer\":\"\\u062c\\u0627\\u0631\\u064d \\u062a\\u062d\\u0645\\u064a\\u0644 \\u0639\\u0627\\u0631\\u0636 3D...\",\"uploadPlaceholder\":\"\\ud83d\\udce4 \\u062d\\u0645\\u0651\\u0644 \\u0645\\u0644\\u0641 3D (STL \\u0623\\u0648 OBJ)\",\"dimensions\":{\"width\":\"\\u0627\\u0644\\u0639\\u0631\\u0636\",\"height\":\"\\u0627\\u0644\\u0627\\u0631\\u062a\\u0641\\u0627\\u0639\",\"depth\":\"\\u0627\\u0644\\u0639\\u0645\\u0642\"},\"volume\":\"\\u0627\\u0644\\u062d\\u062c\\u0645\",\"printability\":\"\\u0642\\u0627\\u0628\\u0644\\u064a\\u0629 \\u0627\\u0644\\u0637\\u0628\\u0627\\u0639\\u0629\",\"totalHT\":\"\\u0627\\u0644\\u0645\\u062c\\u0645\\u0648\\u0639 \\u0628\\u062f\\u0648\\u0646 \\u0636\\u0631\\u064a\\u0628\\u0629:\",\"totalTTC\":\"\\u0627\\u0644\\u0645\\u062c\\u0645\\u0648\\u0639 \\u0645\\u0639 \\u0627\\u0644\\u0636\\u0631\\u064a\\u0628\\u0629:\",\"filesSelectedSuffix\":\"\\u0645\\u0644\\u0641\\\/\\u0645\\u0644\\u0641\\u0627\\u062a \\u0645\\u062d\\u062f\\u062f\\u0629\",\"fileUnreadable\":\"\\u062a\\u0639\\u0630\\u0631 \\u0642\\u0631\\u0627\\u0621\\u0629 \\u0627\\u0644\\u0645\\u0644\\u0641\",\"fileUnreadableMsg\":\"\\u0646\\u0623\\u0633\\u0641\\u060c \\u0644\\u0645 \\u0646\\u062a\\u0645\\u0643\\u0646 \\u0645\\u0646 \\u0642\\u0631\\u0627\\u0621\\u0629 \\u0647\\u0630\\u0627 \\u0627\\u0644\\u0645\\u0644\\u0641. \\u064a\\u0631\\u062c\\u0649 \\u0627\\u0644\\u062a\\u0648\\u0627\\u0635\\u0644 \\u0645\\u0639\\u0646\\u0627 \\u0645\\u0628\\u0627\\u0634\\u0631\\u0629 \\u0648\\u0633\\u0646\\u062a\\u0648\\u0644\\u0649 \\u0627\\u0644\\u0623\\u0645\\u0631.\",\"viewerDragHint\":\"\\ud83d\\uddb1\\ufe0f \\u0627\\u0633\\u062d\\u0628 \\u0644\\u0644\\u062a\\u062f\\u0648\\u064a\\u0631 \\u2022 \\u0645\\u0631\\u0631 \\u0644\\u0644\\u062a\\u0643\\u0628\\u064a\\u0631\",\"viewerLoadError\":\"\\u0641\\u0634\\u0644 \\u062a\\u062d\\u0645\\u064a\\u0644 \\u0627\\u0644\\u0646\\u0645\\u0648\\u0630\\u062c 3D\",\"viewerError\":\"\\u062e\\u0637\\u0623 \\u0641\\u064a \\u0639\\u0627\\u0631\\u0636 3D\",\"dimensionsMm\":\"\\u0627\\u0644\\u0623\\u0628\\u0639\\u0627\\u062f (\\u0645\\u0645)\",\"cartProcessing\":\"\\u23f3 \\u062c\\u0627\\u0631\\u064d \\u0627\\u0644\\u0645\\u0639\\u0627\\u0644\\u062c\\u0629...\",\"labelTechnology\":\"\\u0627\\u0644\\u062a\\u0642\\u0646\\u064a\\u0629\",\"labelMaterial\":\"\\u0627\\u0644\\u0645\\u0627\\u062f\\u0629\",\"labelColor\":\"\\u0627\\u0644\\u0644\\u0648\\u0646\",\"labelQuality\":\"\\u0627\\u0644\\u062c\\u0648\\u062f\\u0629\",\"labelQuantity\":\"\\u0627\\u0644\\u0643\\u0645\\u064a\\u0629\",\"qualityLow\":\"\\u0645\\u0646\\u062e\\u0641\\u0636\\u0629\",\"qualityStandard\":\"\\u0642\\u064a\\u0627\\u0633\\u064a\",\"qualityHigh\":\"\\u0639\\u0627\\u0644\\u064a\\u0629\",\"colorWhite\":\"\\u0623\\u0628\\u064a\\u0636\",\"colorBlack\":\"\\u0623\\u0633\\u0648\\u062f\",\"colorRed\":\"\\u0623\\u062d\\u0645\\u0631\",\"colorGreen\":\"\\u0623\\u062e\\u0636\\u0631\",\"colorBlue\":\"\\u0623\\u0632\\u0631\\u0642\",\"colorGray\":\"\\u0631\\u0645\\u0627\\u062f\\u064a\",\"colorYellow\":\"\\u0623\\u0635\\u0641\\u0631\",\"matStandardResin\":\"Resin \\u0642\\u064a\\u0627\\u0633\\u064a (SLA)\",\"matHighResResin\":\"Resin \\u0639\\u0627\\u0644\\u064a \\u0627\\u0644\\u062f\\u0642\\u0629 (SLA)\",\"printabilityExcellent\":\"\\u0642\\u0627\\u0628\\u0644\\u064a\\u0629 \\u0627\\u0644\\u0637\\u0628\\u0627\\u0639\\u0629: \\u0645\\u0645\\u062a\\u0627\\u0632\\u0629\",\"printabilityGood\":\"\\u0642\\u0627\\u0628\\u0644\\u064a\\u0629 \\u0627\\u0644\\u0637\\u0628\\u0627\\u0639\\u0629: \\u062c\\u064a\\u062f\\u0629\",\"printabilityFair\":\"\\u0642\\u0627\\u0628\\u0644\\u064a\\u0629 \\u0627\\u0644\\u0637\\u0628\\u0627\\u0639\\u0629: \\u0645\\u062a\\u0648\\u0633\\u0637\\u0629\",\"printabilityPoor\":\"\\u0642\\u0627\\u0628\\u0644\\u064a\\u0629 \\u0627\\u0644\\u0637\\u0628\\u0627\\u0639\\u0629: \\u0636\\u0639\\u064a\\u0641\\u0629\",\"printabilityFairEstimated\":\"\\u0642\\u0627\\u0628\\u0644\\u064a\\u0629 \\u0627\\u0644\\u0637\\u0628\\u0627\\u0639\\u0629: \\u0645\\u062a\\u0648\\u0633\\u0637\\u0629 (\\u062a\\u0642\\u062f\\u064a\\u0631\\u064a\\u0629)\"}}};\r\n\/\/ Apply translation overlay for inline i18n labels (function defined in tdp-pricing.js)\r\nif (typeof window.tdpApplyI18n === 'function') {\r\n    window.tdpApplyI18n(i18n, 'i18n');\r\n} else {\r\n    console.warn('[3dp-pricing] tdpApplyI18n not found \u2014 external tdp-pricing.js did not load');\r\n}\r\n\r\n\/\/ Sync the static-HTML label with i18n.totalHT so the \"Total excl. tax:\" line above\r\n\/\/ the breakdown is always in the same language as the JS-rendered cost-item-pro lines.\r\n\/\/ Done after overlay so we pick up the right AR\/FR string (or the English default on\r\n\/\/ the English page). textContent on a span \u2014 TRP can't grab onto it because the parent\r\n\/\/ element keeps notranslate semantics via the i18n source it pulled from.\r\n(function syncStaticLabels() {\r\n    var lbl = document.getElementById('breakdownHTLabel');\r\n    if (lbl) lbl.textContent = i18n.totalHT;\r\n})();\r\n\r\n\/\/ Mapping des couleurs pour les mat\u00e9riaux\r\nconst materialColors = {\r\n    'White': 0xFFFFFF, 'Black': 0x111111, 'Red': 0xFF0000, 'Green': 0x00FF00,\r\n    'Blue': 0x0000FF, 'Gray': 0x808080, 'Yellow': 0xFFFF00\r\n};\r\n\r\n\/\/ =====================================================================\r\n\/\/ CALCULATEUR DE VOLUME ET SURFACE STL COMPLET\r\n\/\/ =====================================================================\r\n\r\n\/\/ Maximum number of triangles for which the deep analysis (overhangs in\r\n\/\/ 6 orientations, grid-based wall thickness, edge-map mesh integrity) is\r\n\/\/ run. Above this, the calculator falls back to single-orientation overhang\r\n\/\/ + 3V\/SA thickness so the browser stays responsive.\r\nconst TDP_MAX_ANALYSIS_TRIANGLES = 100000;\r\n\r\n\/\/ =====================================================================\r\n\/\/ GEOMETRY ANALYZER \u2014 overhangs, thickness, mesh integrity\r\n\/\/ =====================================================================\r\nclass TDPGeometryAnalyzer {\r\n\r\n    static thicknessToScore(thicknessMm) {\r\n        if (thicknessMm >= 3.0)      return 20;\r\n        else if (thicknessMm >= 2.0) return 16;\r\n        else if (thicknessMm >= 1.2) return 12;\r\n        else if (thicknessMm >= 1.0) return 6;\r\n        else if (thicknessMm >= 0.6) return 2;\r\n        else                         return 0;\r\n    }\r\n\r\n    \/**\r\n     * Mesh integrity via edge map + signed-vs-absolute volume.\r\n     * Returns { score: 0..20, data: { ... } } or null if triangles unavailable.\r\n     *\/\r\n    static analyzeMeshIntegrity(triangles, signedVolume, absoluteVolume) {\r\n        if (!triangles || triangles.length === 0) return null;\r\n\r\n        const edgeMap = new Map();\r\n        const round = v => Math.round(v * 1000) \/ 1000;\r\n\r\n        for (let i = 0; i < triangles.length; i++) {\r\n            const t = triangles[i];\r\n            const verts = [\r\n                [round(t.v1.x), round(t.v1.y), round(t.v1.z)],\r\n                [round(t.v2.x), round(t.v2.y), round(t.v2.z)],\r\n                [round(t.v3.x), round(t.v3.y), round(t.v3.z)]\r\n            ];\r\n\r\n            for (let e = 0; e < 3; e++) {\r\n                const a = verts[e];\r\n                const b = verts[(e + 1) % 3];\r\n                \/\/ sort vertices so edge AB and BA collide\r\n                const cmp = (a[0] - b[0]) || (a[1] - b[1]) || (a[2] - b[2]);\r\n                const key = cmp < 0\r\n                    ? a[0] + ',' + a[1] + ',' + a[2] + '_' + b[0] + ',' + b[1] + ',' + b[2]\r\n                    : b[0] + ',' + b[1] + ',' + b[2] + '_' + a[0] + ',' + a[1] + ',' + a[2];\r\n                edgeMap.set(key, (edgeMap.get(key) || 0) + 1);\r\n            }\r\n        }\r\n\r\n        let nonManifoldEdges = 0;\r\n        let openEdges = 0;\r\n        for (const c of edgeMap.values()) {\r\n            if (c > 2)      nonManifoldEdges++;\r\n            else if (c === 1) openEdges++;\r\n        }\r\n        const uniqueEdges = edgeMap.size;\r\n        const nonManifoldPct = uniqueEdges > 0 ? nonManifoldEdges \/ uniqueEdges : 0;\r\n        const openEdgePct    = uniqueEdges > 0 ? openEdges \/ uniqueEdges : 0;\r\n        const volumeConsistency = absoluteVolume > 0 ? Math.abs(signedVolume) \/ absoluteVolume : 0;\r\n\r\n        let meshScore = 20;\r\n\r\n        if (nonManifoldEdges > 10 || nonManifoldPct > 0.005) meshScore -= 12;\r\n        else if (nonManifoldEdges > 3)                       meshScore -= 6;\r\n        else if (nonManifoldEdges > 0)                       meshScore -= 2;\r\n\r\n        if (openEdgePct > 0.01)  meshScore -= 6;\r\n        else if (openEdges > 10) meshScore -= 4;\r\n        else if (openEdges > 0)  meshScore -= 1;\r\n\r\n        if (volumeConsistency < 0.50)      meshScore -= 8;\r\n        else if (volumeConsistency < 0.80) meshScore -= 4;\r\n        else if (volumeConsistency < 0.95) meshScore -= 2;\r\n\r\n        meshScore = Math.max(0, meshScore);\r\n\r\n        const isCritical = nonManifoldEdges > 10 || openEdgePct > 0.01 || volumeConsistency < 0.50;\r\n        const isWarning  = (nonManifoldEdges > 0 || openEdges > 0 || volumeConsistency < 0.95) && !isCritical;\r\n\r\n        return {\r\n            score: meshScore,\r\n            data: {\r\n                nonManifoldEdges,\r\n                openEdges,\r\n                volumeConsistency,\r\n                isCritical,\r\n                isWarning\r\n            }\r\n        };\r\n    }\r\n\r\n    \/**\r\n     * Overhang analysis. Runs single Z-down check by default; if triangle\r\n     * count <= TDP_MAX_ANALYSIS_TRIANGLES, runs all 6 build orientations.\r\n     * Returns { score: 0..30, data: { ... } } or null if triangles unavailable.\r\n     *\/\r\n    static analyzeOverhangs(triangles, totalSurfaceMm2) {\r\n        if (!triangles || triangles.length === 0) return null;\r\n\r\n        const orientations = [\r\n            [ 0,  0, -1],  \/\/ Z-down (default build orientation)\r\n            [ 0,  0,  1],  \/\/ Z-up   (printed inverted)\r\n            [ 0, -1,  0],  \/\/ Y-down\r\n            [ 0,  1,  0],  \/\/ Y-up\r\n            [-1,  0,  0],  \/\/ X-down\r\n            [ 1,  0,  0]   \/\/ X-up\r\n        ];\r\n\r\n        const fullCheck = triangles.length <= TDP_MAX_ANALYSIS_TRIANGLES;\r\n        const orientList = fullCheck ? orientations : [orientations[0]];\r\n\r\n        const SPAN_THRESHOLD_MM    = 10;\r\n        const AREA_PCT_THRESHOLD   = 0.02;\r\n        const CRITICAL_ANGLE_DEG   = 70;\r\n        const RAD_TO_DEG           = 180 \/ Math.PI;\r\n\r\n        let criticalOrientations = 0;\r\n        let maxOverhangAngle = 0;\r\n        let worstCritPct = 0;\r\n        let worstSpanMm = 0;\r\n\r\n        for (let oi = 0; oi < orientList.length; oi++) {\r\n            const dx = orientList[oi][0], dy = orientList[oi][1], dz = orientList[oi][2];\r\n            let criticalArea = 0;\r\n\r\n            for (let i = 0; i < triangles.length; i++) {\r\n                const tri = triangles[i];\r\n                \/\/ Dot product of outward normal with build-down direction.\r\n                \/\/ Positive \u21d2 normal aligns with \"down\" \u21d2 face is an overhang.\r\n                const dot = tri.nx * dx + tri.ny * dy + tri.nz * dz;\r\n                if (dot <= 0) continue;\r\n\r\n                const clamped = dot > 1 ? 1 : dot;\r\n                \/\/ severity in degrees: 0 = vertical wall, 90 = horizontal floor\r\n                const severity = 90 - Math.acos(clamped) * RAD_TO_DEG;\r\n                if (severity > maxOverhangAngle) maxOverhangAngle = severity;\r\n\r\n                if (severity >= CRITICAL_ANGLE_DEG) {\r\n                    const span = Math.sqrt(tri.area);\r\n                    if (span >= SPAN_THRESHOLD_MM) criticalArea += tri.area;\r\n                }\r\n            }\r\n\r\n            const critPct = totalSurfaceMm2 > 0 ? criticalArea \/ totalSurfaceMm2 : 0;\r\n            if (critPct > AREA_PCT_THRESHOLD) {\r\n                criticalOrientations++;\r\n                if (critPct > worstCritPct) {\r\n                    worstCritPct = critPct;\r\n                    worstSpanMm  = Math.sqrt(criticalArea);\r\n                }\r\n            }\r\n        }\r\n\r\n        let overhangScore;\r\n        if (fullCheck) {\r\n            if (criticalOrientations === 0)      overhangScore = 30;\r\n            else if (criticalOrientations <= 2)  overhangScore = 22;\r\n            else if (criticalOrientations <= 4)  overhangScore = 12;\r\n            else                                 overhangScore = 0;\r\n        } else {\r\n            overhangScore = (criticalOrientations === 0) ? 30 : 12;\r\n        }\r\n\r\n        const isCritical = fullCheck\r\n            ? (criticalOrientations >= 5 || (worstCritPct > 0.10 && criticalOrientations >= 3))\r\n            : (criticalOrientations >= 1 && worstCritPct > 0.05);\r\n        const isWarning = !isCritical && criticalOrientations >= 1;\r\n\r\n        return {\r\n            score: overhangScore,\r\n            data: {\r\n                criticalOrientations,\r\n                maxOverhangAngle,\r\n                criticalAreaPercent: worstCritPct * 100,\r\n                spanEstimateMm: worstSpanMm,\r\n                skippedMultiOrientation: !fullCheck,\r\n                isCritical,\r\n                isWarning\r\n            }\r\n        };\r\n    }\r\n\r\n    \/**\r\n     * Grid ray-cast wall thickness. Shoots ~100 rays per axis (X\/Y\/Z),\r\n     * pairs ray-mesh entry\/exit hits as inside-segments. p5 (5th percentile)\r\n     * of segment lengths drives the score (catches thin tabs); median is the\r\n     * \"average\" thickness reported in the tip text.\r\n     * Falls back to 3V\/SA approximation when triangles unavailable or > cap.\r\n     * Returns { score: 0..20, data: { ... } }.\r\n     *\/\r\n    static analyzeThickness(triangles, dimensions, volumeCm3, surfaceAreaCm2, bbox) {\r\n        const fallbackMm = surfaceAreaCm2 > 0\r\n            ? (3 * volumeCm3 * 1000) \/ (surfaceAreaCm2 * 100)\r\n            : 0;\r\n\r\n        const useGrid = triangles && triangles.length > 0 && triangles.length <= TDP_MAX_ANALYSIS_TRIANGLES;\r\n        if (!useGrid) {\r\n            return {\r\n                score: this.thicknessToScore(fallbackMm),\r\n                data: {\r\n                    avgThicknessMm: fallbackMm,\r\n                    minThicknessMm: fallbackMm,\r\n                    p5ThicknessMm:  fallbackMm,\r\n                    medianThicknessMm: fallbackMm,\r\n                    isCritical: fallbackMm < 0.6,\r\n                    isWarning:  fallbackMm < 1.0 && fallbackMm >= 0.6,\r\n                    method: 'approximation',\r\n                    sampleCount: 0\r\n                }\r\n            };\r\n        }\r\n\r\n        const W = dimensions.width;\r\n        const H = dimensions.height;\r\n        const D = dimensions.depth;\r\n        const TARGET_RAYS_PER_DIR = 100;\r\n        const segments = [];\r\n\r\n        \/\/ Pre-compute per-triangle 2D bboxes for quick rejection in each\r\n        \/\/ ray-direction pass. Three projection planes: XY (Z-rays), XZ (Y-rays), YZ (X-rays).\r\n        const minXa = new Float32Array(triangles.length);\r\n        const maxXa = new Float32Array(triangles.length);\r\n        const minYa = new Float32Array(triangles.length);\r\n        const maxYa = new Float32Array(triangles.length);\r\n        const minZa = new Float32Array(triangles.length);\r\n        const maxZa = new Float32Array(triangles.length);\r\n        for (let i = 0; i < triangles.length; i++) {\r\n            const t = triangles[i];\r\n            const x1 = t.v1.x, x2 = t.v2.x, x3 = t.v3.x;\r\n            const y1 = t.v1.y, y2 = t.v2.y, y3 = t.v3.y;\r\n            const z1 = t.v1.z, z2 = t.v2.z, z3 = t.v3.z;\r\n            minXa[i] = x1 < x2 ? (x1 < x3 ? x1 : x3) : (x2 < x3 ? x2 : x3);\r\n            maxXa[i] = x1 > x2 ? (x1 > x3 ? x1 : x3) : (x2 > x3 ? x2 : x3);\r\n            minYa[i] = y1 < y2 ? (y1 < y3 ? y1 : y3) : (y2 < y3 ? y2 : y3);\r\n            maxYa[i] = y1 > y2 ? (y1 > y3 ? y1 : y3) : (y2 > y3 ? y2 : y3);\r\n            minZa[i] = z1 < z2 ? (z1 < z3 ? z1 : z3) : (z2 < z3 ? z2 : z3);\r\n            maxZa[i] = z1 > z2 ? (z1 > z3 ? z1 : z3) : (z2 > z3 ? z2 : z3);\r\n        }\r\n\r\n        \/\/ Z-axis rays at (x, y), parameterized by t = z\r\n        const sZ = Math.max(5, Math.sqrt((W * H) \/ TARGET_RAYS_PER_DIR));\r\n        const nZx = Math.max(2, Math.floor(W \/ sZ));\r\n        const nZy = Math.max(2, Math.floor(H \/ sZ));\r\n        for (let i = 0; i < nZx; i++) {\r\n            const x = bbox.minX + (i + 0.5) * (W \/ nZx);\r\n            for (let j = 0; j < nZy; j++) {\r\n                const y = bbox.minY + (j + 0.5) * (H \/ nZy);\r\n                const hits = [];\r\n                for (let k = 0; k < triangles.length; k++) {\r\n                    if (x < minXa[k] || x > maxXa[k]) continue;\r\n                    if (y < minYa[k] || y > maxYa[k]) continue;\r\n                    const t = triangles[k];\r\n                    const denom = (t.v2.y - t.v3.y) * (t.v1.x - t.v3.x) + (t.v3.x - t.v2.x) * (t.v1.y - t.v3.y);\r\n                    if (denom < 1e-10 && denom > -1e-10) continue;\r\n                    const a = ((t.v2.y - t.v3.y) * (x - t.v3.x) + (t.v3.x - t.v2.x) * (y - t.v3.y)) \/ denom;\r\n                    if (a < -1e-9) continue;\r\n                    const b = ((t.v3.y - t.v1.y) * (x - t.v3.x) + (t.v1.x - t.v3.x) * (y - t.v3.y)) \/ denom;\r\n                    if (b < -1e-9) continue;\r\n                    const c = 1 - a - b;\r\n                    if (c < -1e-9) continue;\r\n                    hits.push(a * t.v1.z + b * t.v2.z + c * t.v3.z);\r\n                }\r\n                if (hits.length < 2) continue;\r\n                hits.sort((p, q) => p - q);\r\n                for (let m = 0; m + 1 < hits.length; m += 2) {\r\n                    const len = hits[m + 1] - hits[m];\r\n                    if (len > 0.05) segments.push(len);\r\n                }\r\n            }\r\n        }\r\n\r\n        \/\/ Y-axis rays at (x, z)\r\n        const sY = Math.max(5, Math.sqrt((W * D) \/ TARGET_RAYS_PER_DIR));\r\n        const nYx = Math.max(2, Math.floor(W \/ sY));\r\n        const nYz = Math.max(2, Math.floor(D \/ sY));\r\n        for (let i = 0; i < nYx; i++) {\r\n            const x = bbox.minX + (i + 0.5) * (W \/ nYx);\r\n            for (let j = 0; j < nYz; j++) {\r\n                const z = bbox.minZ + (j + 0.5) * (D \/ nYz);\r\n                const hits = [];\r\n                for (let k = 0; k < triangles.length; k++) {\r\n                    if (x < minXa[k] || x > maxXa[k]) continue;\r\n                    if (z < minZa[k] || z > maxZa[k]) continue;\r\n                    const t = triangles[k];\r\n                    const denom = (t.v2.z - t.v3.z) * (t.v1.x - t.v3.x) + (t.v3.x - t.v2.x) * (t.v1.z - t.v3.z);\r\n                    if (denom < 1e-10 && denom > -1e-10) continue;\r\n                    const a = ((t.v2.z - t.v3.z) * (x - t.v3.x) + (t.v3.x - t.v2.x) * (z - t.v3.z)) \/ denom;\r\n                    if (a < -1e-9) continue;\r\n                    const b = ((t.v3.z - t.v1.z) * (x - t.v3.x) + (t.v1.x - t.v3.x) * (z - t.v3.z)) \/ denom;\r\n                    if (b < -1e-9) continue;\r\n                    const c = 1 - a - b;\r\n                    if (c < -1e-9) continue;\r\n                    hits.push(a * t.v1.y + b * t.v2.y + c * t.v3.y);\r\n                }\r\n                if (hits.length < 2) continue;\r\n                hits.sort((p, q) => p - q);\r\n                for (let m = 0; m + 1 < hits.length; m += 2) {\r\n                    const len = hits[m + 1] - hits[m];\r\n                    if (len > 0.05) segments.push(len);\r\n                }\r\n            }\r\n        }\r\n\r\n        \/\/ X-axis rays at (y, z)\r\n        const sX = Math.max(5, Math.sqrt((H * D) \/ TARGET_RAYS_PER_DIR));\r\n        const nXy = Math.max(2, Math.floor(H \/ sX));\r\n        const nXz = Math.max(2, Math.floor(D \/ sX));\r\n        for (let i = 0; i < nXy; i++) {\r\n            const y = bbox.minY + (i + 0.5) * (H \/ nXy);\r\n            for (let j = 0; j < nXz; j++) {\r\n                const z = bbox.minZ + (j + 0.5) * (D \/ nXz);\r\n                const hits = [];\r\n                for (let k = 0; k < triangles.length; k++) {\r\n                    if (y < minYa[k] || y > maxYa[k]) continue;\r\n                    if (z < minZa[k] || z > maxZa[k]) continue;\r\n                    const t = triangles[k];\r\n                    const denom = (t.v2.z - t.v3.z) * (t.v1.y - t.v3.y) + (t.v3.y - t.v2.y) * (t.v1.z - t.v3.z);\r\n                    if (denom < 1e-10 && denom > -1e-10) continue;\r\n                    const a = ((t.v2.z - t.v3.z) * (y - t.v3.y) + (t.v3.y - t.v2.y) * (z - t.v3.z)) \/ denom;\r\n                    if (a < -1e-9) continue;\r\n                    const b = ((t.v3.z - t.v1.z) * (y - t.v3.y) + (t.v1.y - t.v3.y) * (z - t.v3.z)) \/ denom;\r\n                    if (b < -1e-9) continue;\r\n                    const c = 1 - a - b;\r\n                    if (c < -1e-9) continue;\r\n                    hits.push(a * t.v1.x + b * t.v2.x + c * t.v3.x);\r\n                }\r\n                if (hits.length < 2) continue;\r\n                hits.sort((p, q) => p - q);\r\n                for (let m = 0; m + 1 < hits.length; m += 2) {\r\n                    const len = hits[m + 1] - hits[m];\r\n                    if (len > 0.05) segments.push(len);\r\n                }\r\n            }\r\n        }\r\n\r\n        if (segments.length < 5) {\r\n            return {\r\n                score: this.thicknessToScore(fallbackMm),\r\n                data: {\r\n                    avgThicknessMm:    fallbackMm,\r\n                    minThicknessMm:    fallbackMm,\r\n                    p5ThicknessMm:     fallbackMm,\r\n                    medianThicknessMm: fallbackMm,\r\n                    isCritical: fallbackMm < 0.6,\r\n                    isWarning:  fallbackMm < 1.0 && fallbackMm >= 0.6,\r\n                    method: 'approximation_after_grid',\r\n                    sampleCount: segments.length\r\n                }\r\n            };\r\n        }\r\n\r\n        segments.sort((a, b) => a - b);\r\n        const minThicknessMm    = segments[0];\r\n        const p5ThicknessMm     = segments[Math.floor(segments.length * 0.05)];\r\n        const medianThicknessMm = segments[Math.floor(segments.length * 0.5)];\r\n\r\n        return {\r\n            score: this.thicknessToScore(p5ThicknessMm),\r\n            data: {\r\n                avgThicknessMm:    medianThicknessMm,\r\n                minThicknessMm:    minThicknessMm,\r\n                p5ThicknessMm:     p5ThicknessMm,\r\n                medianThicknessMm: medianThicknessMm,\r\n                isCritical: p5ThicknessMm < 0.6,\r\n                isWarning:  p5ThicknessMm < 1.0 && p5ThicknessMm >= 0.6,\r\n                method: 'grid_sampling',\r\n                sampleCount: segments.length\r\n            }\r\n        };\r\n    }\r\n}\r\n\r\nclass AccurateSTLVolumeCalculator {\r\n    static calculateVolumeAndSurface(arrayBuffer) {\r\n        try {\r\n            const data = new DataView(arrayBuffer);\r\n            if (arrayBuffer.byteLength < 84) {\r\n                throw new Error('Fichier trop petit pour \u00eatre un STL valide');\r\n            }\r\n\r\n            let minX = Infinity, minY = Infinity, minZ = Infinity;\r\n            let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\r\n            let totalVolume = 0;\r\n            let totalSurfaceArea = 0;\r\n            let triangleCount = 0;\r\n            \r\n            const header = new TextDecoder().decode(new DataView(arrayBuffer, 0, 80));\r\n            const isAscii = this.isAsciiSTL(header, arrayBuffer);\r\n\r\n            if (isAscii) {\r\n                const result = this.processAsciiSTL(arrayBuffer);\r\n                return this.finalizeVolumeAndSurface(result);\r\n            } else {\r\n                const result = this.processBinarySTL(data, arrayBuffer);\r\n                return this.finalizeVolumeAndSurface(result);\r\n            }\r\n            \r\n        } catch (error) {\r\n            console.error('Erreur dans le calcul du volume et surface:', error);\r\n            return this.estimateVolumeAndSurfaceFromFileSize(arrayBuffer.byteLength);\r\n        }\r\n    }\r\n\r\n    static isAsciiSTL(header, arrayBuffer) {\r\n        const headerLower = header.toLowerCase();\r\n        const hasSolid = headerLower.includes('solid');\r\n        const hasFacet = headerLower.includes('facet');\r\n        \r\n        if (hasSolid && hasFacet) return true;\r\n        \r\n        if (arrayBuffer.byteLength >= 84) {\r\n            const triangleCount = new DataView(arrayBuffer).getUint32(80, true);\r\n            const expectedSize = 84 + (triangleCount * 50);\r\n            if (Math.abs(arrayBuffer.byteLength - expectedSize) <= 2) return false;\r\n        }\r\n        \r\n        return hasSolid;\r\n    }\r\n\r\n    static processAsciiSTL(arrayBuffer) {\r\n        const text = new TextDecoder().decode(arrayBuffer);\r\n        let minX = Infinity, minY = Infinity, minZ = Infinity;\r\n        let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\r\n        let totalVolume = 0;\r\n        let totalSurfaceArea = 0;\r\n        let absoluteVolume = 0;\r\n        let triangleCount = 0;\r\n\r\n        const vertexRegex = \/vertex\\s+([-\\d.eE]+)\\s+([-\\d.eE]+)\\s+([-\\d.eE]+)\/gi;\r\n        let match;\r\n        const foundVertices = [];\r\n\r\n        while ((match = vertexRegex.exec(text)) !== null) {\r\n            const x = parseFloat(match[1]);\r\n            const y = parseFloat(match[2]);\r\n            const z = parseFloat(match[3]);\r\n\r\n            if (isNaN(x) || isNaN(y) || isNaN(z)) continue;\r\n\r\n            foundVertices.push({x, y, z});\r\n            if (x < minX) minX = x; if (x > maxX) maxX = x;\r\n            if (y < minY) minY = y; if (y > maxY) maxY = y;\r\n            if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;\r\n        }\r\n\r\n        const expectedTriangles = Math.floor(foundVertices.length \/ 3);\r\n        const storeTriangles = expectedTriangles <= TDP_MAX_ANALYSIS_TRIANGLES;\r\n        const triangles = storeTriangles ? [] : null;\r\n\r\n        for (let i = 0; i + 2 < foundVertices.length; i += 3) {\r\n            const v1 = foundVertices[i];\r\n            const v2 = foundVertices[i + 1];\r\n            const v3 = foundVertices[i + 2];\r\n\r\n            const ex = v2.x - v1.x, ey = v2.y - v1.y, ez = v2.z - v1.z;\r\n            const fx = v3.x - v1.x, fy = v3.y - v1.y, fz = v3.z - v1.z;\r\n            const cx = ey * fz - ez * fy;\r\n            const cy = ez * fx - ex * fz;\r\n            const cz = ex * fy - ey * fx;\r\n            const cMag = Math.sqrt(cx * cx + cy * cy + cz * cz);\r\n            const area = cMag * 0.5;\r\n\r\n            const tetra = (v1.x * (v2.y * v3.z - v2.z * v3.y) +\r\n                           v1.y * (v2.z * v3.x - v2.x * v3.z) +\r\n                           v1.z * (v2.x * v3.y - v2.y * v3.x)) \/ 6.0;\r\n\r\n            totalVolume      += tetra;\r\n            absoluteVolume   += Math.abs(tetra);\r\n            totalSurfaceArea += area;\r\n            triangleCount++;\r\n\r\n            if (storeTriangles) {\r\n                triangles.push({\r\n                    v1, v2, v3,\r\n                    nx: cMag > 0 ? cx \/ cMag : 0,\r\n                    ny: cMag > 0 ? cy \/ cMag : 0,\r\n                    nz: cMag > 0 ? cz \/ cMag : 1,\r\n                    area: area\r\n                });\r\n            }\r\n        }\r\n\r\n        return {\r\n            minX, minY, minZ, maxX, maxY, maxZ,\r\n            totalVolume, totalSurfaceArea, absoluteVolume,\r\n            triangleCount,\r\n            triangles,\r\n            estimated: false,\r\n            accurate: triangleCount > 0\r\n        };\r\n    }\r\n\r\n    static processBinarySTL(data, arrayBuffer) {\r\n        let minX = Infinity, minY = Infinity, minZ = Infinity;\r\n        let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\r\n        let totalVolume = 0;\r\n        let totalSurfaceArea = 0;\r\n        let absoluteVolume = 0;\r\n\r\n        let triangleCount = data.getUint32(80, true);\r\n        const maxReasonableTriangles = (arrayBuffer.byteLength - 84) \/ 50;\r\n        if (triangleCount > maxReasonableTriangles) {\r\n            triangleCount = data.getUint32(80, false);\r\n            if (triangleCount > maxReasonableTriangles) throw new Error('Nombre de triangles invalide');\r\n        }\r\n\r\n        \/\/ Only retain triangles for deep analysis if the count is within the\r\n        \/\/ performance budget. For huge meshes we still parse the whole file\r\n        \/\/ to compute volume\/surface accurately, but we don't keep the raw\r\n        \/\/ triangles in memory.\r\n        const storeTriangles = triangleCount <= TDP_MAX_ANALYSIS_TRIANGLES;\r\n        const triangles = storeTriangles ? new Array(triangleCount) : null;\r\n\r\n        let offset = 84;\r\n        for (let i = 0; i < triangleCount; i++) {\r\n            if (offset + 48 > arrayBuffer.byteLength) { triangleCount = i; break; }\r\n            offset += 12; \/\/ skip the file's normal \u2014 we recompute it from vertices\r\n\r\n            const v1x = data.getFloat32(offset,      true);\r\n            const v1y = data.getFloat32(offset + 4,  true);\r\n            const v1z = data.getFloat32(offset + 8,  true);\r\n            const v2x = data.getFloat32(offset + 12, true);\r\n            const v2y = data.getFloat32(offset + 16, true);\r\n            const v2z = data.getFloat32(offset + 20, true);\r\n            const v3x = data.getFloat32(offset + 24, true);\r\n            const v3y = data.getFloat32(offset + 28, true);\r\n            const v3z = data.getFloat32(offset + 32, true);\r\n\r\n            if (isNaN(v1x) || isNaN(v1y) || isNaN(v1z) || isNaN(v2x) || isNaN(v2y) || isNaN(v2z) || isNaN(v3x) || isNaN(v3y) || isNaN(v3z)) {\r\n                throw new Error('Donn\u00e9es de vertex invalides');\r\n            }\r\n\r\n            \/\/ Bounding box\r\n            if (v1x < minX) minX = v1x; if (v1x > maxX) maxX = v1x;\r\n            if (v1y < minY) minY = v1y; if (v1y > maxY) maxY = v1y;\r\n            if (v1z < minZ) minZ = v1z; if (v1z > maxZ) maxZ = v1z;\r\n            if (v2x < minX) minX = v2x; if (v2x > maxX) maxX = v2x;\r\n            if (v2y < minY) minY = v2y; if (v2y > maxY) maxY = v2y;\r\n            if (v2z < minZ) minZ = v2z; if (v2z > maxZ) maxZ = v2z;\r\n            if (v3x < minX) minX = v3x; if (v3x > maxX) maxX = v3x;\r\n            if (v3y < minY) minY = v3y; if (v3y > maxY) maxY = v3y;\r\n            if (v3z < minZ) minZ = v3z; if (v3z > maxZ) maxZ = v3z;\r\n\r\n            \/\/ Edge vectors v1\u2192v2 and v1\u2192v3\r\n            const ex = v2x - v1x, ey = v2y - v1y, ez = v2z - v1z;\r\n            const fx = v3x - v1x, fy = v3y - v1y, fz = v3z - v1z;\r\n\r\n            \/\/ Cross product (e \u00d7 f) = 2 \u00d7 area-vector, also serves as un-normalized normal\r\n            const cx = ey * fz - ez * fy;\r\n            const cy = ez * fx - ex * fz;\r\n            const cz = ex * fy - ey * fx;\r\n            const cMag = Math.sqrt(cx * cx + cy * cy + cz * cz);\r\n            const area = cMag * 0.5;\r\n\r\n            \/\/ Signed tetrahedron volume from origin (same formula as before)\r\n            const tetra = (v1x * (v2y * v3z - v2z * v3y) +\r\n                           v1y * (v2z * v3x - v2x * v3z) +\r\n                           v1z * (v2x * v3y - v2y * v3x)) \/ 6.0;\r\n\r\n            totalVolume      += tetra;\r\n            absoluteVolume   += Math.abs(tetra);\r\n            totalSurfaceArea += area;\r\n\r\n            if (storeTriangles) {\r\n                triangles[i] = {\r\n                    v1: { x: v1x, y: v1y, z: v1z },\r\n                    v2: { x: v2x, y: v2y, z: v2z },\r\n                    v3: { x: v3x, y: v3y, z: v3z },\r\n                    nx: cMag > 0 ? cx \/ cMag : 0,\r\n                    ny: cMag > 0 ? cy \/ cMag : 0,\r\n                    nz: cMag > 0 ? cz \/ cMag : 1,\r\n                    area: area\r\n                };\r\n            }\r\n\r\n            offset += 36 + 2; \/\/ 9 floats + attribute byte count\r\n        }\r\n\r\n        \/\/ If we overran (offset issue truncated the loop), trim the array.\r\n        if (storeTriangles && triangles.length !== triangleCount) {\r\n            triangles.length = triangleCount;\r\n        }\r\n\r\n        return {\r\n            minX, minY, minZ, maxX, maxY, maxZ,\r\n            totalVolume, totalSurfaceArea, absoluteVolume,\r\n            triangleCount,\r\n            triangles,\r\n            estimated: false,\r\n            accurate: true\r\n        };\r\n    }\r\n\r\n    static calculateTetrahedronVolume(v1, v2, v3) {\r\n        const crossX = (v2.y * v3.z) - (v2.z * v3.y);\r\n        const crossY = (v2.z * v3.x) - (v2.x * v3.z);\r\n        const crossZ = (v2.x * v3.y) - (v2.y * v3.x);\r\n        const dot = (v1.x * crossX) + (v1.y * crossY) + (v1.z * crossZ);\r\n        return dot \/ 6.0;\r\n    }\r\n\r\n    static calculateTriangleArea(v1, v2, v3) {\r\n        const ab = { x: v2.x - v1.x, y: v2.y - v1.y, z: v2.z - v1.z };\r\n        const ac = { x: v3.x - v1.x, y: v3.y - v1.y, z: v3.z - v1.z };\r\n        \r\n        const crossX = ab.y * ac.z - ab.z * ac.y;\r\n        const crossY = ab.z * ac.x - ab.x * ac.z;\r\n        const crossZ = ab.x * ac.y - ab.y * ac.x;\r\n        \r\n        const magnitude = Math.sqrt(crossX * crossX + crossY * crossY + crossZ * crossZ);\r\n        return magnitude \/ 2.0;\r\n    }\r\n\r\n    static finalizeVolumeAndSurface(result) {\r\n        const {\r\n            minX, minY, minZ, maxX, maxY, maxZ,\r\n            totalVolume, totalSurfaceArea, absoluteVolume,\r\n            triangleCount, triangles,\r\n            estimated, accurate\r\n        } = result;\r\n\r\n        const dimensions = {\r\n            width:  Math.max(maxX - minX, 0.001),\r\n            height: Math.max(maxY - minY, 0.001),\r\n            depth:  Math.max(maxZ - minZ, 0.001)\r\n        };\r\n\r\n        const actualVolumeMm3 = Math.abs(totalVolume);            \/\/ mm\u00b3\r\n        const actualSurfaceMm2 = Math.abs(totalSurfaceArea);      \/\/ mm\u00b2\r\n        const actualVolume     = actualVolumeMm3 \/ 1000;          \/\/ cm\u00b3 (for display)\r\n        const actualSurfaceArea = actualSurfaceMm2 \/ 100;         \/\/ cm\u00b2 (for display)\r\n        const boundingBoxVolume = (dimensions.width * dimensions.height * dimensions.depth) \/ 1000;\r\n        let finalVolume = Math.max(actualVolume, 0.001);\r\n        let finalSurfaceArea = Math.max(actualSurfaceArea, 0.001);\r\n\r\n        if (actualVolume < 0.01 && boundingBoxVolume > 1.0) {\r\n            finalVolume = boundingBoxVolume * 0.3;\r\n            finalSurfaceArea = Math.pow(finalVolume * 1000, 2\/3) * 6 \/ 100;\r\n        }\r\n\r\n        const efficiency = actualVolume > 0 ? (actualVolume \/ boundingBoxVolume) * 100 : 0;\r\n\r\n        \/\/ ===== Factor 1 \u2014 Aspect Ratio (15 pts) =====\r\n        const sortedDims = [dimensions.width, dimensions.height, dimensions.depth].sort((a, b) => a - b);\r\n        const aspectRatio = sortedDims[2] \/ Math.max(sortedDims[0], 0.001);\r\n        let aspectScore;\r\n        if (aspectRatio <= 5)       aspectScore = 15;\r\n        else if (aspectRatio <= 10) aspectScore = 10;\r\n        else if (aspectRatio <= 20) aspectScore = 5;\r\n        else                        aspectScore = 0;\r\n\r\n        \/\/ ===== Factor 2 \u2014 Volume Efficiency (15 pts) =====\r\n        const isFlatObject = aspectRatio > 8 && efficiency > 50;\r\n        let efficiencyScore;\r\n        if (isFlatObject)            efficiencyScore = 12;\r\n        else if (efficiency >= 50)   efficiencyScore = 15;\r\n        else if (efficiency >= 30)   efficiencyScore = 11;\r\n        else if (efficiency >= 15)   efficiencyScore = 6;\r\n        else                         efficiencyScore = 2;\r\n\r\n        \/\/ ===== Factor 3 \u2014 Overhang Severity (30 pts) =====\r\n        const overhang = TDPGeometryAnalyzer.analyzeOverhangs(triangles, actualSurfaceMm2);\r\n        const overhangScore = overhang ? overhang.score : 22; \/\/ neutral if no triangles\r\n        const overhangData  = overhang ? overhang.data : {\r\n            criticalOrientations: 0,\r\n            maxOverhangAngle: 0,\r\n            criticalAreaPercent: 0,\r\n            spanEstimateMm: 0,\r\n            skippedMultiOrientation: triangles ? false : true,\r\n            isCritical: false,\r\n            isWarning: false\r\n        };\r\n\r\n        \/\/ ===== Factor 4 \u2014 Wall Thickness (20 pts) =====\r\n        const thickness = TDPGeometryAnalyzer.analyzeThickness(\r\n            triangles,\r\n            dimensions,\r\n            actualVolume,\r\n            actualSurfaceArea,\r\n            { minX, minY, minZ, maxX, maxY, maxZ }\r\n        );\r\n        const thicknessScore = thickness.score;\r\n        const thicknessData  = thickness.data;\r\n\r\n        \/\/ ===== Factor 5 \u2014 Mesh Integrity (20 pts) =====\r\n        const integrity = TDPGeometryAnalyzer.analyzeMeshIntegrity(\r\n            triangles,\r\n            totalVolume,\r\n            absoluteVolume !== undefined ? absoluteVolume : Math.abs(totalVolume)\r\n        );\r\n        const meshScore = integrity ? integrity.score : 14; \/\/ neutral if no triangles\r\n        const meshData  = integrity ? integrity.data : {\r\n            nonManifoldEdges: 0,\r\n            openEdges: 0,\r\n            volumeConsistency: 1,\r\n            isCritical: false,\r\n            isWarning: false\r\n        };\r\n\r\n        const totalScore = aspectScore + efficiencyScore + overhangScore + thicknessScore + meshScore;\r\n\r\n        \/\/ ===== Map score to level =====\r\n        \/\/ printabilityText reads from i18n at call time so it follows the active language.\r\n        let printability, printabilityText, printabilityReason;\r\n        if (totalScore >= 70) {\r\n            printability = 'excellent'; printabilityText = i18n.printabilityExcellent;\r\n            printabilityReason = 'Well-proportioned, clean geometry, solid walls';\r\n        } else if (totalScore >= 45) {\r\n            printability = 'good'; printabilityText = i18n.printabilityGood;\r\n            printabilityReason = 'Geometry suitable for printing';\r\n        } else if (totalScore >= 25) {\r\n            printability = 'fair'; printabilityText = i18n.printabilityFair;\r\n            printabilityReason = 'Some printability concerns';\r\n        } else {\r\n            printability = 'poor'; printabilityText = i18n.printabilityPoor;\r\n            printabilityReason = 'Multiple printability issues';\r\n        }\r\n\r\n        \/\/ Hard floors \u2014 preserve old strictness for clearly broken meshes.\r\n        if (!isFlatObject && efficiency < 10) {\r\n            printability = 'poor';\r\n            printabilityText = i18n.printabilityPoor;\r\n            printabilityReason = 'Very low volume efficiency \u2014 mesh likely broken';\r\n        }\r\n        if (meshData.isCritical && (printability === 'excellent' || printability === 'good')) {\r\n            printability = 'fair';\r\n            printabilityText = i18n.printabilityFair;\r\n        }\r\n        if (thicknessData.isCritical && printability === 'excellent') {\r\n            printability = 'good';\r\n            printabilityText = i18n.printabilityGood;\r\n        }\r\n\r\n        return {\r\n            volume: finalVolume,\r\n            surfaceArea: finalSurfaceArea,\r\n            dimensions,\r\n            triangleCount,\r\n            boundingBoxVolume,\r\n            printability,\r\n            printabilityText,\r\n            printabilityReason,\r\n            estimated,\r\n            accurate,\r\n            printabilityScore: totalScore,\r\n            printabilityFactors: {\r\n                aspectScore, efficiencyScore,\r\n                overhangScore, thicknessScore, meshScore,\r\n                aspectRatio, isFlatObject, efficiency\r\n            },\r\n            overhangData,\r\n            thicknessData,\r\n            meshData\r\n        };\r\n    }\r\n\r\n    static estimateVolumeAndSurfaceFromFileSize(fileSize) {\r\n        const trianglesEstimate = fileSize \/ 50;\r\n        const surfaceAreaEstimate = trianglesEstimate * 5;\r\n        const volumeEstimate = Math.cbrt(surfaceAreaEstimate * surfaceAreaEstimate);\r\n        const estimatedVolume = Math.max(volumeEstimate \/ 1000, 0.01);\r\n        const estimatedSurfaceArea = Math.max(surfaceAreaEstimate \/ 100, 0.01);\r\n        const side = Math.cbrt(estimatedVolume * 1000);\r\n        \r\n        return {\r\n            volume: estimatedVolume,\r\n            surfaceArea: estimatedSurfaceArea,\r\n            dimensions: { width: side, height: side, depth: side },\r\n            triangleCount: 0,\r\n            boundingBoxVolume: estimatedVolume,\r\n            printability: 'fair',\r\n            printabilityText: i18n.printabilityFairEstimated,\r\n            printabilityReason: 'Volume and surface estimated from file size',\r\n            estimated: true,\r\n            accurate: false,\r\n            printabilityScore: 40,\r\n            printabilityFactors: {\r\n                aspectScore:     15,\r\n                efficiencyScore: 0,\r\n                overhangScore:   15,\r\n                thicknessScore:  10,\r\n                meshScore:       0,\r\n                aspectRatio:     1,\r\n                isFlatObject:    false,\r\n                efficiency:      0\r\n            },\r\n            overhangData: {\r\n                criticalOrientations: 0,\r\n                maxOverhangAngle: 0,\r\n                criticalAreaPercent: 0,\r\n                spanEstimateMm: 0,\r\n                skippedMultiOrientation: true,\r\n                isCritical: false,\r\n                isWarning: false\r\n            },\r\n            thicknessData: {\r\n                avgThicknessMm: 0,\r\n                minThicknessMm: 0,\r\n                p5ThicknessMm: 0,\r\n                medianThicknessMm: 0,\r\n                isCritical: false,\r\n                isWarning: false,\r\n                method: 'estimated_from_filesize',\r\n                sampleCount: 0\r\n            },\r\n            meshData: {\r\n                nonManifoldEdges: 0,\r\n                openEdges: 0,\r\n                volumeConsistency: 0,\r\n                isCritical: false,\r\n                isWarning: true\r\n            }\r\n        };\r\n    }\r\n}\r\n\r\n\/\/ =====================================================================\r\n\/\/ CALCULATEUR DE VOLUME ET SURFACE OBJ\r\n\/\/ =====================================================================\r\n\r\nclass AccurateOBJVolumeCalculator {\r\n    static calculateVolumeAndSurface(arrayBuffer) {\r\n        try {\r\n            const text  = new TextDecoder().decode(arrayBuffer);\r\n            const lines = text.split('\\n');\r\n\r\n            const vertices = [];\r\n            const faces    = [];\r\n\r\n            for (const line of lines) {\r\n                const trimmed = line.trim();\r\n                if (!trimmed || trimmed.startsWith('#')) continue;\r\n\r\n                const parts = trimmed.split(\/\\s+\/);\r\n                if (parts[0] === 'v') {\r\n                    \/\/ Geometric vertex: v x y z [w]\r\n                    const x = parseFloat(parts[1]);\r\n                    const y = parseFloat(parts[2]);\r\n                    const z = parseFloat(parts[3]);\r\n                    if (!isNaN(x) && !isNaN(y) && !isNaN(z)) {\r\n                        vertices.push({ x, y, z });\r\n                    }\r\n                } else if (parts[0] === 'f') {\r\n                    \/\/ Face: indices are 1-based; tokens may be \"v\", \"v\/vt\", or \"v\/vt\/vn\"\r\n                    const faceVerts = parts.slice(1).map(p => parseInt(p.split('\/')[0], 10) - 1);\r\n                    \/\/ Fan-triangulate from first vertex (handles triangles, quads, n-gons)\r\n                    for (let i = 1; i < faceVerts.length - 1; i++) {\r\n                        faces.push([ faceVerts[0], faceVerts[i], faceVerts[i + 1] ]);\r\n                    }\r\n                }\r\n            }\r\n\r\n            if (vertices.length === 0 || faces.length === 0) {\r\n                throw new Error('No geometry found in OBJ file');\r\n            }\r\n\r\n            let minX = Infinity, minY = Infinity, minZ = Infinity;\r\n            let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\r\n            let totalVolume      = 0;\r\n            let totalSurfaceArea = 0;\r\n            let absoluteVolume   = 0;\r\n\r\n            const storeTriangles = faces.length <= TDP_MAX_ANALYSIS_TRIANGLES;\r\n            const triangles = storeTriangles ? [] : null;\r\n            let triangleCount = 0;\r\n\r\n            for (const [i0, i1, i2] of faces) {\r\n                const v1 = vertices[i0];\r\n                const v2 = vertices[i1];\r\n                const v3 = vertices[i2];\r\n                if (!v1 || !v2 || !v3) continue;\r\n\r\n                \/\/ Bounding box update\r\n                if (v1.x < minX) minX = v1.x; if (v1.x > maxX) maxX = v1.x;\r\n                if (v1.y < minY) minY = v1.y; if (v1.y > maxY) maxY = v1.y;\r\n                if (v1.z < minZ) minZ = v1.z; if (v1.z > maxZ) maxZ = v1.z;\r\n                if (v2.x < minX) minX = v2.x; if (v2.x > maxX) maxX = v2.x;\r\n                if (v2.y < minY) minY = v2.y; if (v2.y > maxY) maxY = v2.y;\r\n                if (v2.z < minZ) minZ = v2.z; if (v2.z > maxZ) maxZ = v2.z;\r\n                if (v3.x < minX) minX = v3.x; if (v3.x > maxX) maxX = v3.x;\r\n                if (v3.y < minY) minY = v3.y; if (v3.y > maxY) maxY = v3.y;\r\n                if (v3.z < minZ) minZ = v3.z; if (v3.z > maxZ) maxZ = v3.z;\r\n\r\n                const ex = v2.x - v1.x, ey = v2.y - v1.y, ez = v2.z - v1.z;\r\n                const fx = v3.x - v1.x, fy = v3.y - v1.y, fz = v3.z - v1.z;\r\n                const cx = ey * fz - ez * fy;\r\n                const cy = ez * fx - ex * fz;\r\n                const cz = ex * fy - ey * fx;\r\n                const cMag = Math.sqrt(cx * cx + cy * cy + cz * cz);\r\n                const area = cMag * 0.5;\r\n\r\n                const tetra = (v1.x * (v2.y * v3.z - v2.z * v3.y) +\r\n                               v1.y * (v2.z * v3.x - v2.x * v3.z) +\r\n                               v1.z * (v2.x * v3.y - v2.y * v3.x)) \/ 6.0;\r\n\r\n                totalVolume      += tetra;\r\n                absoluteVolume   += Math.abs(tetra);\r\n                totalSurfaceArea += area;\r\n                triangleCount++;\r\n\r\n                if (storeTriangles) {\r\n                    triangles.push({\r\n                        v1, v2, v3,\r\n                        nx: cMag > 0 ? cx \/ cMag : 0,\r\n                        ny: cMag > 0 ? cy \/ cMag : 0,\r\n                        nz: cMag > 0 ? cz \/ cMag : 1,\r\n                        area: area\r\n                    });\r\n                }\r\n            }\r\n\r\n            return AccurateSTLVolumeCalculator.finalizeVolumeAndSurface({\r\n                minX, minY, minZ, maxX, maxY, maxZ,\r\n                totalVolume, totalSurfaceArea, absoluteVolume,\r\n                triangleCount,\r\n                triangles,\r\n                estimated: false,\r\n                accurate:  triangleCount > 0,\r\n            });\r\n\r\n        } catch (error) {\r\n            console.error('OBJ volume calculation error:', error);\r\n            return AccurateSTLVolumeCalculator.estimateVolumeAndSurfaceFromFileSize(arrayBuffer.byteLength);\r\n        }\r\n    }\r\n}\r\n\r\n\/\/ =====================================================================\r\n\/\/ VISIONNEUSE 3D COMPL\u00c8TE\r\n\/\/ =====================================================================\r\n\r\nclass Real3DViewer {\r\n    static viewers = new Map();\r\n    \r\n    static initViewer(containerId, fileData, fileName, dimensions, initialColor = 0xdc3545) {\r\n        const container = document.getElementById(containerId);\r\n        container.innerHTML = `<div class=\"viewer-placeholder-pro\"><div style=\"font-size: 32px; margin-bottom: 10px;\">\ud83d\udd04<\/div><div>${i18n.loadingViewer}<\/div><\/div>`;\r\n\r\n        const isOBJ = fileName.toLowerCase().endsWith('.obj');\r\n        const checkThreeJS = setInterval(() => {\r\n            \/\/ For OBJ files also wait for OBJLoader; for STL wait for STLLoader\r\n            const baseReady = typeof THREE !== 'undefined' && typeof THREE.OrbitControls !== 'undefined';\r\n            const loaderReady = isOBJ\r\n                ? baseReady && typeof THREE.OBJLoader !== 'undefined'\r\n                : baseReady && typeof THREE.STLLoader !== 'undefined';\r\n            if (loaderReady) {\r\n                clearInterval(checkThreeJS);\r\n                this.create3DViewer(containerId, fileData, fileName, dimensions, initialColor);\r\n            }\r\n        }, 100);\r\n    }\r\n    \r\n    static create3DViewer(containerId, fileData, fileName, dimensions, initialColor) {\r\n        const container = document.getElementById(containerId);\r\n        container.innerHTML = '';\r\n\r\n        try {\r\n            const scene    = new THREE.Scene();\r\n            scene.background = new THREE.Color(0xf8f9fa);\r\n            const camera   = new THREE.PerspectiveCamera(75, container.clientWidth \/ container.clientHeight, 0.1, 1000);\r\n            const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });\r\n            renderer.setSize(container.clientWidth, container.clientHeight);\r\n            renderer.setClearColor(0x000000, 0);\r\n            container.appendChild(renderer.domElement);\r\n\r\n            const ambientLight = new THREE.AmbientLight(0x404040, 0.6);\r\n            scene.add(ambientLight);\r\n            const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.8);\r\n            directionalLight1.position.set(1, 1, 1).normalize();\r\n            scene.add(directionalLight1);\r\n            const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5);\r\n            directionalLight2.position.set(-1, -1, -1).normalize();\r\n            scene.add(directionalLight2);\r\n\r\n            const controls = new THREE.OrbitControls(camera, renderer.domElement);\r\n            controls.enableDamping = true;\r\n            controls.dampingFactor = 0.05;\r\n\r\n            const isOBJ    = fileName.toLowerCase().endsWith('.obj');\r\n            const material = new THREE.MeshPhongMaterial({\r\n                color: initialColor, specular: 0x111111, shininess: 100, transparent: true, opacity: 0.9\r\n            });\r\n            const blob = new Blob([fileData], { type: 'application\/octet-stream' });\r\n            const url  = URL.createObjectURL(blob);\r\n\r\n            \/\/ Unified post-load handler: works for both STL (BufferGeometry) and OBJ (THREE.Group)\r\n            const onModelLoad = (object) => {\r\n                let rootObject;\r\n\r\n                if (isOBJ) {\r\n                    \/\/ OBJLoader returns a THREE.Group \u2014 apply shared material to every mesh child\r\n                    object.traverse(child => {\r\n                        if (child.isMesh) { child.material = material; }\r\n                    });\r\n                    scene.add(object);\r\n                    rootObject = object;\r\n                } else {\r\n                    \/\/ STLLoader returns a BufferGeometry\r\n                    object.computeVertexNormals();\r\n                    const mesh = new THREE.Mesh(object, material);\r\n                    scene.add(mesh);\r\n                    rootObject = mesh;\r\n                }\r\n\r\n                \/\/ Centre and fit camera to bounding box\r\n                const box    = new THREE.Box3().setFromObject(rootObject);\r\n                const center = new THREE.Vector3();\r\n                box.getCenter(center);\r\n                rootObject.position.sub(center);\r\n\r\n                const size   = new THREE.Vector3();\r\n                box.getSize(size);\r\n                const maxDim = Math.max(size.x, size.y, size.z);\r\n                camera.position.z = maxDim * 2;\r\n                camera.position.y = maxDim * 0.5;\r\n                controls.update();\r\n\r\n                const infoDiv     = document.createElement('div');\r\n                infoDiv.className = 'viewer-info';\r\n                infoDiv.innerHTML = i18n.viewerDragHint;\r\n                container.appendChild(infoDiv);\r\n\r\n                \/\/ Store material + renderer reference so updateColor() and disposal work\r\n                this.viewers.set(containerId, { scene, mesh: rootObject, material, renderer });\r\n\r\n                function animate() {\r\n                    requestAnimationFrame(animate);\r\n                    controls.update();\r\n                    renderer.render(scene, camera);\r\n                }\r\n                animate();\r\n\r\n                window.addEventListener('resize', () => {\r\n                    camera.aspect = container.clientWidth \/ container.clientHeight;\r\n                    camera.updateProjectionMatrix();\r\n                    renderer.setSize(container.clientWidth, container.clientHeight);\r\n                });\r\n            };\r\n\r\n            const onError = (error) => {\r\n                console.error('Error loading 3D model:', error);\r\n                container.innerHTML = `<div class=\"viewer-placeholder-pro\"><div style=\"font-size: 48px; margin-bottom: 15px;\">\u274c<\/div><div style=\"font-size: 16px; color: #dc3545;\">${i18n.viewerLoadError}<\/div><\/div>`;\r\n            };\r\n\r\n            if (isOBJ) {\r\n                const loader = new THREE.OBJLoader();\r\n                loader.load(url, onModelLoad, undefined, onError);\r\n            } else {\r\n                const loader = new THREE.STLLoader();\r\n                loader.load(url, onModelLoad, undefined, onError);\r\n            }\r\n\r\n        } catch (error) {\r\n            console.error('3D viewer initialisation error:', error);\r\n            container.innerHTML = `<div class=\"viewer-placeholder-pro\"><div style=\"font-size: 48px; margin-bottom: 15px;\">\u274c<\/div><div style=\"font-size: 16px; color: #dc3545;\">${i18n.viewerError}<\/div><\/div>`;\r\n        }\r\n    }\r\n    \r\n    static updateColor(containerId, colorName) {\r\n        const viewer = this.viewers.get(containerId);\r\n        if (viewer && viewer.material) {\r\n            const colorHex = materialColors[colorName] || 0xdc3545;\r\n            viewer.material.color.setHex(colorHex);\r\n            viewer.material.needsUpdate = true;\r\n        }\r\n    }\r\n\r\n    static disposeAllViewers() {\r\n        this.viewers.forEach((viewer, containerId) => {\r\n            try {\r\n                if (viewer.renderer) {\r\n                    viewer.renderer.forceContextLoss();\r\n                    viewer.renderer.dispose();\r\n                }\r\n            } catch(e) { \/* ignore disposal errors *\/ }\r\n        });\r\n        this.viewers.clear();\r\n    }\r\n}\r\n\r\n\/\/ =====================================================================\r\n\/\/ CALCULATEUR DE PRIX (avec r\u00e9ductions par pi\u00e8ce)\r\n\/\/ =====================================================================\r\n\r\nconst pricingPro = {\r\n    calculateDetailedPrice: function(volume, surfaceArea, technology, material, quality, quantity, color) {\r\n        let breakdown = { priceHT: 0, priceTTC: 0, costPerUnit: 0, totalCost: 0 };\r\n\r\n        try {\r\n            const qualitySlug = quality.toLowerCase().replace(' ', '-');\r\n            const qci = parseFloat(qualityConfigPro[qualitySlug + '_qci'] || this.getDefaultQci(quality));\r\n            const qct = parseFloat(qualityConfigPro[qualitySlug + '_qct'] || this.getDefaultQct(quality));\r\n            \r\n            const materialSlug = material.toLowerCase().replace('+', '-plus').replace(' ', '-');\r\n            const materialPrice = parseFloat(materialConfigPro[materialSlug + '_price'] || this.getDefaultMaterialPrice(material));\r\n            const materialDensity = parseFloat(materialConfigPro[materialSlug + '_density'] || this.getDefaultMaterialDensity(material));\r\n            const materialCoc = parseFloat(materialConfigPro[materialSlug + '_coc'] || this.getDefaultMaterialCoc(material));\r\n            \r\n            const wasteFactor = parseFloat(generalConfigPro.waste_factor || 1.05);\r\n            const fixedCost = parseFloat(generalConfigPro.fixed_cost || 5.0);\r\n            const machineSpeed = parseFloat(machineConfigPro.machine_speed || 0.4);\r\n            const machineHourlyCost = parseFloat(machineConfigPro.machine_hourly_cost || 1.0);\r\n            \r\n            const colorCoef = (color === 'White' || color === 'Black') ? \r\n                parseFloat(materialConfigPro.color_standard_coef || 1.0) : \r\n                parseFloat(materialConfigPro.color_premium_coef || 1.1);\r\n            \r\n            \/\/ Calcul pour 1 pi\u00e8ce\r\n            const materialCostPerUnit = this.calculateMaterialCost(volume, surfaceArea, qci, qct, materialDensity, wasteFactor, materialPrice, colorCoef);\r\n            const machineCostPerUnit = this.calculateBaseMachineCost(volume, surfaceArea, qci, qct, machineSpeed, machineHourlyCost);\r\n            const manufacturingCostPerUnit = this.calculateManufacturingCost(materialCostPerUnit, machineCostPerUnit, materialCoc, fixedCost, quantity);\r\n            const basePriceHT = this.calculatePriceHT(manufacturingCostPerUnit, quantity);\r\n            \r\n            \/\/ Application des r\u00e9ductions par quantit\u00e9 (pi\u00e8ce par pi\u00e8ce)\r\n            let totalPriceHT = 0;\r\n            for (let i = 1; i <= quantity; i++) {\r\n                let reductionRate = this.getReductionRateForPiece(i);\r\n                totalPriceHT += basePriceHT * (1 - reductionRate);\r\n            }\r\n            \r\n            breakdown.costPerUnit = totalPriceHT \/ quantity;\r\n            breakdown.totalCost = totalPriceHT;\r\n            breakdown.priceHT = totalPriceHT;\r\n            \r\n            \/\/ Application multiplicateur SLA\r\n            if (technology === 'SLA') {\r\n                const plaPrice = parseFloat(materialConfigPro['pla_price'] || 80.00);\r\n                const plaDensity = parseFloat(materialConfigPro['pla_density'] || 1.24);\r\n                const plaCoc = parseFloat(materialConfigPro['pla_coc'] || 1.0);\r\n                \r\n                const plaMaterialCost = this.calculateMaterialCost(volume, surfaceArea, qci, qct, plaDensity, wasteFactor, plaPrice, colorCoef);\r\n                const plaMachineCost = this.calculateBaseMachineCost(volume, surfaceArea, qci, qct, machineSpeed, machineHourlyCost);\r\n                const plaManufacturingCost = this.calculateManufacturingCost(plaMaterialCost, plaMachineCost, plaCoc, fixedCost, quantity);\r\n                const plaBasePriceHT = this.calculatePriceHT(plaManufacturingCost, quantity);\r\n                \r\n                let slaTotalPriceHT = 0;\r\n                for (let i = 1; i <= quantity; i++) {\r\n                    let reductionRate = this.getReductionRateForPiece(i);\r\n                    slaTotalPriceHT += plaBasePriceHT * (1 - reductionRate);\r\n                }\r\n                \r\n                let slaMultiplier = (material === 'High Resolution Resin') \r\n                    ? parseFloat(machineConfigPro.sla_highres_multiplier || 2.5)\r\n                    : parseFloat(machineConfigPro.sla_standard_multiplier || 2.2);\r\n                \r\n                breakdown.priceHT = slaTotalPriceHT * slaMultiplier;\r\n            }\r\n            \r\n            const tvaRate = parseFloat(generalConfigPro.tva_rate || 0.19);\r\n            breakdown.priceTTC = breakdown.priceHT * (1 + tvaRate);\r\n            \r\n        } catch (error) {\r\n            console.error('Erreur calcul:', error);\r\n        }\r\n        \r\n        return breakdown;\r\n    },\r\n    \r\n    getReductionRateForPiece: function(pieceNumber) {\r\n        if (pieceNumber === 1) return 0;\r\n        if (pieceNumber === 2) return 0.15;\r\n        if (pieceNumber === 3) return 0.16;\r\n        if (pieceNumber >= 4 && pieceNumber <= 5) return 0.17;\r\n        if (pieceNumber >= 6 && pieceNumber <= 9) return 0.18;\r\n        if (pieceNumber >= 10 && pieceNumber <= 29) return 0.20;\r\n        if (pieceNumber >= 30 && pieceNumber <= 99) return 0.30;\r\n        if (pieceNumber >= 100) return 0.35;\r\n        return 0;\r\n    },\r\n    \r\n    calculateMaterialCost: function(volume, surfaceArea, qci, qct, density, wasteFactor, materialPrice, colorCoef) {\r\n        const volumePart = volume * qci;\r\n        const surfacePart = surfaceArea * (qct \/ 10);\r\n        const totalVolume = volumePart + surfacePart;\r\n        const weight = totalVolume * density * wasteFactor;\r\n        const cost = (weight \/ 1000) * materialPrice * colorCoef;\r\n        return Math.max(cost, 0.001);\r\n    },\r\n    \r\n    calculateBaseMachineCost: function(volume, surfaceArea, qci, qct, machineSpeed, machineHourlyCost) {\r\n        const volumePart = volume * qci;\r\n        const surfacePart = surfaceArea * (qct \/ 10);\r\n        const totalWork = volumePart + surfacePart;\r\n        const cost = (machineHourlyCost \/ (60 * machineSpeed)) * totalWork;\r\n        return Math.max(cost, 0.001);\r\n    },\r\n    \r\n    calculateManufacturingCost: function(materialCost, machineCost, materialCoc, fixedCost, quantity) {\r\n        const variableCost = materialCost + (machineCost * materialCoc);\r\n        const fixedCostPerUnit = fixedCost \/ quantity;\r\n        return variableCost + fixedCostPerUnit;\r\n    },\r\n    \r\n    calculatePriceHT: function(manufacturingCost, quantity) {\r\n        let markup;\r\n        if (quantity === 1) markup = parseFloat(quantityConfigPro['1_markup'] || 1.30);\r\n        else if (quantity >= 2 && quantity <= 5) markup = parseFloat(quantityConfigPro['2_5_markup'] || 1.30);\r\n        else if (quantity >= 6 && quantity <= 10) markup = parseFloat(quantityConfigPro['6_10_markup'] || 1.30);\r\n        else markup = parseFloat(quantityConfigPro['11_plus_markup'] || 1.25);\r\n        return manufacturingCost * markup;\r\n    },\r\n    \r\n    \/\/ Valeurs par d\u00e9faut\r\n    getDefaultQci: function(quality) {\r\n        const defaults = { 'Basse': 0.1, 'Standard': 0.3, 'Haute': 1.0 };\r\n        return defaults[quality] || 0.3;\r\n    },\r\n    getDefaultQct: function(quality) {\r\n        const defaults = { 'Basse': 0.8, 'Standard': 1.0, 'Haute': 2.0 };\r\n        return defaults[quality] || 1.0;\r\n    },\r\n    getDefaultMaterialPrice: function(material) {\r\n        const defaults = { \r\n            'PLA': 80.00, 'PLA+': 95.00, 'PETG': 130.00, \r\n            'ABS': 155.00, 'TPU': 165.00,\r\n            'Standard Resin': 100.00, 'High Resolution Resin': 150.00 \r\n        };\r\n        return defaults[material] || 80.00;\r\n    },\r\n    getDefaultMaterialDensity: function(material) {\r\n        const defaults = { \r\n            'PLA': 1.24, 'PLA+': 1.24, 'PETG': 1.27, \r\n            'ABS': 1.04, 'TPU': 1.20,\r\n            'Standard Resin': 1.10, 'High Resolution Resin': 1.10 \r\n        };\r\n        return defaults[material] || 1.24;\r\n    },\r\n    getDefaultMaterialCoc: function(material) {\r\n        const defaults = { \r\n            'PLA': 1.0, 'PLA+': 1.2, 'PETG': 1.5, \r\n            'ABS': 2.2, 'TPU': 3.0,\r\n            'Standard Resin': 1.0, 'High Resolution Resin': 1.0 \r\n        };\r\n        return defaults[material] || 1.0;\r\n    }\r\n};\r\n\r\n\/\/ =====================================================================\r\n\/\/ APPLICATION PRINCIPALE\r\n\/\/ =====================================================================\r\n\r\ndocument.addEventListener(\"DOMContentLoaded\", function() {\r\n    const MAX_FILES      = 10;\r\n    const MAX_VIEWERS    = 6;  \/\/ WebGL context limit \u2014 browsers cap at ~8-16; stay safe at 6\r\n    const MAX_FILE_SIZE  = 100 * 1024 * 1024; \/\/ 100 MB per file\r\n    const MAX_TOTAL_SIZE = 200 * 1024 * 1024; \/\/ 200 MB across all files\r\n    \r\n    const elements = {\r\n        fileInput: document.getElementById('stlFilesPro'),\r\n        alertContainer: document.getElementById('alertContainerPro'),\r\n        rateLimitBanner: document.getElementById('rateLimitBannerPro'),\r\n        fileCounter: document.getElementById('fileCounterPro'),\r\n        filesList: document.getElementById('stlFilesListPro'),\r\n        totalPrice: document.getElementById('totalPricePro'),\r\n        submitBtn: document.getElementById('submitStlOrderPro'),\r\n        cartBtn: document.getElementById('addToCartPro'),\r\n        uploadArea: document.getElementById('uploadAreaPro'),\r\n        quotationModal: document.getElementById('quotationModalPro'),\r\n        quotationContent: document.getElementById('quotationContentPro')\r\n    };\r\n    \r\n    let state = {\r\n        files: [],\r\n        processing: 0,\r\n        fileData: new Map(),\r\n        hasLargeDimensions: false,\r\n        quotationNumber: null   \/\/ set server-side before quote is shown\r\n    };\r\n\r\n    \/\/ Guest info state\r\n    let guestInfo = {\r\n        identified: false,\r\n        name: '',\r\n        email: '',\r\n        phone: '',\r\n        gdpr_consent: 0,\r\n        wp_user_id: null,\r\n        guest_identifier: '',\r\n        billing: {\r\n            company:   '',\r\n            address_1: '',\r\n            address_2: '',\r\n            city:      '',\r\n            postcode:  '',\r\n            country:   ''\r\n        }\r\n    };\r\n\r\n    \r\n    const technologyMaterials = {\r\n        'FDM': ['PLA', 'PLA+', 'PETG', 'ABS', 'TPU'],\r\n        \/\/ SLA names: value stays English for pricing lookup; display uses i18n\r\n        'SLA': [\r\n            { value: 'Standard Resin',        label: () => i18n.matStandardResin },\r\n            { value: 'High Resolution Resin', label: () => i18n.matHighResResin  },\r\n        ]\r\n    };\r\n\r\n    \/\/ Color value stays English (used for order\/pricing lookups); name is translated\r\n    const colorOptions = [\r\n        { name: () => i18n.colorWhite,  value: 'White'  },\r\n        { name: () => i18n.colorBlack,  value: 'Black'  },\r\n        { name: () => i18n.colorRed,    value: 'Red'    },\r\n        { name: () => i18n.colorGreen,  value: 'Green'  },\r\n        { name: () => i18n.colorBlue,   value: 'Blue'   },\r\n        { name: () => i18n.colorGray,   value: 'Gray'   },\r\n        { name: () => i18n.colorYellow, value: 'Yellow' },\r\n    ];\r\n    \r\n    function init() {\r\n        elements.fileInput.addEventListener('change', handleFileSelect);\r\n        elements.submitBtn.addEventListener('click', handleSubmit);\r\n        elements.cartBtn.addEventListener('click', handleAddToCart);\r\n        elements.uploadArea.addEventListener('dragover', handleDragOver);\r\n        elements.uploadArea.addEventListener('dragleave', handleDragLeave);\r\n        elements.uploadArea.addEventListener('drop', handleFileDrop);\r\n        \r\n        window.pro_ajax_nonce = '5f1dada9bd';\r\n        window.t3d_tracking_nonce = '0ac54c44db';\r\n\r\n        updateFileCounter();\r\n    }\r\n    \r\n    function handleDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover'); }\r\n    function handleDragLeave(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); }\r\n    \r\n    function handleFileDrop(e) {\r\n        e.preventDefault();\r\n        e.currentTarget.classList.remove('dragover');\r\n        const files = Array.from(e.dataTransfer.files).filter(f => \/\\.(stl|obj)$\/i.test(f.name));\r\n        processFiles(files);\r\n    }\r\n    \r\n    function handleFileSelect(event) {\r\n        const files = Array.from(event.target.files);\r\n        processFiles(files);\r\n    }\r\n    \r\n    function processFiles(newFiles) {\r\n        const validFiles = [];\r\n        const errors = [];\r\n        \/\/ Running total of bytes already queued (existing + newly accepted)\r\n        let accumulatedSize = state.files.reduce((sum, f) => sum + f.size, 0);\r\n\r\n        newFiles.forEach(file => {\r\n            if (file.size > MAX_FILE_SIZE) {\r\n                errors.push(`${file.name} exceeds 100MB limit`);\r\n            } else if (state.files.length + validFiles.length >= MAX_FILES) {\r\n                errors.push(`Maximum ${MAX_FILES} files allowed`);\r\n            } else if (state.files.some(f => f.name === file.name)) {\r\n                errors.push(`${file.name} is already added`);\r\n            } else if (accumulatedSize + file.size > MAX_TOTAL_SIZE) {\r\n                errors.push(`Adding ${file.name} would exceed the 200MB total upload limit`);\r\n            } else {\r\n                validFiles.push(file);\r\n                accumulatedSize += file.size;\r\n            }\r\n        });\r\n        \r\n        if (errors.length > 0) showAlert(errors.join('<br>'), 'error');\r\n        if (validFiles.length === 0) return;\r\n\r\n        \/\/ Rate limit check for unidentified guests\r\n        if (!guestInfo.identified) {\r\n            jQuery.ajax({\r\n                type: 'POST',\r\n                url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\r\n                data: {\r\n                    action: 't3d_check_rate_limit',\r\n                    nonce: window.t3d_tracking_nonce,\r\n                    email: guestInfo.email\r\n                },\r\n                success: function(response) {\r\n                    if (response.success) {\r\n                        if (response.data.allowed) {\r\n                            \/\/ Log upload attempt\r\n                            if (response.data.guest_identifier) {\r\n                                guestInfo.guest_identifier = response.data.guest_identifier;\r\n                            }\r\n                            doProcessFiles(validFiles);\r\n                            logUploadAttempt();\r\n                        } else {\r\n                            \/\/ Rate limit reached \u2014 redirect to login\r\n                            window.location.href = 'https:\/\/tunisia3dprint.com\/fr\/login\/?redirect_to=' + encodeURIComponent(window.location.href);\r\n                        }\r\n                    } else {\r\n                        doProcessFiles(validFiles);\r\n                    }\r\n                },\r\n                error: function() {\r\n                    doProcessFiles(validFiles);\r\n                }\r\n            });\r\n        } else {\r\n            doProcessFiles(validFiles);\r\n        }\r\n    }\r\n\r\n    function logUploadAttempt() {\r\n        jQuery.ajax({\r\n            type: 'POST',\r\n            url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\r\n            data: {\r\n                action: 't3d_capture_guest',\r\n                nonce: window.t3d_tracking_nonce,\r\n                name: guestInfo.name,\r\n                email: guestInfo.email,\r\n                phone: guestInfo.phone,\r\n                gdpr_consent: guestInfo.gdpr_consent\r\n            }\r\n        });\r\n    }\r\n\r\n    function doProcessFiles(validFiles) {\r\n        if (elements.rateLimitBanner) {\r\n            elements.rateLimitBanner.style.display = 'none';\r\n        }\r\n        state.files.push(...validFiles);\r\n        updateFileCounter();\r\n        renderFiles();\r\n        processAllFiles();\r\n    }\r\n    \r\n    function renderFiles() {\r\n        elements.filesList.innerHTML = '';\r\n        \r\n        state.files.forEach((file, index) => {\r\n            const fileId = `file_${index}`;\r\n            const viewerId = `viewer_${fileId}`;\r\n            \r\n            const card = document.createElement('div');\r\n            card.className = 'stl-file-card-pro';\r\n            card.innerHTML = `\r\n                <button class=\"remove-file-pro\" onclick=\"removeFilePro(${index})\" title=\"Remove file\">\u00d7<\/button>\r\n                \r\n                <div class=\"file-header-pro\">\r\n                    <div class=\"file-name-pro\">${file.name}<\/div>\r\n                    <div class=\"file-status-pro status-processing-pro\" id=\"status_${fileId}\">\u23f3 ${i18n.processing}<\/div>\r\n                <\/div>\r\n                \r\n                <div class=\"preview-section-pro\">\r\n                    <div class=\"viewer-container-pro\" id=\"${viewerId}\">\r\n                        ${index < MAX_VIEWERS\r\n                            ? `<div class=\"viewer-placeholder-pro\"><div style=\"font-size:32px;margin-bottom:10px;\">\ud83d\udce4<\/div><div>${i18n.uploadPlaceholder}<\/div><\/div>`\r\n                            : `<div class=\"viewer-placeholder-pro\" style=\"font-size:13px;color:#666;padding:20px 10px;line-height:1.6;\"><div style=\"font-size:28px;margin-bottom:8px;\">\ud83d\udcd0<\/div><div>Aper\u00e7u 3D d\u00e9sactiv\u00e9<br><span style=\"font-size:11px;color:#999;\">Max ${MAX_VIEWERS} visionneuses simultan\u00e9es<\/span><\/div><\/div>`\r\n                        }<\/div>\r\n                    \r\n                    <div class=\"info-panel-pro\">\r\n                        <div class=\"dimension-box-pro\">\r\n                            <strong>${i18n.dimensionsMm}<\/strong><br>\r\n                            <div class=\"info-row-pro\">\r\n                                <span class=\"info-label-pro\">${i18n.dimensions.width}:<\/span>\r\n                                <span class=\"info-value-pro\" id=\"width_${fileId}\">--<\/span>\r\n                            <\/div>\r\n                            <div class=\"info-row-pro\">\r\n                                <span class=\"info-label-pro\">${i18n.dimensions.height}:<\/span>\r\n                                <span class=\"info-value-pro\" id=\"height_${fileId}\">--<\/span>\r\n                            <\/div>\r\n                            <div class=\"info-row-pro\">\r\n                                <span class=\"info-label-pro\">${i18n.dimensions.depth}:<\/span>\r\n                                <span class=\"info-value-pro\" id=\"depth_${fileId}\">--<\/span>\r\n                            <\/div>\r\n                        <\/div>\r\n                        \r\n                        <div class=\"volume-box-pro\">\r\n                            <div style=\"font-size: 12px; color: #666;\">${i18n.volume}<\/div>\r\n                            <div class=\"volume-value-pro\" id=\"vol_${fileId}\">--<\/div>\r\n                            <div style=\"font-size: 11px; color: #666;\">cm\u00b3<\/div>\r\n                        <\/div>\r\n                        \r\n                        <div class=\"printability-bar-pro\">\r\n                            <div class=\"printability-fill-pro\" id=\"printabilityBar_${fileId}\"><\/div>\r\n                        <\/div>\r\n                        <!-- notranslate \/ translate=\"no\" \/ data-no-translation: blocks TranslatePress's\r\n                             Auto-Translate Addon from overwriting our JS-set printability text. Without\r\n                             these, TRP can replace e.g. our Arabic \"\u0642\u0627\u0628\u0644\u064a\u0629 \u0627\u0644\u0637\u0628\u0627\u0639\u0629: \u0636\u0639\u064a\u0641\u0629\" with a French\r\n                             entry it cached during a previous fr session. -->\r\n                        <div class=\"printability-label-pro notranslate\" translate=\"no\" data-no-translation id=\"printabilityLabel_${fileId}\">${i18n.calculating}<\/div>\r\n                    <\/div>\r\n                <\/div>\r\n                \r\n                <div class=\"cost-breakdown-pro\" id=\"costBreakdown_${fileId}\">\r\n                    <div class=\"cost-item-pro\"><span>${i18n.totalHT}<\/span><span>-- TND<\/span><\/div>\r\n                    <div class=\"cost-item-pro\"><span>${i18n.totalTTC}<\/span><span>-- TND<\/span><\/div>\r\n                <\/div>\r\n                \r\n                <div class=\"settings-grid-pro\">\r\n                    <div class=\"form-group-pro\">\r\n                        <label>${i18n.labelTechnology}<\/label>\r\n                        <select class=\"tech-pro notranslate\" translate=\"no\" data-no-translation\r\n                                onchange=\"updateMaterialsPro(${index})\">\r\n                            <option value=\"FDM\" class=\"notranslate\" translate=\"no\">FDM<\/option>\r\n                            <option value=\"SLA\" class=\"notranslate\" translate=\"no\">SLA<\/option>\r\n                        <\/select>\r\n                    <\/div>\r\n\r\n                    <div class=\"form-group-pro\">\r\n                        <label>${i18n.labelMaterial}<\/label>\r\n                        <!-- notranslate on parent: PLA, PLA+, PETG, ABS, TPU are abbreviations,\r\n                             blocks TranslatePress Auto-Translate Addon from mistranslating them\r\n                             (it was rendering \"PLA\" as \"Chinese People's Liberation Army\" in Arabic). -->\r\n                        <select class=\"material-pro notranslate\" translate=\"no\" data-no-translation\r\n                                id=\"material_${fileId}\"><\/select>\r\n                    <\/div>\r\n\r\n                    <div class=\"form-group-pro\">\r\n                        <label>${i18n.labelColor}<\/label>\r\n                        <select class=\"color-pro\" onchange=\"updateColorAndPricePro(${index}, this.value)\">\r\n                            ${colorOptions.map(color => `<option value=\"${color.value}\">${color.name()}<\/option>`).join('')}\r\n                        <\/select>\r\n                    <\/div>\r\n\r\n                    <div class=\"form-group-pro\">\r\n                        <label>${i18n.labelQuality}<\/label>\r\n                        <select class=\"quality-pro\">\r\n                            <option value=\"Basse\">${i18n.qualityLow}<\/option>\r\n                            <option value=\"Standard\" selected>${i18n.qualityStandard}<\/option>\r\n                            <option value=\"Haute\">${i18n.qualityHigh}<\/option>\r\n                        <\/select>\r\n                    <\/div>\r\n\r\n                    <div class=\"form-group-pro\">\r\n                        <label>${i18n.labelQuantity}<\/label>\r\n                        <input type=\"number\" class=\"quantity-pro\" value=\"1\" min=\"1\" max=\"1000\">\r\n                    <\/div>\r\n                <\/div>\r\n                \r\n                <input type=\"hidden\" class=\"volume-pro\" value=\"0\">\r\n                <input type=\"hidden\" class=\"surface-pro\" value=\"0\">\r\n                <input type=\"hidden\" class=\"bounding-volume-pro\" value=\"0\">\r\n                <input type=\"hidden\" class=\"unit-price-ht-pro\" value=\"0\">\r\n            `;\r\n            \r\n            elements.filesList.appendChild(card);\r\n            updateMaterialsPro(index);\r\n            \r\n            setTimeout(() => {\r\n                const cardElement = document.querySelectorAll('.stl-file-card-pro')[index];\r\n                const inputs = cardElement.querySelectorAll('input, select');\r\n                inputs.forEach(input => {\r\n                    input.addEventListener('change', updatePricesPro);\r\n                });\r\n            }, 100);\r\n        });\r\n    }\r\n    \r\n    \/\/ Global functions\r\n    window.removeFilePro = function(fileIndex) {\r\n        \/\/ Dispose all active WebGL contexts before re-rendering to avoid context leaks\r\n        Real3DViewer.disposeAllViewers();\r\n\r\n        state.files.splice(fileIndex, 1);\r\n        const newFileData = new Map();\r\n        state.files.forEach((file, index) => {\r\n            const oldFileId = `file_${fileIndex <= index ? index + 1 : index}`;\r\n            const newFileId = `file_${index}`;\r\n            if (state.fileData.has(oldFileId)) newFileData.set(newFileId, state.fileData.get(oldFileId));\r\n        });\r\n        state.fileData = newFileData;\r\n        renderFiles();\r\n        processAllFiles();\r\n        updatePricesPro();\r\n        updateFileCounter();\r\n    };\r\n    \r\n    window.updateMaterialsPro = function(fileIndex) {\r\n        const fileId = `file_${fileIndex}`;\r\n        const card = document.getElementById(`viewer_${fileId}`)?.closest('.stl-file-card-pro');\r\n        if (!card) return;\r\n        \r\n        const techSelect = card.querySelector('.tech-pro');\r\n        const materialSelect = card.querySelector('.material-pro');\r\n        const selectedTech = techSelect.value;\r\n        const materials = technologyMaterials[selectedTech] || [];\r\n        \r\n        materialSelect.innerHTML = '';\r\n        materials.forEach(material => {\r\n            const option = document.createElement('option');\r\n            if (typeof material === 'object') {\r\n                \/\/ SLA materials: {value: 'Standard Resin', label: () => i18n.matXxx}\r\n                option.value       = material.value;\r\n                option.textContent = material.label();\r\n            } else {\r\n                \/\/ FDM materials are abbreviations (PLA, PLA+, PETG, ABS, TPU) \u2014\r\n                \/\/ identical in every language. Mark as untranslatable so\r\n                \/\/ TranslatePress \/ Google Translate \/ browser tools leave them alone.\r\n                \/\/ (Without this, TRP has been auto-translating \"PLA\" to wrong Arabic.)\r\n                option.value       = material;\r\n                option.textContent = material;\r\n                option.classList.add('notranslate');\r\n                option.setAttribute('translate', 'no');\r\n            }\r\n            materialSelect.appendChild(option);\r\n        });\r\n        \r\n        updatePricesPro();\r\n    };\r\n    \r\n    window.updateColorAndPricePro = function(fileIndex, colorName) {\r\n        const fileId = `file_${fileIndex}`;\r\n        const viewerId = `viewer_${fileId}`;\r\n        Real3DViewer.updateColor(viewerId, colorName);\r\n        updatePricesPro();\r\n    };\r\n    \r\n    function handleFileError(fileId) {\r\n        \/\/ Status badge\r\n        const statusEl = document.getElementById(`status_${fileId}`);\r\n        if (statusEl) {\r\n            statusEl.className = 'file-status-pro status-error-pro';\r\n            statusEl.innerHTML = `\u26a0\ufe0f ${i18n.fileUnreadable}`;\r\n        }\r\n\r\n        \/\/ Printability bar \u2014 empty \/ 0\r\n        const printabilityBar   = document.getElementById(`printabilityBar_${fileId}`);\r\n        const printabilityLabel = document.getElementById(`printabilityLabel_${fileId}`);\r\n        if (printabilityBar) {\r\n            printabilityBar.className = 'printability-fill-pro';\r\n            printabilityBar.style.width = '0%';\r\n        }\r\n        if (printabilityLabel) {\r\n            \/\/ Preserve \"notranslate\" so TRP keeps its hands off this dynamic text.\r\n            printabilityLabel.className = 'printability-label-pro printability-unreadable-text-pro notranslate';\r\n            printabilityLabel.textContent = i18n.fileUnreadableMsg;\r\n        }\r\n\r\n        \/\/ Zero out dimension \/ volume display\r\n        ['width', 'height', 'depth', 'vol'].forEach(id => {\r\n            const el = document.getElementById(`${id}_${fileId}`);\r\n            if (el) el.textContent = '--';\r\n        });\r\n\r\n        \/\/ Zero out hidden price inputs so the card contributes 0\r\n        const card = document.getElementById(`viewer_${fileId}`)?.closest('.stl-file-card-pro');\r\n        if (card) {\r\n            ['volume-pro', 'surface-pro', 'bounding-volume-pro'].forEach(cls => {\r\n                const inp = card.querySelector('.' + cls);\r\n                if (inp) inp.value = '0';\r\n            });\r\n        }\r\n\r\n        \/\/ Store error sentinel \u2014 updatePricesPro will render 0.000 for this card\r\n        state.fileData.set(fileId, { error: true });\r\n    }\r\n\r\n    function processAllFiles() {\r\n        state.processing = 0;\r\n        elements.submitBtn.disabled = true;\r\n        elements.cartBtn.disabled = true;\r\n        \r\n        if (state.files.length === 0) {\r\n            checkAllProcessed();\r\n            return;\r\n        }\r\n        \r\n        state.files.forEach((file, index) => processSingleFile(file, index));\r\n    }\r\n    \r\n    function processSingleFile(file, index) {\r\n        const fileId = `file_${index}`;\r\n        const viewerId = `viewer_${fileId}`;\r\n        const reader = new FileReader();\r\n        \r\n        reader.onload = function(e) {\r\n            try {\r\n                const isOBJ  = file.name.toLowerCase().endsWith('.obj');\r\n                const result = isOBJ\r\n                    ? AccurateOBJVolumeCalculator.calculateVolumeAndSurface(e.target.result)\r\n                    : AccurateSTLVolumeCalculator.calculateVolumeAndSurface(e.target.result);\r\n                state.fileData.set(fileId, result);\r\n                \r\n                document.getElementById(`status_${fileId}`).className = 'file-status-pro status-success-pro';\r\n                document.getElementById(`status_${fileId}`).innerHTML = `\u2705 ${i18n.ready}`;\r\n                document.getElementById(`width_${fileId}`).textContent = result.dimensions.width.toFixed(1);\r\n                document.getElementById(`height_${fileId}`).textContent = result.dimensions.height.toFixed(1);\r\n                document.getElementById(`depth_${fileId}`).textContent = result.dimensions.depth.toFixed(1);\r\n                document.getElementById(`vol_${fileId}`).textContent = result.volume.toFixed(2);\r\n                \r\n                const printabilityBar = document.getElementById(`printabilityBar_${fileId}`);\r\n                const printabilityLabel = document.getElementById(`printabilityLabel_${fileId}`);\r\n                if (printabilityBar && printabilityLabel) {\r\n                    printabilityBar.className = `printability-fill-pro printability-${result.printability}-pro`;\r\n                    printabilityLabel.className = `printability-label-pro printability-${result.printability}-text-pro notranslate`;\r\n                    \/\/ Debug: log what we're actually writing \u2014 if this shows the right language but\r\n                    \/\/ the on-page text is still wrong, TranslatePress's Auto-Translate Addon is overriding.\r\n                    console.log('[3dp-pricing] writing printabilityText =', result.printabilityText);\r\n                    \/\/ Use innerHTML with a fresh inner span carrying every TRP block flag we know.\r\n                    \/\/ textContent on its own is replayed by TRP's MutationObserver and overwritten;\r\n                    \/\/ a brand-new span node with notranslate + translate=no + data-no-translation is\r\n                    \/\/ skipped by TRP entirely, so the value we set sticks.\r\n                    var _safe = String(result.printabilityText)\r\n                        .replace(\/&\/g, '&amp;').replace(\/<\/g, '&lt;').replace(\/>\/g, '&gt;');\r\n                    printabilityLabel.innerHTML =\r\n                        '<span class=\"notranslate\" translate=\"no\" data-no-translation>' + _safe + '<\/span>';\r\n                }\r\n\r\n                tdpFileStore[fileId] = file;\r\n                tdpRenderDesignTip(result, file.name, fileId);\r\n                \r\n                const card = document.getElementById(viewerId)?.closest('.stl-file-card-pro');\r\n                if (card) {\r\n                    card.querySelector('.volume-pro').value = result.volume.toFixed(2);\r\n                    card.querySelector('.surface-pro').value = result.surfaceArea.toFixed(2);\r\n                    card.querySelector('.bounding-volume-pro').value = result.boundingBoxVolume.toFixed(2);\r\n                }\r\n                \r\n                if (index < MAX_VIEWERS) {\r\n                    Real3DViewer.initViewer(viewerId, e.target.result, file.name, result.dimensions, materialColors['White']);\r\n                }\r\n                \r\n            } catch (error) {\r\n                console.error(`Error processing ${file.name}:`, error);\r\n                handleFileError(fileId);\r\n            } finally {\r\n                state.processing++;\r\n                checkAllProcessed();\r\n            }\r\n        };\r\n\r\n        reader.onerror = function() {\r\n            console.error(`Read error for ${file.name}`);\r\n            handleFileError(fileId);\r\n            state.processing++;\r\n            checkAllProcessed();\r\n        };\r\n        \r\n        reader.readAsArrayBuffer(file);\r\n    }\r\n    \r\n    function checkAllProcessed() {\r\n        if (state.processing === state.files.length) {\r\n            updatePricesPro();\r\n            const errorCount = [...state.fileData.values()].filter(d => d && d.error).length;\r\n            const okCount    = state.files.length - errorCount;\r\n            elements.submitBtn.disabled = okCount === 0 && errorCount === 0;\r\n            elements.cartBtn.disabled   = okCount === 0 && errorCount === 0;\r\n            if (state.files.length > 0) {\r\n                if (errorCount === 0) {\r\n                    showAlert(i18n.allFilesProcessed, 'success');\r\n                } else if (okCount > 0) {\r\n                    showAlert(`\u26a0\ufe0f ${okCount} file(s) ready, ${errorCount} could not be read. Please contact us for the unreadable file(s).`, 'error');\r\n                } else {\r\n                    showAlert(`\u26a0\ufe0f ${i18n.fileUnreadableMsg}`, 'error');\r\n                }\r\n            }\r\n        }\r\n    }\r\n    \r\n    function updatePricesPro() {\r\n        let totalHT = 0;\r\n        let totalTTC = 0;\r\n        \r\n        document.querySelectorAll('.stl-file-card-pro').forEach((card, index) => {\r\n            if (index < state.files.length) {\r\n                const fileId = `file_${index}`;\r\n                const fileData = state.fileData.get(fileId);\r\n                \r\n                if (fileData && !fileData.error) {\r\n                    const technology = card.querySelector('.tech-pro').value;\r\n                    const material = card.querySelector('.material-pro').value;\r\n                    const quality = card.querySelector('.quality-pro').value;\r\n                    const color = card.querySelector('.color-pro').value;\r\n                    const materialVolume = parseFloat(card.querySelector('.volume-pro').value) || 0;\r\n                    const surfaceArea = parseFloat(card.querySelector('.surface-pro').value) || 0;\r\n                    const quantity = parseInt(card.querySelector('.quantity-pro').value) || 1;\r\n\r\n                    const priceResult = pricingPro.calculateDetailedPrice(\r\n                        materialVolume, surfaceArea, technology, material, quality, quantity, color\r\n                    );\r\n\r\n                    card.querySelector('.unit-price-ht-pro').value = (priceResult.priceHT \/ quantity).toFixed(3);\r\n\r\n                    const breakdownElement = document.getElementById(`costBreakdown_${fileId}`);\r\n                    if (breakdownElement) {\r\n                        breakdownElement.innerHTML = `\r\n                            <div class=\"cost-item-pro\"><span>${i18n.totalHT}<\/span><span>${priceResult.priceHT.toFixed(3)} TND<\/span><\/div>\r\n                            <div class=\"cost-item-pro\"><span>${i18n.totalTTC}<\/span><span>${priceResult.priceTTC.toFixed(3)} TND<\/span><\/div>\r\n                        `;\r\n                    }\r\n\r\n                    totalHT += priceResult.priceHT;\r\n                    totalTTC += priceResult.priceTTC;\r\n\r\n                } else if (fileData && fileData.error) {\r\n                    \/\/ Unreadable file \u2014 show 0 explicitly, contribute nothing to total\r\n                    const unitInput = card.querySelector('.unit-price-ht-pro');\r\n                    if (unitInput) unitInput.value = '0.000';\r\n                    const breakdownElement = document.getElementById(`costBreakdown_${fileId}`);\r\n                    if (breakdownElement) {\r\n                        breakdownElement.innerHTML = `\r\n                            <div class=\"cost-item-pro\"><span>${i18n.totalHT}<\/span><span>0.000 TND<\/span><\/div>\r\n                            <div class=\"cost-item-pro\"><span>${i18n.totalTTC}<\/span><span>0.000 TND<\/span><\/div>\r\n                        `;\r\n                    }\r\n                }\r\n            }\r\n        });\r\n        \r\n        elements.totalPrice.textContent = totalTTC.toFixed(3);\r\n        document.getElementById('breakdownHT').textContent = totalHT.toFixed(3) + ' TND';\r\n    }\r\n    \r\n    function handleSubmit() {\r\n        if (state.files.length === 0) {\r\n            showAlert(i18n.noFiles, 'error');\r\n            return;\r\n        }\r\n        if (!guestInfo.identified) {\r\n            window.location.href = 'https:\/\/tunisia3dprint.com\/fr\/login\/?redirect_to=' + encodeURIComponent(window.location.href);\r\n            return;\r\n        }\r\n        showQuotationPro();\r\n    }\r\n    \r\n    \/\/ \u2500\u2500 Number-to-words (French, TND) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n    function amountToWordsFR(amount) {\r\n        const units  = ['','un','deux','trois','quatre','cinq','six','sept','huit','neuf',\r\n                        'dix','onze','douze','treize','quatorze','quinze','seize','dix-sept','dix-huit','dix-neuf'];\r\n        const tens   = ['','','vingt','trente','quarante','cinquante','soixante','soixante','quatre-vingt','quatre-vingt'];\r\n        function belowHundred(n) {\r\n            if (n < 20) return units[n];\r\n            const t = Math.floor(n \/ 10), u = n % 10;\r\n            if (t === 7 || t === 9) return tens[t] + (u === 0 ? (t===9?'s':'') : '-' + units[10 + u]);\r\n            if (t === 8) return 'quatre-vingt' + (u === 0 ? 's' : '-' + units[u]);\r\n            return tens[t] + (u === 1 ? ' et un' : u > 0 ? '-' + units[u] : '');\r\n        }\r\n        function belowThousand(n) {\r\n            if (n < 100) return belowHundred(n);\r\n            const h = Math.floor(n \/ 100), r = n % 100;\r\n            return (h === 1 ? 'cent' : units[h] + ' cent') + (r > 0 ? (h===1?'':' ') + ' ' + belowHundred(r) : (h>1?'s':''));\r\n        }\r\n        const intPart  = Math.floor(amount);\r\n        const millPart = Math.round((amount - intPart) * 1000);\r\n        let words = '';\r\n        if (intPart === 0) {\r\n            words = 'z\u00e9ro';\r\n        } else if (intPart < 1000) {\r\n            words = belowThousand(intPart);\r\n        } else {\r\n            const th = Math.floor(intPart \/ 1000), rem = intPart % 1000;\r\n            words = (th === 1 ? 'mille' : belowThousand(th) + ' mille') + (rem > 0 ? ' ' + belowThousand(rem) : '');\r\n        }\r\n        words = words.charAt(0).toUpperCase() + words.slice(1) + ' dinars';\r\n        if (millPart > 0) words += ' et ' + belowThousand(millPart) + ' millimes';\r\n        return words;\r\n    }\r\n\r\n    function showQuotationPro() {\r\n        \/\/ First get a server-side sequential quote number, then render\r\n        if (!state.quotationNumber) {\r\n            jQuery.ajax({\r\n                type: 'POST',\r\n                url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\r\n                data: { action: 't3d_generate_quote_number', nonce: window.pro_ajax_nonce },\r\n                success: function(res) {\r\n                    const yr = String(new Date().getFullYear()).slice(-2);\r\n                    state.quotationNumber = res.success ? res.data.quote_number : ('DEV3D' + yr + '-' + Date.now().toString().slice(-4));\r\n                    renderQuotationPro();\r\n                },\r\n                error: function() {\r\n                    const yr = String(new Date().getFullYear()).slice(-2);\r\n                    state.quotationNumber = 'DEV3D' + yr + '-' + Date.now().toString().slice(-4);\r\n                    renderQuotationPro();\r\n                }\r\n            });\r\n        } else {\r\n            renderQuotationPro();\r\n        }\r\n    }\r\n\r\n    function renderQuotationPro() {\r\n        const today   = new Date();\r\n        const dateStr = today.toLocaleDateString('fr-TN', { day:'2-digit', month:'2-digit', year:'numeric' });\r\n\r\n        \/\/ Collect item data\r\n        const orderData = state.files.map((file, index) => {\r\n            const card       = document.querySelectorAll('.stl-file-card-pro')[index];\r\n            const quantity   = parseInt(card.querySelector('.quantity-pro').value) || 1;\r\n            const unitPriceHT = parseFloat(card.querySelector('.unit-price-ht-pro').value) || 0;\r\n            return {\r\n                ref:          String(index + 1).padStart(3, '0'),\r\n                description:  file.name.replace(\/\\.[^.]+$\/, '') +\r\n                              ' \u2014 ' + card.querySelector('.tech-pro').value +\r\n                              ' \/ ' + card.querySelector('.material-pro').value +\r\n                              ' \/ ' + card.querySelector('.color-pro').value +\r\n                              ' \/ ' + card.querySelector('.quality-pro').value,\r\n                quantity:     quantity,\r\n                unit_price_ht: unitPriceHT,\r\n                total_ht:     unitPriceHT * quantity\r\n            };\r\n        });\r\n\r\n        const subtotalHT    = orderData.reduce((s, i) => s + i.total_ht, 0);\r\n        const vatAmount     = subtotalHT * 0.19;\r\n        const timbreFiscal  = 1.000;\r\n        const grandTotal    = subtotalHT + vatAmount + timbreFiscal;\r\n\r\n        \/\/ Client address block\r\n        const b = guestInfo.billing;\r\n        let clientAddr = '';\r\n        if (b.company)   clientAddr += `<div style=\"font-weight:700;\">${b.company}<\/div>`;\r\n        if (b.address_1) clientAddr += `<div>${b.address_1}<\/div>`;\r\n        if (b.address_2) clientAddr += `<div>${b.address_2}<\/div>`;\r\n        if (b.city || b.postcode) clientAddr += `<div>${[b.postcode, b.city].filter(Boolean).join(' ')}<\/div>`;\r\n        if (b.country)   clientAddr += `<div>${b.country}<\/div>`;\r\n        if (!clientAddr) clientAddr  = `<div style=\"color:#999;font-style:italic;\">\u2014 address not set in profile \u2014<\/div>`;\r\n\r\n        \/\/ Items rows\r\n        let rowsHTML = '';\r\n        orderData.forEach(item => {\r\n            rowsHTML += `\r\n            <tr>\r\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:center;font-size:12px;color:#555;\">${item.ref}<\/td>\r\n                <td style=\"padding:9px 10px;border:1px solid #ddd;font-size:13px;\">${item.description}<\/td>\r\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:center;\">${item.quantity}<\/td>\r\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:right;\">${item.unit_price_ht.toFixed(3)}<\/td>\r\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:right;font-weight:600;\">${item.total_ht.toFixed(3)}<\/td>\r\n            <\/tr>`;\r\n        });\r\n\r\n        const quotationHTML = `\r\n<div id=\"ppp-quote-doc\" style=\"font-family:'Segoe UI',Arial,sans-serif;color:#1a1a1a;max-width:800px;margin:0 auto;font-size:13px;line-height:1.5;\">\r\n\r\n    <!-- HEADER -->\r\n    <div style=\"display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:28px;padding-bottom:18px;border-bottom:3px solid #1a1a1a;\">\r\n        <div>\r\n            <div style=\"font-size:22px;font-weight:900;color:#1a1a1a;letter-spacing:-.5px;\">FM MECA<\/div>\r\n            <div style=\"font-size:11px;color:#d32f2f;font-weight:700;text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px;\">tunisia3dprint.com<\/div>\r\n            <div style=\"font-size:12px;color:#555;line-height:1.7;\">\r\n                Cit\u00e9 Ezzayatine, Ksibet Sousse<br>\r\n                4041 Sousse, Tunisia<br>\r\n                T\u00e9l&nbsp;: 56 401 310<br>\r\n                MF&nbsp;: 1814198PAM000\r\n            <\/div>\r\n        <\/div>\r\n        <div style=\"text-align:right;\">\r\n            <div style=\"font-size:28px;font-weight:900;color:#1a1a1a;text-transform:uppercase;letter-spacing:1px;\">Devis<\/div>\r\n            <div style=\"margin-top:8px;border:1px solid #ddd;border-radius:6px;overflow:hidden;font-size:12px;\">\r\n                <table style=\"border-collapse:collapse;width:100%;\">\r\n                    <tr style=\"background:#f4f4f4;\">\r\n                        <th style=\"padding:6px 12px;border-right:1px solid #ddd;font-weight:700;text-align:left;\">Num\u00e9ro<\/th>\r\n                        <th style=\"padding:6px 12px;border-right:1px solid #ddd;font-weight:700;text-align:left;\">Date<\/th>\r\n                        <th style=\"padding:6px 12px;font-weight:700;text-align:left;\">Page<\/th>\r\n                    <\/tr>\r\n                    <tr>\r\n                        <td style=\"padding:6px 12px;border-right:1px solid #ddd;font-weight:700;color:#d32f2f;\">${state.quotationNumber}<\/td>\r\n                        <td style=\"padding:6px 12px;border-right:1px solid #ddd;\">${dateStr}<\/td>\r\n                        <td style=\"padding:6px 12px;\">1<\/td>\r\n                    <\/tr>\r\n                <\/table>\r\n            <\/div>\r\n        <\/div>\r\n    <\/div>\r\n\r\n    <!-- CLIENT BLOCK -->\r\n    <div style=\"display:flex;justify-content:flex-end;margin-bottom:28px;\">\r\n        <div style=\"border:1px solid #ddd;border-top:3px solid #d32f2f;border-radius:4px;padding:14px 18px;min-width:260px;font-size:13px;\">\r\n            <div style=\"font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:#d32f2f;margin-bottom:8px;\">Client<\/div>\r\n            <div style=\"font-weight:700;margin-bottom:2px;\">${guestInfo.name}<\/div>\r\n            <div style=\"color:#555;\">${guestInfo.email}<\/div>\r\n            <div style=\"color:#555;margin-top:4px;\">${clientAddr}<\/div>\r\n            ${guestInfo.phone ? `<div style=\"color:#555;margin-top:4px;\">T\u00e9l : ${guestInfo.phone}<\/div>` : ''}\r\n        <\/div>\r\n    <\/div>\r\n\r\n    <!-- ITEMS TABLE -->\r\n    <table style=\"width:100%;border-collapse:collapse;margin-bottom:24px;font-size:13px;\">\r\n        <thead>\r\n            <tr style=\"background:#fff;color:#1a1a1a;border-bottom:2px solid #1a1a1a;\">\r\n                <th style=\"padding:10px;text-align:center;width:50px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">R\u00e9f.<\/th>\r\n                <th style=\"padding:10px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">Description<\/th>\r\n                <th style=\"padding:10px;text-align:center;width:50px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">Qt\u00e9<\/th>\r\n                <th style=\"padding:10px;text-align:right;width:120px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">P.U. HT (TND)<\/th>\r\n                <th style=\"padding:10px;text-align:right;width:120px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">Total HT (TND)<\/th>\r\n            <\/tr>\r\n        <\/thead>\r\n        <tbody>${rowsHTML}<\/tbody>\r\n        <!-- empty filler rows up to 10 lines total -->\r\n        ${Array(Math.max(0, 10 - orderData.length)).fill('<tr style=\"height:36px;\"><td colspan=\"5\" style=\"border-bottom:1px solid #eee;\"><\/td><\/tr>').join('')}\r\n    <\/table>\r\n\r\n    <!-- TOTALS -->\r\n    <div style=\"display:flex;justify-content:flex-end;margin-bottom:24px;\">\r\n        <table style=\"border-collapse:collapse;font-size:13px;min-width:320px;\">\r\n            <tr>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;background:#fafafa;\">Total H.T.<\/td>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;text-align:right;font-weight:600;\">${subtotalHT.toFixed(3)} TND<\/td>\r\n            <\/tr>\r\n            <tr>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;background:#fafafa;\">TVA (19%)<\/td>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;text-align:right;\">${vatAmount.toFixed(3)} TND<\/td>\r\n            <\/tr>\r\n            <tr>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;background:#fafafa;\">Timbre fiscal<\/td>\r\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;text-align:right;\">${timbreFiscal.toFixed(3)} TND<\/td>\r\n            <\/tr>\r\n            <tr style=\"border-top:2px solid #1a1a1a;\">\r\n                <td style=\"padding:11px 14px;border:1px solid #e0e0e0;font-weight:800;font-size:14px;color:#1a1a1a;\">TOTAL T.T.C.<\/td>\r\n                <td style=\"padding:11px 14px;border:1px solid #e0e0e0;text-align:right;font-weight:800;font-size:14px;color:#d32f2f;\">${grandTotal.toFixed(3)} TND<\/td>\r\n            <\/tr>\r\n        <\/table>\r\n    <\/div>\r\n\r\n    <!-- AMOUNT IN WORDS -->\r\n    <div style=\"background:#f8f8f8;border-left:4px solid #d32f2f;padding:10px 14px;font-size:13px;margin-bottom:20px;\">\r\n        <strong>Arr\u00eat\u00e9 le pr\u00e9sent devis \u00e0 la somme de :<\/strong><br>\r\n        <span style=\"font-style:italic;\">${amountToWordsFR(grandTotal)}<\/span>\r\n    <\/div>\r\n\r\n    <!-- NOTES -->\r\n    <div style=\"font-size:12px;color:#555;margin-bottom:16px;\">\r\n        <strong style=\"color:#1a1a1a;display:block;margin-bottom:6px;\">Notes :<\/strong>\r\n        <div>\u2014 Ce devis est valable un mois \u00e0 compter de sa date d'\u00e9mission.<\/div>\r\n        <div>\u2014 D\u00e9lai de livraison estim\u00e9 : 3 \u00e0 5 jours ouvrables apr\u00e8s confirmation de commande.<\/div>\r\n        <div>\u2014 Les prix sont donn\u00e9s hors options non mentionn\u00e9es dans ce devis.<\/div>\r\n    <\/div>\r\n\r\n    <!-- FOOTER -->\r\n    <div class=\"quote-footer-pro\" style=\"border-top:1px solid #e0e0e0;padding-top:10px;text-align:center;font-size:11px;color:#999;\">\r\n        FM MECA \u2014 MF 1814198PAM000 \u2014 Cit\u00e9 Ezzayatine, 4041 Sousse, Tunisia \u2014 tunisia3dprint.com \u2014 Contact@Fmmeca.com\r\n    <\/div>\r\n\r\n<\/div>`;\r\n\r\n        elements.quotationContent.innerHTML = quotationHTML;\r\n        elements.quotationModal.style.display = 'flex';\r\n        saveFilesToServer('quote_viewed');\r\n    }\r\n    \r\n    window.closeQuotationPro = function() {\r\n        elements.quotationModal.style.display = 'none';\r\n    };\r\n\r\n    window.printQuotePro = function() {\r\n        const doc = document.getElementById('ppp-quote-doc');\r\n        if (!doc) return;\r\n\r\n        const itemCount = state.files.length;\r\n\r\n        \/\/ Clone and compact for print\r\n        const clone = doc.cloneNode(true);\r\n\r\n        \/\/ 1. Shrink filler rows: detect by single td with colspan=5 and no text\r\n        clone.querySelectorAll('tr').forEach(tr => {\r\n            const tds = tr.querySelectorAll('td');\r\n            if (tds.length === 1 && tds[0].getAttribute('colspan') === '5' && tds[0].textContent.trim() === '') {\r\n                tr.style.height = '10px';\r\n                tds[0].style.padding = '0';\r\n            }\r\n        });\r\n\r\n        \/\/ 2. Compact all table cells\r\n        clone.querySelectorAll('td, th').forEach(el => {\r\n            el.style.padding = '5px 8px';\r\n            el.style.fontSize = '11px';\r\n        });\r\n\r\n        \/\/ 3. Scale down large decorative font sizes, reduce paddings on divs\r\n        const fontMap = {'32px':'18px','28px':'18px','22px':'15px','14px':'12px','15px':'12px','13px':'11px','12px':'10px'};\r\n        clone.querySelectorAll('*').forEach(el => {\r\n            const fs = el.style.fontSize;\r\n            if (fontMap[fs]) el.style.fontSize = fontMap[fs];\r\n            \/\/ Reduce large div paddings\r\n            const p = el.style.padding;\r\n            if (p && el.tagName !== 'TD' && el.tagName !== 'TH') {\r\n                el.style.padding = p.replace(\/\\b(2[4-9]|[3-9]\\d)px\\b\/g, '10px')\r\n                                    .replace(\/\\b(1[6-9])px\\b\/g, '8px');\r\n            }\r\n            \/\/ Reduce large margins\r\n            const mb = el.style.marginBottom;\r\n            if (mb && parseInt(mb) > 14) el.style.marginBottom = '10px';\r\n        });\r\n\r\n        \/\/ 4. For many items, allow a second page break before totals section\r\n        \/\/ (items > 7: totals block gets page-break-before)\r\n        if (itemCount > 7) {\r\n            const totalsDiv = clone.querySelector('div[style*=\"justify-content:flex-end\"]');\r\n            if (totalsDiv) totalsDiv.style.pageBreakBefore = 'always';\r\n        }\r\n\r\n        \/\/ 5. Pin footer to bottom of A4 page \u2014 make doc a flex column, push footer down\r\n        clone.style.display = 'flex';\r\n        clone.style.flexDirection = 'column';\r\n        clone.style.minHeight = 'calc(297mm - 16mm)';\r\n        const footerEl = clone.querySelector('.quote-footer-pro');\r\n        if (footerEl) footerEl.style.marginTop = 'auto';\r\n\r\n        const pw = window.open('', '_blank', 'width=860,height=700');\r\n        pw.document.write(`<!DOCTYPE html>\r\n<html>\r\n<head>\r\n<meta charset=\"UTF-8\">\r\n<title>Devis ${state.quotationNumber}<\/title>\r\n<style>\r\n  * { box-sizing: border-box; }\r\n  body {\r\n    font-family: Arial, sans-serif;\r\n    font-size: 11px;\r\n    color: #1a1a1a;\r\n    line-height: 1.35;\r\n    margin: 0;\r\n    padding: 8mm 10mm;\r\n  }\r\n  table { border-collapse: collapse; width: 100%; }\r\n  thead { display: table-header-group; }\r\n  tr { page-break-inside: avoid; }\r\n  @page { size: A4; margin: 0; }\r\n<\/style>\r\n<\/head>\r\n<body>\r\n${clone.outerHTML}\r\n<script>\r\n  window.onload = function() {\r\n    setTimeout(function() { window.print(); window.close(); }, 500);\r\n  };\r\n<\\\/script>\r\n<\/body>\r\n<\/html>`);\r\n        pw.document.close();\r\n    };\r\n    \r\n    function handleAddToCart() {\r\n        if (state.files.length === 0) {\r\n            showAlert(i18n.noFiles, 'error');\r\n            return;\r\n        }\r\n\r\n        if (!guestInfo.identified) {\r\n            window.location.href = 'https:\/\/tunisia3dprint.com\/fr\/login\/?redirect_to=' + encodeURIComponent(window.location.href);\r\n            return;\r\n        }\r\n\r\n        doAddToCart();\r\n    }\r\n\r\n    function doAddToCart() {\r\n        elements.cartBtn.disabled = true;\r\n        elements.cartBtn.classList.add('loading');\r\n        const originalText = elements.cartBtn.innerHTML;\r\n        elements.cartBtn.innerHTML = i18n.cartProcessing;\r\n        \r\n        const productData = {\r\n            action: 'tdp_create_order',\r\n            nonce: window.pro_ajax_nonce,\r\n            quotation_number: state.quotationNumber,\r\n            total_price: parseFloat(elements.totalPrice.textContent),\r\n            user_email: guestInfo.email,\r\n            user_name: guestInfo.name,\r\n            user_phone: guestInfo.phone,\r\n            gdpr_consent: guestInfo.gdpr_consent,\r\n            files: state.files.map((file, index) => {\r\n                const card = document.querySelectorAll('.stl-file-card-pro')[index];\r\n                const quantity = parseInt(card.querySelector('.quantity-pro').value) || 1;\r\n                const unitPrice = parseFloat(card.querySelector('.unit-price-ht-pro').value) || 0;\r\n                const totalHT = unitPrice * quantity;\r\n                \r\n                return {\r\n                    file_name: file.name,\r\n                    technology: card.querySelector('.tech-pro').value,\r\n                    material: card.querySelector('.material-pro').value,\r\n                    color: card.querySelector('.color-pro').value,\r\n                    quality: card.querySelector('.quality-pro').value,\r\n                    quantity: quantity,\r\n                    volume: card.querySelector('.volume-pro').value,\r\n                    surface: card.querySelector('.surface-pro').value,\r\n                    unit_price: unitPrice.toFixed(3),\r\n                    price_ht: totalHT.toFixed(3),\r\n                    price_ttc: (totalHT * 1.19).toFixed(3)\r\n                };\r\n            })\r\n        };\r\n        \r\n        jQuery.ajax({\r\n            type: 'POST',\r\n            url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\r\n            data: productData,\r\n            success: function(response) {\r\n                if (response.success) {\r\n                    saveFilesToServer('cart_added');\r\n                    showAlert(i18n.orderCreated, 'success');\r\n                    setTimeout(() => {\r\n                        window.location.href = response.data.redirect_url || '\/cart\/';\r\n                    }, 1500);\r\n                } else {\r\n                    showAlert(i18n.orderError, 'error');\r\n                }\r\n            },\r\n            error: function(xhr, status, error) {\r\n                console.error('AJAX error:', error);\r\n                showAlert(i18n.connectionError, 'error');\r\n            },\r\n            complete: function() {\r\n                elements.cartBtn.disabled = false;\r\n                elements.cartBtn.classList.remove('loading');\r\n                elements.cartBtn.innerHTML = originalText;\r\n            }\r\n        });\r\n    }\r\n    \r\n    function updateFileCounter() {\r\n        elements.fileCounter.innerHTML = state.files.length > 0\r\n            ? `${i18n.fileCountPrefix} ${state.files.length} ${i18n.filesSelectedSuffix}`\r\n            : i18n.noFileSelected;\r\n    }\r\n    \r\n    function showAlert(message, type) {\r\n        const alert = document.createElement('div');\r\n        alert.className = `alert-message-pro alert-${type}-pro`;\r\n        alert.innerHTML = message;\r\n        elements.alertContainer.appendChild(alert);\r\n        setTimeout(() => alert.remove(), 5000);\r\n    }\r\n\r\n    \/\/ ==================== FILE SAVING ====================\r\n\r\n    function saveFilesToServer(eventType) {\r\n        if (state.files.length === 0) return;\r\n\r\n        const filesData = [];\r\n        const readPromises = [];\r\n\r\n        state.files.forEach(file => {\r\n            const promise = new Promise(resolve => {\r\n                const reader = new FileReader();\r\n                reader.onload = function(e) {\r\n                    \/\/ Convert ArrayBuffer to base64 using Array.from for efficiency\r\n                    \/\/ Chunk the Uint8Array to avoid call-stack overflow on large files\r\n                    const bytes     = new Uint8Array(e.target.result);\r\n                    const chunkSize = 8192;\r\n                    let binary = '';\r\n                    for (let i = 0; i < bytes.length; i += chunkSize) {\r\n                        binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));\r\n                    }\r\n                    const base64 = btoa(binary);\r\n                    filesData.push({ name: file.name, data: base64 });\r\n                    resolve();\r\n                };\r\n                reader.onerror = resolve;\r\n                reader.readAsArrayBuffer(file);\r\n            });\r\n            readPromises.push(promise);\r\n        });\r\n\r\n        Promise.all(readPromises).then(function() {\r\n            jQuery.ajax({\r\n                type: 'POST',\r\n                url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\r\n                data: {\r\n                    action: 't3d_save_uploaded_files',\r\n                    nonce: window.t3d_tracking_nonce,\r\n                    quotation_number: state.quotationNumber,\r\n                    event_type: eventType,\r\n                    total_price: elements.totalPrice.textContent,\r\n                    files: filesData,\r\n                    user_email: guestInfo.email,\r\n                    user_name: guestInfo.name,\r\n                    user_phone: guestInfo.phone,\r\n                    gdpr_consent: guestInfo.gdpr_consent,\r\n                    guest_identifier: guestInfo.guest_identifier\r\n                }\r\n            });\r\n        });\r\n    }\r\n    \r\n    init();\r\n});\r\n\r\n\/* === TDP Design Tips === *\/\r\n\r\nfunction tdpSelectTip(result) {\r\n    if (!window.tdpTips || !tdpTips.tips_enabled) return null;\r\n\r\n    const level = result.printability;\r\n    const f     = result.printabilityFactors || {};\r\n    const ov    = result.overhangData  || {};\r\n    const th    = result.thicknessData || {};\r\n    const me    = result.meshData      || {};\r\n\r\n    const headlines = {\r\n        excellent: tdpTips.headline_excellent,\r\n        good:      tdpTips.headline_good,\r\n        fair:      tdpTips.headline_improve,\r\n        poor:      tdpTips.headline_poor\r\n    };\r\n\r\n    \/\/ Collect active warnings and critical issues from the new analyzers\r\n    const criticals = [];\r\n    const warnings  = [];\r\n\r\n    if (ov.isCritical)        criticals.push(tdpTips.tip_overhang_critical);\r\n    else if (ov.isWarning)    warnings.push(tdpTips.tip_overhang_warning);\r\n\r\n    if (th.isCritical)        criticals.push(tdpTips.tip_thickness_critical);\r\n    else if (th.isWarning)    warnings.push(tdpTips.tip_thickness_warning);\r\n\r\n    if (me.isCritical)        criticals.push(tdpTips.tip_mesh_critical);\r\n    else if (me.isWarning)    warnings.push(tdpTips.tip_mesh_warning);\r\n\r\n    const allIssues   = [...criticals, ...warnings];\r\n    const hasIssues   = allIssues.length > 0;\r\n    const hasCritical = criticals.length > 0;\r\n\r\n    let body = '';\r\n\r\n    if (level === 'excellent') {\r\n        body = hasIssues ? allIssues.join(' ') : tdpTips.tip_excellent_full;\r\n\r\n    } else if (level === 'good') {\r\n        if (!hasIssues) {\r\n            \/\/ Bug fix: confident \"go ahead and order\" message \u2014 no more\r\n            \/\/ dangling \"suggestions below\" with nothing following.\r\n            body = tdpTips.tip_good_clean;\r\n        } else {\r\n            \/\/ Positive opener + the actual minor notes\r\n            body = tdpTips.tip_good_minor + ' ' + allIssues.join(' ');\r\n        }\r\n\r\n    } else {\r\n        \/\/ fair or poor \u2014 lead with the most critical issue\r\n        if (hasCritical) {\r\n            body = criticals[0];\r\n            if (criticals.length > 1) body += ' ' + criticals[1];\r\n            if (warnings.length > 0)  body += ' ' + warnings[0];\r\n        } else {\r\n            \/\/ Only warnings \u2014 fall back to dominant-factor logic for the\r\n            \/\/ legacy (aspect \/ efficiency \/ mesh) scores.\r\n            const scores = {\r\n                aspect:     f.aspectScore     || 0,\r\n                efficiency: f.efficiencyScore || 0,\r\n                mesh:       f.meshScore       || 0\r\n            };\r\n            const weakest = Object.keys(scores).reduce((a, b) => scores[a] <= scores[b] ? a : b);\r\n            if (weakest === 'aspect') {\r\n                body = (f.aspectRatio || 0) > 15 ? tdpTips.tip_aspect_tall : tdpTips.tip_aspect_flat;\r\n            } else if (weakest === 'efficiency') {\r\n                body = tdpTips.tip_efficiency_low;\r\n            } else {\r\n                body = (result.triangleCount || 0) === 0\r\n                    ? tdpTips.tip_mesh_unknown\r\n                    : tdpTips.tip_mesh_low;\r\n            }\r\n            if (warnings.length > 0) body += ' ' + warnings[0];\r\n        }\r\n    }\r\n\r\n    return { headline: headlines[level], body, level, hasCritical };\r\n}\r\n\r\nfunction tdpEscapeHtml(s) {\r\n    return String(s == null ? '' : s)\r\n        .replace(\/&\/g, '&amp;')\r\n        .replace(\/<\/g, '&lt;')\r\n        .replace(\/>\/g, '&gt;')\r\n        .replace(\/\"\/g, '&quot;')\r\n        .replace(\/'\/g, '&#39;');\r\n}\r\n\r\nfunction tdpRenderDesignTip(result, filename, fileId) {\r\n    \/\/ Remove any previous tip block for this card.\r\n    const existing = document.getElementById('tdp-design-tip-block-' + fileId);\r\n    if (existing) existing.remove();\r\n\r\n    const tip = tdpSelectTip(result);\r\n    if (!tip) return;\r\n\r\n    const icons   = { excellent: '\u2705', good: '\ud83d\udc4d', fair: '\ud83d\udca1', poor: '\u26a0\ufe0f' };\r\n    const showCTA = tip.level === 'fair' || tip.level === 'poor';\r\n\r\n    \/\/ Pre-encode all dynamic strings for safe inline HTML.\r\n    const safeFilename   = tdpEscapeHtml(filename);\r\n    const safeHeadline   = tdpEscapeHtml(tip.headline);\r\n    const safeBody       = tdpEscapeHtml(tip.body);\r\n    const safeFileId     = tdpEscapeHtml(fileId);\r\n    const argFilename    = JSON.stringify(filename);\r\n    const argLevel       = JSON.stringify(tip.level);\r\n    const argLevelText   = JSON.stringify(result.printabilityText || tip.level);\r\n    const argTipText     = JSON.stringify(tip.body);\r\n    const argFileId      = JSON.stringify(fileId);\r\n\r\n    let ctaHTML = '';\r\n    if (showCTA) {\r\n        if (tdpTips.isLoggedIn) {\r\n            \/\/ Logged-in: one-click send, no phone field needed.\r\n            ctaHTML = `\r\n        <div class=\"tdp-tip-cta\">\r\n            <p class=\"tdp-tip-cta-title\">${tdpEscapeHtml(tdpTips.cta_title)}<\/p>\r\n            <button type=\"button\" class=\"tdp-tip-send-btn\"\r\n                    onclick='tdpSendToTeam(${argFileId}, ${argFilename}, ${argLevel}, ${argLevelText}, ${argTipText})'>\r\n                \u2709 ${tdpEscapeHtml(tdpTips.cta_button)}\r\n            <\/button>\r\n            <div class=\"tdp-tip-send-msg\" id=\"tdp-tip-send-msg-${safeFileId}\"><\/div>\r\n        <\/div>`;\r\n        } else {\r\n            \/\/ Guest: phone required before button activates.\r\n            ctaHTML = `\r\n        <div class=\"tdp-tip-cta\">\r\n            <p class=\"tdp-tip-cta-title\">${tdpEscapeHtml(tdpTips.cta_title)}<\/p>\r\n            <p class=\"tdp-tip-guest-note\">${tdpEscapeHtml(tdpTips.cta_guest_note)}<\/p>\r\n            <label class=\"tdp-tip-phone-label\" for=\"tdp-tip-phone-${safeFileId}\">${tdpEscapeHtml(tdpTips.cta_phone_label)}<\/label>\r\n            <input type=\"tel\" id=\"tdp-tip-phone-${safeFileId}\" class=\"tdp-tip-phone-input\"\r\n                   placeholder=\"${tdpEscapeHtml(tdpTips.cta_phone_placeholder)}\"\r\n                   oninput=\"tdpUpdateSendBtn('${safeFileId}')\" \/>\r\n            <button type=\"button\" class=\"tdp-tip-send-btn\" disabled\r\n                    onclick='tdpSendToTeam(${argFileId}, ${argFilename}, ${argLevel}, ${argLevelText}, ${argTipText})'>\r\n                \u2709 ${tdpEscapeHtml(tdpTips.cta_button)}\r\n            <\/button>\r\n            <div class=\"tdp-tip-send-msg\" id=\"tdp-tip-send-msg-${safeFileId}\"><\/div>\r\n        <\/div>`;\r\n        }\r\n    }\r\n\r\n    const html = `\r\n        <div class=\"tdp-design-tip tdp-tip-${tip.level}\" id=\"tdp-design-tip-block-${safeFileId}\" data-filename=\"${safeFilename}\">\r\n            <div class=\"tdp-tip-header\">\r\n                <span class=\"tdp-tip-icon\">${icons[tip.level] || '\ud83d\udca1'}<\/span>\r\n                <span class=\"tdp-tip-headline\">${safeHeadline}<\/span>\r\n            <\/div>\r\n            <p class=\"tdp-tip-body\">${safeBody}<\/p>\r\n            ${ctaHTML}\r\n        <\/div>`;\r\n\r\n    \/\/ Insert after the preview section so the tip sits between the printability\r\n    \/\/ bar and the cost-breakdown row.\r\n    const label = document.getElementById('printabilityLabel_' + fileId);\r\n    const previewSection = label && label.closest('.preview-section-pro');\r\n    if (previewSection) {\r\n        previewSection.insertAdjacentHTML('afterend', html);\r\n    }\r\n}\r\n\r\n\/**\r\n * Enable \/ disable the send button for a guest card based on phone input length.\r\n *\/\r\nfunction tdpUpdateSendBtn(fileId) {\r\n    const phoneInput = document.getElementById('tdp-tip-phone-' + fileId);\r\n    const btn = document.querySelector('#tdp-design-tip-block-' + fileId + ' .tdp-tip-send-btn');\r\n    if (btn && phoneInput) {\r\n        btn.disabled = phoneInput.value.trim().length < 6;\r\n    }\r\n}\r\n\r\n\/**\r\n * Show a prominent banner message inside the tip card.\r\n * type: 'success' | 'error' | '' (clear)\r\n *\/\r\nfunction tdpShowSendMsg(msgEl, text, type) {\r\n    if (!msgEl) return;\r\n    msgEl.textContent = text;\r\n    msgEl.className   = 'tdp-tip-send-msg' + (type ? ' tdp-send-' + type : '');\r\n    msgEl.style.display = text ? 'block' : 'none';\r\n}\r\n\r\n\/**\r\n * Send printability info + contact details to the team via server-side wp_mail().\r\n * The file itself is NOT uploaded here (avoids PHP post_max_size limits).\r\n * The admin receives all contact info and can request the file directly.\r\n *\/\r\nasync function tdpSendToTeam(fileId, filename, level, levelText, tipText) {\r\n    const btn     = document.querySelector('#tdp-design-tip-block-' + fileId + ' .tdp-tip-send-btn');\r\n    const msgEl   = document.getElementById('tdp-tip-send-msg-' + fileId);\r\n    const phoneEl = document.getElementById('tdp-tip-phone-' + fileId);\r\n    const phone   = phoneEl ? phoneEl.value.trim() : '';\r\n\r\n    \/\/ Safety net: if the DOM elements are somehow missing, abort visibly.\r\n    if (!btn || !msgEl) {\r\n        console.error('[TDP send-to-team] DOM elements not found for fileId:', fileId);\r\n        return;\r\n    }\r\n\r\n    \/\/ Guest validation.\r\n    if (!tdpTips.isLoggedIn && !phone) {\r\n        tdpShowSendMsg(msgEl, tdpTips.cta_phone_required, 'error');\r\n        return;\r\n    }\r\n\r\n    \/\/ Show loading state.\r\n    btn.disabled = true;\r\n    btn.textContent = tdpTips.cta_sending;\r\n    tdpShowSendMsg(msgEl, '', '');\r\n\r\n    \/\/ Plain URLSearchParams \u2014 no binary file, no multipart, no PHP upload limits.\r\n    const params = new URLSearchParams();\r\n    params.append('action',     'tdp_send_team_email');\r\n    params.append('nonce',      tdpTips.sendNonce);\r\n    params.append('filename',   filename);\r\n    params.append('level',      level);\r\n    params.append('level_text', levelText);\r\n    params.append('tip_text',   tipText);\r\n    params.append('phone',      phone);\r\n\r\n    try {\r\n        const response = await fetch(tdpTips.ajaxUrl, {\r\n            method:      'POST',\r\n            credentials: 'same-origin',\r\n            headers:     { 'Content-Type': 'application\/x-www-form-urlencoded' },\r\n            body:        params.toString(),\r\n        });\r\n\r\n        \/\/ Read as text first so we can log unexpected non-JSON responses.\r\n        const rawText = await response.text();\r\n        let data;\r\n        try {\r\n            data = JSON.parse(rawText);\r\n        } catch (parseErr) {\r\n            console.error('[TDP send-to-team] Non-JSON server response:', rawText);\r\n            throw new Error('Server returned an unexpected response.');\r\n        }\r\n\r\n        if (data && data.success) {\r\n            if (btn) { btn.textContent = '\u2713 ' + tdpTips.cta_sent; btn.disabled = true; }\r\n            const msg = (data.data && data.data.message) ? data.data.message : tdpTips.cta_sent_detail;\r\n            tdpShowSendMsg(msgEl, msg, 'success');\r\n        } else {\r\n            if (btn) { btn.disabled = false; btn.innerHTML = '\u2709 ' + tdpEscapeHtml(tdpTips.cta_button); }\r\n            const errMsg = (data && typeof data.data === 'string' && data.data)\r\n                ? data.data : tdpTips.cta_error;\r\n            console.error('[TDP send-to-team] wp_mail error:', errMsg);\r\n            tdpShowSendMsg(msgEl, errMsg, 'error');\r\n        }\r\n    } catch (err) {\r\n        console.error('[TDP send-to-team] Fetch error:', err);\r\n        if (btn) { btn.disabled = false; btn.innerHTML = '\u2709 ' + tdpEscapeHtml(tdpTips.cta_button); }\r\n        tdpShowSendMsg(msgEl, tdpTips.cta_error, 'error');\r\n    }\r\n}\r\n<\/script><\/div>\n\t\t\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"","protected":false},"author":4,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"elementor_header_footer","meta":{"_acf_changed":false,"footnotes":""},"class_list":["post-11068","page","type-page","status-publish","hentry"],"acf":[],"_links":{"self":[{"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/pages\/11068","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/users\/4"}],"replies":[{"embeddable":true,"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/comments?post=11068"}],"version-history":[{"count":76,"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/pages\/11068\/revisions"}],"predecessor-version":[{"id":13720,"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/pages\/11068\/revisions\/13720"}],"wp:attachment":[{"href":"https:\/\/tunisia3dprint.com\/fr\/wp-json\/wp\/v2\/media?parent=11068"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}