﻿{"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\/ar\/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\">\n<style type=\"text\/css\">\n    .stl-upload-container-pro {\n        padding: 25px;\n        background: #ffffff;\n        border-radius: 8px;\n        border: 1px solid #e8e8e8;\n        border-left: 4px solid #d32f2f;\n        box-shadow: 0 2px 10px rgba(0,0,0,0.05);\n        font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\n    }\n    \n    .stl-upload-container-pro h3 {\n        text-align: left;\n        margin: 0 0 22px;\n        color: #1a1a1a;\n        font-size: 13px;\n        font-weight: 800;\n        text-transform: uppercase;\n        letter-spacing: .7px;\n        padding-bottom: 12px;\n        border-bottom: 1px solid #f0f0f0;\n    }\n    \n    .upload-area-pro {\n        border: 2px dashed #d0d5dd;\n        border-radius: 10px;\n        padding: 40px 20px;\n        text-align: center;\n        background: #fafbfc;\n        margin-bottom: 25px;\n        transition: border-color .2s, background .2s;\n    }\n\n    .upload-area-pro:hover {\n        border-color: #d32f2f;\n        background: #fff8f8;\n    }\n\n    .upload-area-pro.dragover {\n        background: #fff8f8;\n        border-color: #d32f2f;\n        border-style: solid;\n    }\n    \n    \n    .upload-button-pro {\n        display: inline-flex;\n        align-items: center;\n        gap: 8px;\n        padding: 12px 26px;\n        background: #fff;\n        color: #d32f2f;\n        border-radius: 7px;\n        font-size: 13px;\n        font-weight: 800;\n        letter-spacing: .6px;\n        text-transform: uppercase;\n        cursor: pointer;\n        transition: background .2s, color .2s, transform .15s, box-shadow .2s;\n        border: 2px solid #d32f2f;\n        box-shadow: 0 2px 8px rgba(211,47,47,.12);\n        font-family: inherit;\n    }\n\n    .upload-button-pro::before {\n        content: '';\n        display: inline-block;\n        width: 15px;\n        height: 15px;\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\");\n        background-size: contain;\n        background-repeat: no-repeat;\n        transition: background-image .2s;\n    }\n\n    .upload-button-pro:hover {\n        background: #d32f2f;\n        color: #fff;\n        transform: translateY(-1px);\n        box-shadow: 0 4px 16px rgba(211,47,47,.28);\n    }\n\n    .upload-button-pro:hover::before {\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\");\n    }\n    \n    .upload-info-pro {\n        margin-top: 15px;\n        color: #666;\n        font-size: 14px;\n    }\n    \n    .stl-file-card-pro {\n        background: #ffffff;\n        border-radius: 8px;\n        padding: 20px;\n        margin-bottom: 20px;\n        border: 2px solid #e9ecef;\n        box-shadow: 0 2px 8px rgba(0,0,0,0.08);\n        transition: all 0.3s ease;\n        position: relative;\n    }\n    \n    .file-header-pro {\n        display: flex;\n        flex-wrap: wrap;\n        justify-content: space-between;\n        align-items: center;\n        gap: 8px 12px;\n        margin-bottom: 15px;\n        padding-bottom: 10px;\n        border-bottom: 2px solid #f0f0f0;\n        position: relative;\n        padding-right: 50px; \/* reserve room for the absolutely-positioned \u00d7 button *\/\n    }\n\n    .file-name-pro {\n        font-size: 18px;\n        font-weight: 600;\n        color: #000000;\n        margin: 0;\n        flex: 1 1 auto;\n        min-width: 0;              \/* allow the name to shrink below its content width *\/\n        overflow-wrap: anywhere;  \/* break long unbroken filenames instead of overflowing *\/\n        word-break: break-word;\n    }\n\n    .file-status-pro {\n        padding: 6px 12px;\n        border-radius: 20px;\n        font-size: 12px;\n        font-weight: 600;\n        text-align: center;\n        flex: 0 0 auto;           \/* badge keeps its size; wraps below the name when cramped *\/\n        white-space: nowrap;\n    }\n    \n    .status-processing-pro {\n        background: #fff3cd;\n        color: #856404;\n    }\n    \n    .status-success-pro {\n        background: #d4edda;\n        color: #155724;\n    }\n    \n    .status-error-pro {\n        background: #f8d7da;\n        color: #721c24;\n    }\n    \n    .remove-file-pro {\n        position: absolute;\n        top: 15px;\n        right: 15px;\n        background: #dc3545;\n        color: white;\n        border: none;\n        border-radius: 50%;\n        width: 30px;\n        height: 30px;\n        cursor: pointer;\n        font-size: 16px;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        transition: all 0.3s ease;\n        z-index: 10;\n    }\n    \n    .remove-file-pro:hover {\n        background: #c82333;\n        transform: scale(1.1);\n    }\n    \n    .preview-section-pro {\n        display: grid;\n        grid-template-columns: 4fr 1fr;\n        gap: 15px;\n        margin: 20px 0;\n    }\n    \n    .viewer-container-pro {\n        height: 320px;\n        border: 2px solid #e9ecef;\n        border-radius: 6px;\n        background: #f8f9fa;\n        position: relative;\n        overflow: hidden;\n    }\n    \n    .viewer-placeholder-pro {\n        width: 100%;\n        height: 100%;\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        justify-content: center;\n        text-align: center;\n        color: #666;\n        padding: 20px;\n    }\n    \n    .info-panel-pro {\n        background: #f8f9fa;\n        padding: 12px;\n        border-radius: 6px;\n        border: 2px solid #e9ecef;\n        display: flex;\n        flex-direction: column;\n        gap: 10px;\n        min-width: 200px;\n        height: 320px;\n        overflow-y: auto;\n    }\n    \n    .dimension-box-pro {\n        background: #f8f9fa;\n        padding: 10px;\n        border-radius: 6px;\n        border-left: 4px solid #000000;\n        font-size: 12px;\n        border: 2px solid #e9ecef;\n    }\n    \n    .volume-box-pro {\n        background: #f8f9fa;\n        padding: 12px;\n        border-radius: 6px;\n        text-align: center;\n        border-left: 4px solid #28a745;\n        border: 2px solid #e9ecef;\n    }\n    \n    .volume-value-pro {\n        font-size: 20px;\n        font-weight: 700;\n        color: #000000;\n        margin: 5px 0;\n    }\n    \n    .settings-grid-pro {\n        display: grid;\n        grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));\n        gap: 10px;\n        margin-top: 20px;\n    }\n    \n    .form-group-pro {\n        display: flex;\n        flex-direction: column;\n        position: relative;\n    }\n    \n    .form-group-pro label {\n        font-size: 11px;\n        font-weight: 600;\n        color: #000000;\n        margin-bottom: 4px;\n    }\n    \n    .form-group-pro select,\n    .form-group-pro input {\n        padding: 8px 10px;\n        border: 2px solid #e9ecef;\n        border-radius: 6px;\n        font-size: 12px;\n        background: #ffffff;\n        color: #000000;\n    }\n    \n    .form-group-pro select {\n        appearance: none;\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\");\n        background-repeat: no-repeat;\n        background-position: right 8px center;\n        background-size: 16px;\n        \/* Reserve room for the arrow on both sides \u2014 in RTL the text starts from the right\n           edge and would otherwise collide with the arrow (e.g. PLA in the Material select\n           on Arabic). 30px = 16px arrow width + 8px right offset + 6px gap. *\/\n        padding-right: 30px;\n    }\n    \/* RTL: move the arrow to the visual LEFT (end of the RTL text flow) for proper UX,\n       and swap the padding side so the dropdown reads naturally on Arabic pages. *\/\n    [dir=\"rtl\"] .form-group-pro select,\n    .rtl .form-group-pro select {\n        background-position: left 8px center;\n        padding-right: 10px;\n        padding-left: 30px;\n    }\n    \n    .form-group-pro input[readonly] {\n        background: #f8f9fa;\n        font-weight: 600;\n    }\n    \n    .cost-breakdown-pro {\n        background: #f8f9fa;\n        padding: 15px;\n        border-radius: 8px;\n        margin: 15px 0;\n        border: 2px solid #e9ecef;\n    }\n    \n    .cost-item-pro {\n        display: flex;\n        justify-content: space-between;\n        padding: 5px 0;\n        font-size: 12px;\n        border-bottom: 1px solid #e9ecef;\n    }\n    \n    .cost-item-pro:last-child {\n        border-bottom: none;\n        font-weight: 600;\n        color: #dc3545;\n    }\n    \n    .total-section-pro {\n        background: #fff;\n        padding: 22px 24px;\n        border-radius: 10px;\n        margin: 25px 0;\n        text-align: left;\n        border: 1px solid #e8e8e8;\n        border-left: 4px solid #d32f2f;\n        box-shadow: 0 2px 10px rgba(0,0,0,.05);\n    }\n\n    \/* label: \"Total Project Cost (incl. tax)\"\n       \u26a0\ufe0f This used to be `.total-section-pro > div:first-child`, which broke the day\n       the exact figures were wrapped in #tdpExactTotalPro for the price gate \u2014 the\n       wrapper became the first child and inherited the label's styling. Targeting the\n       element by its own id says what it means and cannot be moved out from under. *\/\n    #totalProjectCostLabel {\n        font-size: 11px !important;\n        font-weight: 800 !important;\n        text-transform: uppercase;\n        letter-spacing: .6px;\n        color: #d32f2f !important;\n        margin-bottom: 4px !important;\n    }\n\n    .total-amount-pro {\n        font-size: 30px;\n        font-weight: 900;\n        margin: 4px 0 8px;\n        letter-spacing: -.5px;\n        color: #1a1a1a;\n    }\n\n    \/* \u2500\u2500 The price gate \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n       Shown to a logged-out visitor in place of the exact figures. Deliberately\n       quiet: one red element on the whole card (the unlock button), the amount in\n       near-black like the real total it stands in for, so the page does not read as\n       an advertisement for itself. *\/\n\n    \/* Both halves need an explicit rule: [hidden] is only a user-agent default and\n       a single `display:block` from the theme or Elementor overrides it, which\n       would show the exact figures and the teaser at the same time. *\/\n    .tdp-verify-notice-pro {\n        margin: 0 0 14px;\n        padding: 10px 12px;\n        border: 1px solid #e8e8e8;\n        border-left: 4px solid #1a7f4b;   \/* success, not alarm: the account exists *\/\n        border-radius: 6px;\n        background: #f6fbf8;\n        font-size: 12px;\n        line-height: 1.5;\n        color: #1a1a1a;\n    }\n\n    .tdp-verify-notice-pro[hidden] { display: none !important; }\n\n    .tdp-teaser-pro[hidden],\n    #tdpExactTotalPro[hidden] { display: none !important; }\n\n    .tdp-teaser-label-pro {\n        font-size: 11px;\n        font-weight: 800;\n        text-transform: uppercase;\n        letter-spacing: .6px;\n        color: #6b6b6b;\n        margin-bottom: 4px;\n    }\n\n    .tdp-teaser-amount-pro {\n        font-size: 30px;\n        font-weight: 900;\n        margin: 4px 0 6px;\n        letter-spacing: -.5px;\n        color: #1a1a1a;\n    }\n\n    \/* The dash carries its own spacing so the two figures never touch, and is a\n       little lighter than the numbers so the range reads as one value, not two. *\/\n    .tdp-teaser-dash-pro { color: #8a8a8a; font-weight: 700; }\n\n    .tdp-teaser-vary-pro {\n        font-size: 12px;\n        line-height: 1.5;\n        color: #6b6b6b;\n        margin-bottom: 14px;\n    }\n\n    \/* The frozen settings. A disabled control with no explanation reads as a bug,\n       so every card says why \u2014 and the row is muted rather than struck through,\n       because the values shown (PLA \/ Standard) are the real ones being priced. *\/\n    \/* The low-quality note. Muted grey and normal weight ON PURPOSE: it is guidance\n       about what the setting is best for, not an alarm. It was red and bold for one\n       revision and read as though the cheap option were dangerous, which is not true\n       \u2014 function is affected only in limited cases. *\/\n    .tdp-lowq-warn-pro {\n        grid-column: 1 \/ -1;\n        margin-top: -2px;\n        font-size: 11.5px;\n        line-height: 1.45;\n        color: #6b6b6b;\n    }\n\n    .tdp-lowq-warn-pro[hidden] { display: none !important; }\n\n    .tdp-locked-opts-pro {\n        grid-column: 1 \/ -1;\n        display: flex;\n        align-items: center;\n        gap: 6px;\n        margin-top: -4px;\n        font-size: 11.5px;\n        line-height: 1.45;\n        color: #8a8a8a;\n    }\n\n    .settings-grid-pro select:disabled {\n        background-color: #f8f9fa;\n        color: #6b6b6b;\n        cursor: not-allowed;\n        opacity: 1;              \/* Safari greys disabled controls to near-invisible *\/\n    }\n\n    .tdp-unlock-pro {\n        display: block;\n        width: 100%;\n        box-sizing: border-box;\n        padding: 13px 18px;\n        border-radius: 6px;\n        background: #c0392b;\n        color: #ffffff !important;\n        font-size: 13px;\n        font-weight: 700;\n        line-height: 1.35;\n        text-align: center;\n        text-decoration: none !important;\n        cursor: pointer;\n    }\n\n    .tdp-unlock-pro:hover { background: #a93226; color: #ffffff !important; }\n\n    .tdp-teaser-note-pro {\n        margin-top: 8px;\n        font-size: 11.5px;\n        line-height: 1.5;\n        color: #8a8a8a;\n    }\n\n    \/* The per-file card's cost box while the gate is on. One muted line, no button:\n       ten file cards each shouting the same call to action would be noise, and the\n       card is not where the decision is taken. *\/\n    .tdp-locked-line-pro {\n        display: flex;\n        align-items: center;\n        gap: 6px;\n        padding: 5px 0;\n        font-size: 12px;\n        color: #8a8a8a;\n    }\n\n\n    .submit-section-pro {\n        text-align: center;\n    }\n    \n    .submit-btn-pro {\n        padding: 14px 32px;\n        background: #d32f2f;\n        color: #fff;\n        border: 2px solid #d32f2f;\n        border-radius: 7px;\n        font-size: 13px;\n        font-weight: 800;\n        letter-spacing: .6px;\n        text-transform: uppercase;\n        cursor: pointer;\n        margin: 8px 5px;\n        transition: background .2s, transform .15s, box-shadow .2s;\n        box-shadow: 0 4px 14px rgba(211,47,47,.22);\n    }\n\n    .cart-btn-pro {\n        padding: 14px 32px;\n        background: #fff;\n        color: #1a1a1a;\n        border: 2px solid #1a1a1a;\n        border-radius: 7px;\n        font-size: 13px;\n        font-weight: 800;\n        letter-spacing: .6px;\n        text-transform: uppercase;\n        cursor: pointer;\n        margin: 8px 5px;\n        transition: background .2s, color .2s, transform .15s, box-shadow .2s;\n    }\n\n    .submit-btn-pro:hover {\n        background: #b71c1c;\n        border-color: #b71c1c;\n        transform: translateY(-1px);\n        box-shadow: 0 6px 20px rgba(211,47,47,.32);\n    }\n\n    .cart-btn-pro:hover {\n        background: #1a1a1a;\n        color: #fff;\n        transform: translateY(-1px);\n        box-shadow: 0 6px 20px rgba(0,0,0,.18);\n    }\n\n    .submit-btn-pro:disabled,\n    .cart-btn-pro:disabled {\n        background: #f0f0f0;\n        color: #aaa;\n        border-color: #e0e0e0;\n        cursor: not-allowed;\n        transform: none;\n        box-shadow: none;\n    }\n    \n    .cart-btn-pro.loading {\n        background: #f0f0f0 !important;\n        color: #aaa !important;\n        border-color: #e0e0e0 !important;\n        cursor: not-allowed !important;\n        position: relative;\n    }\n    \n    .cart-btn-pro.loading::after {\n        content: '';\n        position: absolute;\n        top: 50%;\n        left: 50%;\n        margin: -8px 0 0 -8px;\n        width: 16px;\n        height: 16px;\n        border: 2px solid transparent;\n        border-top: 2px solid #ffffff;\n        border-radius: 50%;\n        animation: spin 1s linear infinite;\n    }\n    \n    @keyframes spin {\n        0% { transform: rotate(0deg); }\n        100% { transform: rotate(360deg); }\n    }\n    \n    .file-counter-pro {\n        text-align: center;\n        margin: 10px 0;\n        font-size: 14px;\n        color: #666;\n    }\n    \n    .contact-section-pro {\n        text-align: center;\n        margin-top: 20px;\n        padding: 16px 20px;\n        background: #fafbfc;\n        border-radius: 8px;\n        border: 1px solid #e8e8e8;\n    }\n\n    .contact-section-label-pro {\n        display: block;\n        font-size: 11px;\n        font-weight: 800;\n        text-transform: uppercase;\n        letter-spacing: .6px;\n        color: #999;\n        margin-bottom: 10px;\n    }\n\n    .contact-btn-pro {\n        display: inline-block;\n        padding: 9px 18px;\n        background: #fff;\n        color: #d32f2f;\n        text-decoration: none;\n        border-radius: 6px;\n        font-size: 13px;\n        font-weight: 700;\n        margin: 4px;\n        transition: background .2s, color .2s, transform .15s;\n        border: 1.5px solid #d32f2f;\n    }\n\n    .contact-btn-pro:hover {\n        background: #d32f2f;\n        color: #fff;\n        transform: translateY(-1px);\n        text-decoration: none;\n    }\n\n    .contact-btn-pro.whatsapp-btn-pro {\n        color: #25a244;\n        border-color: #25a244;\n    }\n\n    .contact-btn-pro.whatsapp-btn-pro:hover {\n        background: #25a244;\n        color: #fff;\n    }\n\n    .printability-bar-pro {\n        width: 100%;\n        height: 6px;\n        background: #e9ecef;\n        border-radius: 4px;\n        margin: 6px 0;\n        overflow: hidden;\n    }\n    \n    .printability-fill-pro {\n        height: 100%;\n        border-radius: 4px;\n        transition: all 0.3s ease;\n    }\n    \n    .printability-excellent-pro {\n        background: #28a745;\n        width: 90%;\n    }\n    .printability-good-pro {\n        background: #ffc107;\n        width: 70%;\n    }\n    .printability-fair-pro {\n        background: #fd7e14;\n        width: 50%;\n    }\n    .printability-poor-pro {\n        background: #dc3545;\n        width: 30%;\n    }\n    \n    .printability-label-pro {\n        font-size: 10px;\n        font-weight: 600;\n        text-align: center;\n        margin-top: 4px;\n    }\n    \n    .printability-excellent-text-pro {\n        color: #28a745;\n    }\n    .printability-good-text-pro {\n        color: #ffc107;\n    }\n    .printability-fair-text-pro {\n        color: #fd7e14;\n    }\n    .printability-poor-text-pro {\n        color: #dc3545;\n    }\n    .printability-unreadable-text-pro {\n        color: #6b6b6b;\n        font-style: italic;\n    }\n    \n    .quotation-modal-pro {\n        display: none;\n        position: fixed;\n        top: 0; left: 0;\n        width: 100%; height: 100%;\n        background: rgba(0,0,0,.6);\n        z-index: 1000;\n        align-items: flex-start;\n        justify-content: center;\n        padding: 20px 0;\n        overflow-y: auto;\n    }\n\n    .quotation-content-pro {\n        background: #fff;\n        border-radius: 10px;\n        max-width: 860px;\n        width: 95%;\n        box-shadow: 0 20px 60px rgba(0,0,0,.35);\n        overflow: hidden;\n    }\n\n    .quote-modal-actions {\n        display: flex;\n        justify-content: space-between;\n        align-items: center;\n        padding: 14px 20px;\n        background: #f8f8f8;\n        border-bottom: 1px solid #e0e0e0;\n    }\n\n    .quote-action-btn {\n        display: inline-flex;\n        align-items: center;\n        gap: 6px;\n        padding: 9px 18px;\n        border: none;\n        border-radius: 6px;\n        font-size: 13px;\n        font-weight: 700;\n        cursor: pointer;\n        transition: background .2s;\n    }\n\n    .quote-print-btn {\n        background: #1a1a1a;\n        color: #fff;\n    }\n\n    .quote-print-btn:hover { background: #d32f2f; }\n\n    .close-quotation-pro {\n        background: none;\n        color: #666;\n        border: 1.5px solid #ccc;\n        border-radius: 6px;\n        padding: 8px 14px;\n        font-size: 13px;\n        font-weight: 700;\n        cursor: pointer;\n        transition: border-color .2s, color .2s;\n    }\n\n    .close-quotation-pro:hover { border-color: #d32f2f; color: #d32f2f; }\n\n    \/*\n     * Bottom bar: both decisions live at the foot of the document, where the customer\n     * finishes reading (owner, 2026-08-19: \"move close to the bottom close to add to\n     * cart\"). The top bar keeps Print only.\n     *\n     * \u26a0\ufe0f Add to cart is the primary action and Close the way out, so they are\n     * separated rather than sat side by side \u2014 an accidental Close after reading a\n     * quotation costs the customer the whole flow.\n     *\/\n    .quote-modal-actions--bottom {\n        border-bottom: none;\n        border-top: 1px solid #e0e0e0;\n        justify-content: space-between;\n        gap: 16px;\n        flex-wrap: wrap;\n    }\n\n    .quote-cart-btn {\n        background: #0a0a0a;\n        color: #fff;\n        padding: 12px 34px;\n        font-size: 14px;\n    }\n\n    .quote-cart-btn:hover:not(:disabled) { background: #d32f2f; }\n\n    .quote-cart-btn:disabled {\n        opacity: .55;\n        cursor: not-allowed;\n    }\n\n    #quotationContentPro {\n        padding: 32px 36px;\n    }\n    \n    .alert-message-pro {\n        padding: 12px 15px;\n        border-radius: 6px;\n        margin: 10px 0;\n        font-weight: 600;\n        border: 2px solid;\n    }\n    \n    .alert-success-pro {\n        background: #d4edda;\n        color: #155724;\n        border-color: #c3e6cb;\n    }\n    \n    .alert-error-pro {\n        background: #f8d7da;\n        color: #721c24;\n        border-color: #f5c6cb;\n    }\n    \n    .info-row-pro {\n        display: flex;\n        justify-content: space-between;\n        font-size: 11px;\n        padding: 3px 0;\n    }\n    \n    .info-label-pro {\n        color: #666;\n    }\n    \n    .info-value-pro {\n        font-weight: 600;\n        color: #000000;\n    }\n    \n    @media (max-width: 768px) {\n        .preview-section-pro {\n            grid-template-columns: 1fr;\n        }\n        .settings-grid-pro {\n            grid-template-columns: 1fr;\n        }\n        .contact-btn-pro {\n            display: block;\n            margin: 5px auto;\n            max-width: 200px;\n        }\n        .submit-btn-pro,\n        .cart-btn-pro {\n            display: block;\n            width: 100%;\n            margin: 5px 0;\n        }\n        .file-header-pro {\n            padding-right: 44px; \/* room for the \u00d7 button on small screens *\/\n        }\n        .file-name-pro {\n            font-size: 16px;\n            flex-basis: 100%; \/* filename takes the full row, badge drops neatly below *\/\n        }\n    }\n    \n    \/* print handled via JS new-window \u2014 no @media print needed here *\/\n\n    \/* Guest Capture Modal *\/\n    .guest-capture-modal-pro {\n        display: none;\n        position: fixed;\n        top: 0;\n        left: 0;\n        width: 100%;\n        height: 100%;\n        background: rgba(0,0,0,0.5);\n        z-index: 1100;\n        align-items: center;\n        justify-content: center;\n    }\n\n    .guest-capture-content-pro {\n        background: white;\n        padding: 30px;\n        border-radius: 10px;\n        max-width: 460px;\n        width: 90%;\n        box-shadow: 0 10px 30px rgba(0,0,0,0.3);\n    }\n\n    .guest-capture-content-pro h3 {\n        margin-top: 0;\n        color: #dc3545;\n        font-size: 20px;\n    }\n\n    .form-group-pro {\n        margin-bottom: 15px;\n    }\n\n    .form-group-pro label {\n        display: block;\n        font-weight: 600;\n        margin-bottom: 5px;\n        color: #333;\n        font-size: 14px;\n    }\n\n    .form-group-pro input[type=\"text\"],\n    .form-group-pro input[type=\"email\"],\n    .form-group-pro input[type=\"tel\"] {\n        width: 100%;\n        padding: 10px 12px;\n        border: 2px solid #e9ecef;\n        border-radius: 6px;\n        font-size: 14px;\n        box-sizing: border-box;\n        transition: border-color 0.2s;\n    }\n\n    .form-group-pro input:focus {\n        border-color: #dc3545;\n        outline: none;\n    }\n\n    .close-btn {\n        padding: 10px 20px;\n        background: #6c757d;\n        color: white;\n        border: none;\n        border-radius: 6px;\n        font-size: 14px;\n        cursor: pointer;\n        margin-left: 10px;\n        transition: background 0.2s;\n    }\n\n    .close-btn:hover {\n        background: #5a6268;\n    }\n\n    \/* Rate limit banner *\/\n    .rate-limit-banner-pro {\n        background: #fff3cd;\n        border: 2px solid #ffc107;\n        border-radius: 6px;\n        padding: 12px 15px;\n        margin: 10px 0;\n        color: #856404;\n        font-weight: 600;\n        display: none;\n    }\n\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 *\/\n    .ppp-page {\n        max-width: 1200px;\n        margin: 0 auto;\n        padding: 0 20px 60px;\n        font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;\n        color: #2d2d2d;\n    }\n\n    .ppp-hero {\n        padding: 40px 0 32px;\n        text-align: center;\n    }\n\n    .ppp-hero-title {\n        font-size: 32px;\n        font-weight: 900;\n        color: #1a1a1a;\n        margin: 0 0 10px;\n        letter-spacing: -.5px;\n        line-height: 1.1;\n    }\n\n    .ppp-hero-title span { color: #d32f2f; }\n\n    .ppp-hero-sub {\n        font-size: 15px;\n        color: #777;\n        margin: 0 auto;\n        max-width: 560px;\n        line-height: 1.6;\n    }\n\n    .ppp-layout {\n        display: grid;\n        grid-template-columns: 1fr 292px;\n        gap: 28px;\n        align-items: start;\n    }\n\n    .ppp-aside {\n        position: sticky;\n        top: 90px;\n        display: flex;\n        flex-direction: column;\n        gap: 16px;\n    }\n\n    .ppp-info-block {\n        background: #fff;\n        border: 1px solid #eee;\n        border-left: 4px solid #d32f2f;\n        border-radius: 10px;\n        padding: 16px 18px;\n        box-shadow: 0 2px 8px rgba(0,0,0,.05);\n    }\n\n    .ppp-info-block-blue   { border-left-color: #1976D2; }\n    .ppp-info-block-green  { border-left-color: #4CAF50; }\n    .ppp-info-block-orange { border-left-color: #f57c00; }\n\n    .ppp-info-block-header {\n        display: flex;\n        align-items: center;\n        gap: 8px;\n        margin-bottom: 10px;\n        padding-bottom: 8px;\n        border-bottom: 1px solid #f0f0f0;\n    }\n\n    .ppp-info-block-header .ppp-icon { font-size: 16px; line-height: 1; }\n\n    .ppp-info-block h4 {\n        font-size: 11px;\n        font-weight: 800;\n        text-transform: uppercase;\n        letter-spacing: .6px;\n        color: #d32f2f;\n        margin: 0;\n    }\n\n    .ppp-info-block-blue   h4 { color: #1565c0; }\n    .ppp-info-block-green  h4 { color: #388e3c; }\n    .ppp-info-block-orange h4 { color: #e65100; }\n\n    .ppp-info-block p {\n        font-size: 13px;\n        color: #666;\n        margin: 0;\n        line-height: 1.6;\n    }\n\n    .ppp-info-block ul {\n        margin: 0;\n        padding: 0 0 0 16px;\n    }\n\n    .ppp-info-block li {\n        font-size: 13px;\n        color: #666;\n        padding: 3px 0;\n        line-height: 1.5;\n    }\n\n    @media (max-width: 960px) {\n        .ppp-layout { grid-template-columns: 1fr; }\n        .ppp-aside  { position: static; }\n    }\n\n    @media (max-width: 700px) {\n        .ppp-hero-title { font-size: 24px; }\n        .ppp-hero { padding: 24px 0 20px; }\n    }\n\n    \/* === TDP Design Tips === *\/\n    .tdp-design-tip {\n        margin: 12px 0 8px 0;\n        padding: 14px 16px;\n        border-radius: 8px;\n        border-left: 4px solid #e0a800;\n        background: #fffdf0;\n        font-size: 14px;\n        line-height: 1.6;\n    }\n    .tdp-tip-excellent { border-left-color: #28a745; background: #f0fff4; }\n    .tdp-tip-good      { border-left-color: #17a2b8; background: #f0faff; }\n    .tdp-tip-fair      { border-left-color: #e0a800; background: #fffdf0; }\n    .tdp-tip-poor      { border-left-color: #dc3545; background: #fff5f5; }\n\n    .tdp-tip-header {\n        display: flex;\n        align-items: center;\n        gap: 8px;\n        margin-bottom: 6px;\n        font-weight: 600;\n        font-size: 14px;\n    }\n    .tdp-tip-body { margin: 0 0 10px 0; color: #444; }\n\n    .tdp-tip-cta { margin-top: 12px; padding-top: 10px; border-top: 1px solid rgba(0,0,0,0.08); }\n    .tdp-tip-cta-title { font-weight: 600; margin: 0 0 8px 0; }\n    .tdp-tip-phone-label { display: block; font-size: 13px; margin-bottom: 4px; color: #555; }\n    .tdp-tip-phone-input {\n        width: 100%;\n        padding: 7px 10px;\n        border: 1px solid #ddd;\n        border-radius: 5px;\n        font-size: 13px;\n        margin-bottom: 10px;\n        box-sizing: border-box;\n    }\n    .tdp-tip-guest-note {\n        font-size: 13px;\n        color: #666;\n        margin: 0 0 8px 0;\n    }\n    .tdp-tip-send-btn {\n        display: inline-block;\n        padding: 8px 18px;\n        background: #333;\n        color: #fff;\n        border: none;\n        border-radius: 5px;\n        font-size: 13px;\n        cursor: pointer;\n        transition: background .2s, opacity .2s;\n    }\n    .tdp-tip-send-btn:hover:not(:disabled) { background: #555; }\n    .tdp-tip-send-btn:disabled { opacity: .45; cursor: default; }\n    .tdp-tip-send-msg {\n        display: none;\n        margin-top: 10px;\n        padding: 10px 14px;\n        border-radius: 6px;\n        font-size: 13px;\n        font-weight: 600;\n        line-height: 1.4;\n    }\n    .tdp-tip-send-msg.tdp-send-success {\n        background: #eafaf1;\n        border: 1px solid #a9dfbf;\n        color: #1a7f4b;\n    }\n    .tdp-tip-send-msg.tdp-send-error {\n        background: #fdf3f2;\n        border: 1px solid #f5b7b1;\n        color: #c0392b;\n    }\n\n    \/* RTL support for Arabic *\/\n    [lang=\"ar\"] .tdp-design-tip { direction: rtl; text-align: right; }\n    [lang=\"ar\"] .tdp-tip-header { flex-direction: row-reverse; }\n    [lang=\"ar\"] .tdp-design-tip { border-left: none; border-right: 4px solid #e0a800; }\n    [lang=\"ar\"] .tdp-tip-excellent { border-right-color: #28a745; }\n    [lang=\"ar\"] .tdp-tip-good      { border-right-color: #17a2b8; }\n    [lang=\"ar\"] .tdp-tip-fair      { border-right-color: #e0a800; }\n    [lang=\"ar\"] .tdp-tip-poor      { border-right-color: #dc3545; }\n\n    \/*\n     * Staff \"quote a client's email request\" tool \u2014 see\n     * TDP_Admin_Page::display_admin_quote_form(). No order or cart is ever created\n     * here, so both Add-to-cart buttons stay HIDDEN, never removed from the DOM:\n     * elements.cartBtn.addEventListener() (init(), no null-guard) would throw on a\n     * missing #addToCartPro and break every other listener registered after it.\n     *\/\n    .tdp-admin-quote-mode #addToCartPro,\n    .tdp-admin-quote-mode #quoteAddToCartPro,\n    .tdp-admin-quote-mode .contact-section-pro { display: none !important; }\n    .tdp-admin-client-pro {\n        background: #fff; border: 1px solid #e8e8e8; border-left: 4px solid #c0392b;\n        border-radius: 8px; padding: 18px 20px; margin: 0 0 20px;\n    }\n    .tdp-admin-client-pro h4 { margin: 0 0 4px; font-size: .95rem; font-weight: 800; color: #0a0a0a; }\n    .tdp-admin-client-pro p  { margin: 0 0 14px; font-size: .82rem; color: #6b6b6b; }\n    .tdp-admin-client-row    { display: flex; flex-wrap: wrap; gap: 12px; }\n    .tdp-admin-client-field  { flex: 1 1 200px; min-width: 0; }\n    .tdp-admin-client-field label {\n        display: block; font-size: .72rem; font-weight: 700; text-transform: uppercase;\n        letter-spacing: .05em; color: #6b6b6b; margin-bottom: 5px;\n    }\n    .tdp-admin-client-field input {\n        width: 100%; box-sizing: border-box; padding: 9px 11px;\n        border: 1px solid #e0e0e0; border-radius: 6px; font-size: .9rem;\n    }\n<\/style>\n\n<!-- Modal pour le devis -->\n<div class=\"quotation-modal-pro\" id=\"quotationModalPro\">\n    <div class=\"quotation-content-pro\">\n        <div class=\"quote-modal-actions\">\n            <button class=\"quote-action-btn quote-print-btn\" onclick=\"printQuotePro()\">\n                \ud83d\udda8\ufe0f \u0637\u0628\u0627\u0639\u0629 \/ \u062d\u0641\u0638 PDF            <\/button>\n        <\/div>\n        <div id=\"quotationContentPro\"><\/div>\n\n        <!--\n          Bottom action bar. \u26a0\ufe0f It sits OUTSIDE #ppp-quote-doc on purpose:\n          printQuotePro() clones that element into a print window, so a button placed\n          inside it would be printed onto the customer's quotation.\n        -->\n        <div class=\"quote-modal-actions quote-modal-actions--bottom\">\n            <!--\n              \u26a0\ufe0f ORDER MATTERS, AND IT IS RTL-CORRECT BY CONSTRUCTION.\n              Add to cart is LAST in the DOM so it lands on the trailing edge \u2014 the\n              right in English and French, and automatically the LEFT on \/ar\/, because\n              flexbox mirrors under dir=\"rtl\". That is the convention in both scripts:\n              the primary action sits where the reader's eye and hand finish.\n              Do NOT \"fix\" this with an [dir=\"rtl\"] override; that would un-mirror it.\n            -->\n            <button class=\"close-quotation-pro\" onclick=\"closeQuotationPro()\">\u2715 \u0625\u063a\u0644\u0627\u0642<\/button>\n            <button class=\"quote-action-btn quote-cart-btn\" id=\"quoteAddToCartPro\">\n                \ud83d\uded2 \u0623\u0636\u0641 \u0625\u0644\u0649 \u0627\u0644\u0633\u0644\u0629            <\/button>\n        <\/div>\n    <\/div>\n<\/div>\n\n<div class=\"ppp-page\">\n\n    <!-- PAGE HERO -->\n    <div class=\"ppp-hero\">\n        <h1 class=\"ppp-hero-title\">\u062d\u0645\u0651\u0644 \u0648\u0627\u0637\u0644\u0628 <span>\u062a\u0635\u0645\u064a\u0645\u0643 \u0627\u0644\u0645\u0637\u0628\u0648\u0639 \u062b\u0644\u0627\u062b\u064a \u0627\u0644\u0623\u0628\u0639\u0627\u062f<\/span><\/h1>\n        <p class=\"ppp-hero-sub\">\u062e\u062f\u0645\u0627\u062a \u0637\u0628\u0627\u0639\u0629 3D \u0627\u062d\u062a\u0631\u0627\u0641\u064a\u0629 \u0628\u0623\u0633\u0639\u0627\u0631 \u062a\u0646\u0627\u0641\u0633\u064a\u0629 \u0648\u0625\u0646\u062c\u0627\u0632 \u0633\u0631\u064a\u0639. \u062d\u0645\u0651\u0644 \u0645\u0644\u0641\u0627\u062a STL \u0627\u0644\u062e\u0627\u0635\u0629 \u0628\u0643 \u0644\u0644\u062d\u0635\u0648\u0644 \u0639\u0644\u0649 \u0639\u0631\u0636 \u0633\u0639\u0631 \u0641\u0648\u0631\u064a.<\/p>\n    <\/div>\n\n    \n    <div class=\"ppp-layout\">\n\n        <!-- MAIN FORM COLUMN -->\n        <main class=\"ppp-main\">\n            <div class=\"stl-upload-container-pro\">\n                <h3>\u062d\u0627\u0633\u0628\u0629 \u0639\u0631\u0648\u0636 \u0623\u0633\u0639\u0627\u0631 \u0637\u0628\u0627\u0639\u0629 3D<\/h3>\n    \n    <div class=\"upload-area-pro\" id=\"uploadAreaPro\">\n        <input type=\"file\" id=\"stlFilesPro\" accept=\".stl,.obj\" multiple style=\"display:none;\" \/>\n        <button type=\"button\" class=\"upload-button-pro\" onclick=\"document.getElementById('stlFilesPro').click()\">\n            \u062d\u0645\u0651\u0644 \u0645\u0644\u0641\u0627\u062a\u0643 \u062b\u0644\u0627\u062b\u064a\u0629 \u0627\u0644\u0623\u0628\u0639\u0627\u062f        <\/button>\n        <div class=\"upload-info-pro\">\n            \u0627\u062e\u062a\u0631 \u062d\u062a\u0649 10 \u0645\u0644\u0641\u0627\u062a (STL, OBJ) \u2022 \u0627\u0644\u062d\u062c\u0645 \u0627\u0644\u0623\u0642\u0635\u0649 100 \u0645\u064a\u063a\u0627\u0628\u0627\u064a\u062a \u0644\u0644\u0645\u0644\u0641 \u2022 200 \u0645\u064a\u063a\u0627\u0628\u0627\u064a\u062a \u0625\u062c\u0645\u0627\u0644\u0627\u064b        <\/div>\n    <\/div>\n    \n    <div id=\"alertContainerPro\"><\/div>\n    <div class=\"rate-limit-banner-pro\" id=\"rateLimitBannerPro\"><\/div>\n    <div class=\"file-counter-pro\" id=\"fileCounterPro\"><\/div>\n    <div id=\"stlFilesListPro\"><\/div>\n    \n    <div class=\"total-section-pro\">\n                                    <div class=\"tdp-verify-notice-pro\" id=\"tdpVerifyNoticePro\" hidden><\/div>\n        \n        <div id=\"tdpExactTotalPro\" hidden>\n            <div id=\"totalProjectCostLabel\">\u0627\u0644\u062a\u0643\u0644\u0641\u0629 \u0627\u0644\u0625\u062c\u0645\u0627\u0644\u064a\u0629 \u0644\u0644\u0645\u0634\u0631\u0648\u0639 (\u0634\u0627\u0645\u0644\u0629 \u0627\u0644\u0636\u0631\u064a\u0628\u0629)<\/div>\n            <div class=\"total-amount-pro\"><span id=\"totalPricePro\">0.000<\/span> TND<\/div>\n            <div style=\"font-size: 12px; color: #888; margin-top: 4px;\">\n                <div id=\"priceBreakdownPro\">\n                    <!-- The label is in a span so JS can swap it from i18n.totalHT (raw .mo read,\n                         no TRP gettext interception). Same trick prevents the same string from\n                         showing up in two languages on one page (static HTML vs cost-item-pro). -->\n                    <div><span id=\"breakdownHTLabel\">Total excl. tax:<\/span> <span id=\"breakdownHT\">0.000 TND<\/span><\/div>\n                <\/div>\n            <\/div>\n        <\/div>\n\n                                <div class=\"tdp-teaser-pro\" id=\"tdpTeaserPro\" hidden>\n                <div class=\"tdp-teaser-label-pro\">\u0646\u0637\u0627\u0642 \u0627\u0644\u0633\u0639\u0631 \u0627\u0644\u062a\u0642\u062f\u064a\u0631\u064a<\/div>\n                                <div class=\"tdp-teaser-amount-pro\">\u2248 <bdi dir=\"ltr\"><span id=\"tdpTeaserMinPro\" class=\"notranslate\" translate=\"no\" data-no-translation>--<\/span><span class=\"tdp-teaser-dash-pro notranslate\" translate=\"no\" data-no-translation> \u2013 <\/span><span id=\"tdpTeaserMaxPro\" class=\"notranslate\" translate=\"no\" data-no-translation>--<\/span><\/bdi> TND<\/div>\n                <div class=\"tdp-teaser-vary-pro\">\u062d\u0633\u0628 \u0627\u0644\u0645\u0627\u062f\u0629 \u0648\u0627\u0644\u062c\u0648\u062f\u0629 \u0627\u0644\u062a\u064a \u0633\u062a\u062e\u062a\u0627\u0631\u0647\u0627.<\/div>\n                <a class=\"tdp-unlock-pro\" id=\"tdpUnlockPro\"\n                   href=\"https:\/\/tunisia3dprint.com\/ar\/login\/?mas_redirect=https%3A%2F%2Ftunisia3dprint.com%2Far%2Fwp-json%2Fwp%2Fv2%2Fpages%2F11068\">\n                    \u0633\u062c\u0651\u0644 \u0627\u0644\u062f\u062e\u0648\u0644 \u0644\u0645\u0639\u0631\u0641\u0629 \u0627\u0644\u0633\u0639\u0631 \u0627\u0644\u062f\u0642\u064a\u0642 \u0648\u0627\u062e\u062a\u064a\u0627\u0631 \u0627\u0644\u0645\u0627\u062f\u0629 \u0648\u0627\u0644\u062c\u0648\u062f\u0629                <\/a>\n                <div class=\"tdp-teaser-note-pro\">\u0645\u062c\u0627\u0646\u0627\u064b\u060c \u0646\u0642\u0631\u062a\u0627\u0646 \u0641\u0642\u0637 \u2014 \u0645\u0644\u0641\u0627\u062a\u0643 \u062a\u0628\u0642\u0649 \u0647\u0646\u0627.<\/div>\n            <\/div>\n            <\/div>\n\n    <div class=\"submit-section-pro\">\n        <button class=\"submit-btn-pro\" id=\"submitStlOrderPro\" disabled>\n            \u0639\u0631\u0636 \u0627\u0644\u062a\u0633\u0639\u064a\u0631\u0629 \u0627\u0644\u062a\u0641\u0635\u064a\u0644\u064a\u0629        <\/button>\n\n        <button class=\"cart-btn-pro\" id=\"addToCartPro\" disabled>\n            \u0623\u0636\u0641 \u0625\u0644\u0649 \u0627\u0644\u0633\u0644\u0629        <\/button>\n\n        <div class=\"contact-section-pro\">\n            <span class=\"contact-section-label-pro\">\u0647\u0644 \u062a\u062d\u062a\u0627\u062c \u0645\u0633\u0627\u0639\u062f\u0629\u061f \u062a\u0648\u0627\u0635\u0644 \u0645\u0639\u0646\u0627<\/span>\n            <div>\n                <a href=\"tel:+21656401310\" class=\"contact-btn-pro\">\ud83d\udcde \u0627\u062a\u0635\u0644: <bdi dir=\"ltr\">56 401 310<\/bdi><\/a>\n                <a href=\"https:\/\/wa.me\/21650600465\" class=\"contact-btn-pro whatsapp-btn-pro\" target=\"_blank\" rel=\"noopener\">\ud83d\udcac WhatsApp<\/a>\n            <\/div>\n        <\/div>\n    <\/div>\n<\/div><!-- \/.stl-upload-container-pro -->\n        <\/main><!-- \/.ppp-main -->\n\n        <!-- SIDEBAR -->\n        <aside class=\"ppp-aside\">\n\n            <div class=\"ppp-info-block\">\n                <div class=\"ppp-info-block-header\">\n                    <span class=\"ppp-icon\">\u23f1<\/span>\n                    <h4>\u0625\u0646\u062c\u0627\u0632 \u0633\u0631\u064a\u0639<\/h4>\n                <\/div>\n                <p>\u0627\u0644\u0625\u0646\u062a\u0627\u062c \u062e\u0644\u0627\u0644 3 \u0625\u0644\u0649 5 \u0623\u064a\u0627\u0645 \u0639\u0645\u0644 \u00b7 \u0627\u0644\u062a\u0648\u0635\u064a\u0644 \u062e\u0644\u0627\u0644 7 \u0625\u0644\u0649 14 \u064a\u0648\u0645\u0627\u064b \u0625\u062c\u0645\u0627\u0644\u0627\u064b. \u0627\u0644\u0637\u0644\u0628\u0627\u062a \u0627\u0644\u0639\u0627\u062c\u0644\u0629 \u0645\u062a\u0627\u062d\u0629.<\/p>\n            <\/div>\n\n            <div class=\"ppp-info-block ppp-info-block-blue\">\n                <div class=\"ppp-info-block-header\">\n                    <span class=\"ppp-icon\">\u2705<\/span>\n                    <h4>\u0636\u0645\u0627\u0646 \u0627\u0644\u062c\u0648\u062f\u0629<\/h4>\n                <\/div>\n                <p>\u0637\u0628\u0627\u0639\u0629 \u0648\u0645\u0648\u0627\u062f \u0628\u0645\u0633\u062a\u0648\u0649 \u0627\u062d\u062a\u0631\u0627\u0641\u064a. \u0646\u0636\u0645\u0646 \u0631\u0636\u0627\u0643 100%.<\/p>\n            <\/div>\n\n            <div class=\"ppp-info-block ppp-info-block-orange\">\n                <div class=\"ppp-info-block-header\">\n                    <span class=\"ppp-icon\">\ud83e\uddf1<\/span>\n                    <h4>\u0627\u0644\u0645\u0648\u0627\u062f \u0627\u0644\u0645\u062a\u0627\u062d\u0629<\/h4>\n                <\/div>\n                <!-- Material names contain Latin acronyms (PLA, PETG, ABS, TPU, Resin) that\n                     must NEVER be transliterated. We use $_tdp_t() (raw .mo read, bypasses\n                     TRP's gettext filter) AND notranslate flags (block TRP's HTML post-\n                     processor) so neither layer can replace \"Resin\" with \"\u0627\u0644\u0631\u0627\u062a\u0646\u062c\" or any\n                     other Auto-Translate cache entry. Same defense pattern as the JS i18n. -->\n                <ul class=\"notranslate\" translate=\"no\" data-no-translation>\n                    <li class=\"notranslate\" translate=\"no\" data-no-translation>PLA, PLA+ (\u0642\u064a\u0627\u0633\u064a \u0648\u0645\u0645\u064a\u0632)<\/li>\n                    <li class=\"notranslate\" translate=\"no\" data-no-translation>PETG (\u0645\u0642\u0627\u0648\u0645 \u0644\u0644\u0645\u0648\u0627\u062f \u0627\u0644\u0643\u064a\u0645\u064a\u0627\u0626\u064a\u0629)<\/li>\n                    <li class=\"notranslate\" translate=\"no\" data-no-translation>ABS (\u0645\u0642\u0627\u0648\u0645 \u0644\u062f\u0631\u062c\u0627\u062a \u0627\u0644\u062d\u0631\u0627\u0631\u0629 \u0627\u0644\u0639\u0627\u0644\u064a\u0629)<\/li>\n                    <li class=\"notranslate\" translate=\"no\" data-no-translation>TPU (\u0645\u0631\u0646)<\/li>\n                    <li class=\"notranslate\" translate=\"no\" data-no-translation>SLA - Resin (\u062f\u0642\u0629 \u0639\u0627\u0644\u064a\u0629)<\/li>\n                <\/ul>\n            <\/div>\n\n            <div class=\"ppp-info-block ppp-info-block-green\">\n                <div class=\"ppp-info-block-header\">\n                    <span class=\"ppp-icon\">\ud83d\udcc1<\/span>\n                    <h4>\u0645\u062a\u0637\u0644\u0628\u0627\u062a \u0627\u0644\u0645\u0644\u0641<\/h4>\n                <\/div>\n                <ul>\n                    <li>\u0635\u064a\u063a\u0629 STL \u0623\u0648 OBJ<\/li>\n                    <li>\u0627\u0644\u062d\u062c\u0645 \u0627\u0644\u0623\u0642\u0635\u0649 100 \u0645\u064a\u063a\u0627\u0628\u0627\u064a\u062a \u0644\u0644\u0645\u0644\u0641 \u2022 200 \u0645\u064a\u063a\u0627\u0628\u0627\u064a\u062a \u0625\u062c\u0645\u0627\u0644\u0627\u064b<\/li>\n                    <li>\u0634\u0628\u0643\u0627\u062a \u0645\u063a\u0644\u0642\u0629 \u0628\u0627\u0644\u0643\u0627\u0645\u0644<\/li>\n                    <li>\u0623\u062f\u0646\u0649 \u062d\u062c\u0645 \u0644\u0644\u062a\u0641\u0627\u0635\u064a\u0644 0.4 \u0645\u0645<\/li>\n                <\/ul>\n            <\/div>\n\n        <\/aside><!-- \/.ppp-aside -->\n\n    <\/div><!-- \/.ppp-layout -->\n<\/div><!-- \/.ppp-page -->\n\n\t<div class=\"mas-auth-modal\" id=\"tdp-quote-auth\" data-context=\"tdp_quote\" hidden>\n\t\t<div class=\"mas-auth-modal__backdrop\" data-mas-auth-close><\/div>\n\n\t\t<div class=\"mas-auth-modal__dialog\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"tdp-quote-auth-title\">\n\t\t\t<button type=\"button\" class=\"mas-auth-modal__x\" data-mas-auth-close aria-label=\"\u0625\u063a\u0644\u0627\u0642\">&times;<\/button>\n\n\t\t\t<h2 class=\"mas-auth-modal__title\" id=\"tdp-quote-auth-title\">\u0627\u0639\u0631\u0641 \u0633\u0639\u0631\u0643 \u0627\u0644\u062f\u0642\u064a\u0642 \u0648\u0627\u062e\u062a\u0631 \u0645\u0627\u062f\u062a\u0643<\/h2>\n\n\t\t\t\t\t\t\t<p class=\"mas-auth-modal__intro\">\u0645\u0644\u0641\u0627\u062a\u0643 \u062a\u0628\u0642\u0649 \u0647\u0646\u0627 \u2014 \u0644\u0627 \u062d\u0627\u062c\u0629 \u0644\u062a\u062d\u0645\u064a\u0644\u0647\u0627 \u0645\u0646 \u062c\u062f\u064a\u062f. \u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644 \u064a\u0641\u062a\u062d \u0644\u0643 \u0627\u0644\u0633\u0639\u0631 \u0627\u0644\u062f\u0642\u064a\u0642\u060c \u0648\u064a\u062a\u064a\u062d \u0644\u0643 \u0627\u062e\u062a\u064a\u0627\u0631 \u0627\u0644\u0645\u0627\u062f\u0629 \u0648\u0627\u0644\u062c\u0648\u062f\u0629\u060c \u0648\u064a\u0645\u0646\u062d\u0643 \u0639\u0631\u0636 \u0627\u0644\u0633\u0639\u0631 \u0627\u0644\u0642\u0627\u0628\u0644 \u0644\u0644\u0637\u0628\u0627\u0639\u0629.<\/p>\n\t\t\t\n\t\t\t<div class=\"mas-auth-modal__msg\" data-mas-auth-msg role=\"alert\" hidden><\/div>\n\n\t\t\t\t\t\t\t<div class=\"mas-auth-modal__social\" data-mas-auth-social>\n\t\t\t\t\t<div class=\"nsl-container nsl-container-block\" data-align=\"left\"><div class=\"nsl-container-buttons\"><a href=\"https:\/\/tunisia3dprint.com\/wp-login.php?loginSocial=google&#038;redirect=https%3A%2F%2Ftunisia3dprint.com%2Far%2Fwp-json%2Fwp%2Fv2%2Fpages%2F11068\" rel=\"nofollow\" aria-label=\"Continue with &lt;b&gt;Google&lt;\/b&gt;\" data-plugin=\"nsl\" data-action=\"connect\" data-provider=\"google\" data-popupwidth=\"600\" data-popupheight=\"600\" data-e-disable-page-transition=\"1\"><div class=\"nsl-button nsl-button-default nsl-button-google\" data-skin=\"light\" style=\"background-color:#fff;\"><div class=\"nsl-button-svg-container\"><svg xmlns=\"http:\/\/www.w3.org\/2000\/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path fill=\"#4285F4\" d=\"M20.64 12.2045c0-.6381-.0573-1.2518-.1636-1.8409H12v3.4814h4.8436c-.2086 1.125-.8427 2.0782-1.7959 2.7164v2.2581h2.9087c1.7018-1.5668 2.6836-3.874 2.6836-6.615z\"><\/path><path fill=\"#34A853\" d=\"M12 21c2.43 0 4.4673-.806 5.9564-2.1805l-2.9087-2.2581c-.8059.54-1.8368.859-3.0477.859-2.344 0-4.3282-1.5831-5.036-3.7104H3.9574v2.3318C5.4382 18.9832 8.4818 21 12 21z\"><\/path><path fill=\"#FBBC05\" d=\"M6.964 13.71c-.18-.54-.2822-1.1168-.2822-1.71s.1023-1.17.2823-1.71V7.9582H3.9573A8.9965 8.9965 0 0 0 3 12c0 1.4523.3477 2.8268.9573 4.0418L6.964 13.71z\"><\/path><path fill=\"#EA4335\" d=\"M12 6.5795c1.3214 0 2.5077.4541 3.4405 1.346l2.5813-2.5814C16.4632 3.8918 14.426 3 12 3 8.4818 3 5.4382 5.0168 3.9573 7.9582L6.964 10.29C7.6718 8.1627 9.6559 6.5795 12 6.5795z\"><\/path><\/svg><\/div><div class=\"nsl-button-label-container\">Continue with <b>Google<\/b><\/div><\/div><\/a><\/div><\/div><p class=\"tnl-social-consent notranslate\" translate=\"no\" data-no-translation dir=\"rtl\">\u0628\u0645\u0648\u0627\u0635\u0644\u0629 \u0627\u0644\u062f\u062e\u0648\u0644 \u0639\u0628\u0631 \u062d\u0633\u0627\u0628 \u0627\u062c\u062a\u0645\u0627\u0639\u064a\u060c \u0641\u0625\u0646\u0643 \u062a\u0648\u0627\u0641\u0642 \u0639\u0644\u0649 <a href=\"https:\/\/tunisia3dprint.com\/ar\/terms-of-use\/\" target=\"_blank\" rel=\"noopener\">\u0627\u0644\u0634\u0631\u0648\u0637 \u0627\u0644\u0639\u0627\u0645\u0629 \u0644\u0644\u0627\u0633\u062a\u0639\u0645\u0627\u0644<\/a> \u0648<a href=\"https:\/\/tunisia3dprint.com\/ar\/privacy-policy\/\" target=\"_blank\" rel=\"noopener\">\u0633\u064a\u0627\u0633\u0629 \u0627\u0644\u062e\u0635\u0648\u0635\u064a\u0629<\/a>.<\/p>\t\t\t\t<\/div>\n\t\t\t\t<div class=\"mas-auth-modal__or\"><span>\u0623\u0648<\/span><\/div>\n\t\t\t\n\t\t\t<div class=\"mas-auth-modal__tabs\" role=\"tablist\">\n\t\t\t\t<button type=\"button\" class=\"mas-auth-modal__tab is-active\" data-mas-auth-tab=\"register\" role=\"tab\" aria-selected=\"true\">\n\t\t\t\t\t\u0625\u0646\u0634\u0627\u0621 \u062d\u0633\u0627\u0628\t\t\t\t<\/button>\n\t\t\t\t<button type=\"button\" class=\"mas-auth-modal__tab\" data-mas-auth-tab=\"login\" role=\"tab\" aria-selected=\"false\">\n\t\t\t\t\t\u0644\u062f\u064a\u0651 \u062d\u0633\u0627\u0628 \u0628\u0627\u0644\u0641\u0639\u0644\t\t\t\t<\/button>\n\t\t\t<\/div>\n\n\t\t\t<form class=\"mas-auth-modal__panel is-active\" data-mas-auth-panel=\"register\" novalidate>\n\t\t\t\t<label class=\"mas-auth-modal__label\" for=\"tdp-quote-auth-name\">\u0627\u0633\u0645\u0643<\/label>\n\t\t\t\t<input class=\"mas-auth-modal__input\" type=\"text\" id=\"tdp-quote-auth-name\" name=\"name\" autocomplete=\"name\" placeholder=\"\u0623\u0645\u064a\u0646 \u0628\u0646 \u0635\u0627\u0644\u062d\" required \/>\n\n\t\t\t\t<label class=\"mas-auth-modal__label\" for=\"tdp-quote-auth-remail\">\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a<\/label>\n\t\t\t\t<input class=\"mas-auth-modal__input\" type=\"email\" id=\"tdp-quote-auth-remail\" name=\"email\" autocomplete=\"email\" placeholder=\"you@example.com\" required \/>\n\n\t\t\t\t<label class=\"mas-auth-modal__label\" for=\"tdp-quote-auth-rpass\">\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631<\/label>\n\t\t\t\t<div class=\"mas-auth-modal__pw\">\n\t\t\t\t\t<input class=\"mas-auth-modal__input\" type=\"password\" id=\"tdp-quote-auth-rpass\" name=\"password\" autocomplete=\"new-password\" placeholder=\"8 \u0623\u062d\u0631\u0641 \u0639\u0644\u0649 \u0627\u0644\u0623\u0642\u0644\" required \/>\n\t\t\t\t\t<button type=\"button\" class=\"mas-auth-modal__reveal\" data-mas-auth-reveal>\u0625\u0638\u0647\u0627\u0631<\/button>\n\t\t\t\t<\/div>\n\t\t\t\t<p class=\"mas-auth-modal__hint\">8 \u0623\u062d\u0631\u0641 \u0639\u0644\u0649 \u0627\u0644\u0623\u0642\u0644\u060c \u062a\u0634\u0645\u0644 \u062d\u0631\u0641\u0627\u064b \u0643\u0628\u064a\u0631\u0627\u064b \u0648\u062d\u0631\u0641\u0627\u064b \u0635\u063a\u064a\u0631\u0627\u064b \u0648\u0631\u0642\u0645\u0627\u064b.<\/p>\n\n\t\t\t\t\t\t\t\t\t<label class=\"mas-auth-modal__consent\" for=\"tdp-quote-auth-terms\">\n\t\t\t\t\t\t<input type=\"checkbox\" id=\"tdp-quote-auth-terms\" name=\"accept_terms\" value=\"1\" required \/>\n\t\t\t\t\t\t<span class=\"notranslate\" translate=\"no\" data-no-translation>\u0627\u0637\u0651\u0644\u0639\u062a \u0639\u0644\u0649 <a href=\"https:\/\/tunisia3dprint.com\/ar\/terms-of-use\/\" target=\"_blank\" rel=\"noopener\">\u0627\u0644\u0634\u0631\u0648\u0637 \u0627\u0644\u0639\u0627\u0645\u0629 \u0644\u0644\u0627\u0633\u062a\u0639\u0645\u0627\u0644<\/a> \u0648<a href=\"https:\/\/tunisia3dprint.com\/ar\/privacy-policy\/\" target=\"_blank\" rel=\"noopener\">\u0633\u064a\u0627\u0633\u0629 \u0627\u0644\u062e\u0635\u0648\u0635\u064a\u0629<\/a> \u0648\u0623\u0648\u0627\u0641\u0642 \u0639\u0644\u064a\u0647\u0645\u0627.<\/span>\n\t\t\t\t\t<\/label>\n\t\t\t\t\n\t\t\t\t<button type=\"submit\" class=\"mas-auth-modal__submit\">\u0623\u0646\u0634\u0626 \u062d\u0633\u0627\u0628\u064a<\/button>\n\t\t\t<\/form>\n\n\t\t\t<form class=\"mas-auth-modal__panel\" data-mas-auth-panel=\"login\" novalidate hidden>\n\t\t\t\t<label class=\"mas-auth-modal__label\" for=\"tdp-quote-auth-lemail\">\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a<\/label>\n\t\t\t\t<input class=\"mas-auth-modal__input\" type=\"email\" id=\"tdp-quote-auth-lemail\" name=\"email\" autocomplete=\"email\" placeholder=\"you@example.com\" required \/>\n\n\t\t\t\t<label class=\"mas-auth-modal__label\" for=\"tdp-quote-auth-lpass\">\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631<\/label>\n\t\t\t\t<div class=\"mas-auth-modal__pw\">\n\t\t\t\t\t<input class=\"mas-auth-modal__input\" type=\"password\" id=\"tdp-quote-auth-lpass\" name=\"password\" autocomplete=\"current-password\" required \/>\n\t\t\t\t\t<button type=\"button\" class=\"mas-auth-modal__reveal\" data-mas-auth-reveal>\u0625\u0638\u0647\u0627\u0631<\/button>\n\t\t\t\t<\/div>\n\n\t\t\t\t\t\t\t\t<div class=\"mas-auth-modal__2fa\" data-mas-auth-2fa hidden>\n\t\t\t\t\t<label class=\"mas-auth-modal__label\" for=\"tdp-quote-auth-l2fa\">\u0631\u0645\u0632 \u0627\u0644\u0645\u0635\u0627\u062f\u0642\u0629<\/label>\n\t\t\t\t\t<input class=\"mas-auth-modal__input\" type=\"text\" id=\"tdp-quote-auth-l2fa\" name=\"wfls-token\" inputmode=\"numeric\" autocomplete=\"one-time-code\" placeholder=\"123456\" \/>\n\t\t\t\t\t<p class=\"mas-auth-modal__hint\">\u064a\u0645\u0643\u0646\u0643 \u0623\u064a\u0636\u0627\u064b \u0627\u0633\u062a\u0639\u0645\u0627\u0644 \u0623\u062d\u062f \u0631\u0645\u0648\u0632 \u0627\u0644\u0627\u0633\u062a\u0631\u062c\u0627\u0639.<\/p>\n\t\t\t\t\t<label class=\"mas-auth-modal__check\">\n\t\t\t\t\t\t<input type=\"checkbox\" name=\"wfls-remember-device\" value=\"1\" \/>\n\t\t\t\t\t\t\u062a\u0630\u0643\u0651\u0631 \u0647\u0630\u0627 \u0627\u0644\u062c\u0647\u0627\u0632 \u0644\u0645\u062f\u0629 30 \u064a\u0648\u0645\u0627\u064b\t\t\t\t\t<\/label>\n\t\t\t\t<\/div>\n\n\t\t\t\t<button type=\"submit\" class=\"mas-auth-modal__submit\">\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644<\/button>\n\n\t\t\t\t<p class=\"mas-auth-modal__links\">\n\t\t\t\t\t<a href=\"https:\/\/tunisia3dprint.com\/ar\/forgot-password\/\">\u0646\u0633\u064a\u062a \u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631\u061f<\/a>\n\t\t\t\t<\/p>\n\t\t\t<\/form>\n\n\t\t\t\n\t\t\t<noscript>\n\t\t\t\t<p class=\"mas-auth-modal__foot\">\n\t\t\t\t\t<a href=\"https:\/\/tunisia3dprint.com\/ar\/login\/?mas_redirect=https%3A%2F%2Ftunisia3dprint.com%2Far%2Fwp-json%2Fwp%2Fv2%2Fpages%2F11068\">\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644<\/a>\n\t\t\t\t<\/p>\n\t\t\t<\/noscript>\n\t\t<\/div>\n\t<\/div>\n\t\n<script>\n\/\/ Server-detected active language \u2014 consumed by external tdp-pricing.js\nwindow.tdpActiveLang = \"ar\";\nconsole.log('[3dp-pricing] inline: tdpActiveLang =', window.tdpActiveLang || '(empty)');\n\n\/*\n * The price gate. `locked` is the SERVER's answer (TDP_Quote_Gate::locked()) and is\n * the only thing that decides whether the exact money is written into the DOM \u2014 the\n * browser never re-derives it, it only flips this flag when the sign-in dialog\n * reports success. `modal` is the dialog's selector; `context` is the identifier the\n * dialog sends back so modern-auth-system knows which fresh nonces we need.\n *\/\nwindow.tdpGate = {\n    locked:  true,\n    modal:   '#tdp-quote-auth',\n    context: 'tdp_quote',\n    ajaxUrl: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php'\n};\n\n\/*\n * The simulation record. Every price this calculator shows is logged as a row so the\n * pricing model can later be tuned against real uploads instead of intuition \u2014 see\n * TDP_Simulations. `enabled` and `storeFiles` are the server's settings, so switching\n * the feature off in wp-admin stops the browser doing the work at all rather than\n * having it POST into a handler that will refuse.\n *\n * \u26a0\ufe0f maxBytes is enforced BOTH here and server-side. The browser copy exists only to\n * avoid base64-ing 60 MB for nothing; the server copy is the one that matters.\n *\/\nwindow.tdpSimCfg = {\n    enabled:    true,\n    storeFiles: true,\n    maxBytes:   41943040};\n\n\/\/ Global store: maps fileId \u2192 original File object for the \"Send to our team\" feature.\n\/\/ Also exposed on window so tdpSendToTeam() (which reads window.tdpFileStore) can\n\/\/ reach it \u2014 a bare top-level `const` is NOT a property of window in classic scripts.\nconst tdpFileStore = window.tdpFileStore = {};\n\n\/\/ Configuration\nconst materialConfigPro = {\"pla_price\":\"80.00\",\"pla_density\":\"1.24\",\"pla_coc\":\"1.0\",\"petg_price\":\"130.00\",\"petg_density\":\"1.27\",\"petg_coc\":\"1.5\",\"abs_price\":\"155.00\",\"abs_density\":\"1.04\",\"abs_coc\":\"2.2\",\"tpu_price\":\"165.00\",\"tpu_density\":\"1.20\",\"tpu_coc\":\"3.0\",\"resine-standard_price\":\"100.00\",\"resine-standard_density\":\"1.10\",\"resine-standard_coc\":\"1.0\",\"resine-haute-resolution_price\":\"150.00\",\"resine-haute-resolution_density\":\"1.10\",\"resine-haute-resolution_coc\":\"1.0\",\"color_standard_coef\":\"1.0\",\"color_premium_coef\":\"1.1\",\"pla-plus_price\":\"90.00\",\"standard-resin_price\":\"100.00\",\"standard-resin_density\":\"1.10\",\"standard-resin_coc\":\"1.0\",\"high-resolution resin_price\":\"150.00\",\"high-resolution resin_density\":\"1.10\",\"high-resolution resin_coc\":\"1.0\"};\nconst qualityConfigPro = {\"basse_qci\":\"0.1\",\"basse_qct\":\"0.8\",\"standard_qci\":\"0.3\",\"standard_qct\":\"1.0\",\"haute_qci\":\"1.0\",\"haute_qct\":\"2.0\"};\nconst machineConfigPro = {\"machine_speed\":\"0.4\",\"machine_hourly_cost\":\"2.0\",\"sla_standard_multiplier\":\"2.2\",\"sla_highres_multiplier\":\"2.5\"};\nconst generalConfigPro = {\"waste_factor\":\"1.05\",\"fixed_cost\":\"5.0\",\"tva_rate\":\"0.19\",\"enable_tips\":\"1\",\"contact_email\":\"contact@tunisia3dprint.com\"};\nconst quantityConfigPro = {\"1_markup\":\"1.30\",\"2_5_markup\":\"1.30\",\"6_10_markup\":\"1.30\",\"11_plus_markup\":\"1.25\"};\n\n\/\/ Translatable strings for JavaScript.\n\/\/\n\/\/ IMPORTANT: All defaults below are HARDCODED ENGLISH literals \u2014 never use PHP __()\n\/\/ here. Why? TranslatePress's Auto-Translate Addon hooks the 'gettext' filter, and\n\/\/ that filter fires REGARDLESS of which page you're on. On the English page, TRP can\n\/\/ still return a cached French translation (e.g. \"Imprimabilit\u00e9 : Faible\") for a key\n\/\/ that was once translated during a French session. Hardcoded JS literals bypass\n\/\/ gettext entirely. The JS overlay (tdp-pricing.js \u2192 tdpApplyI18n) replaces these\n\/\/ defaults with the correct AR or FR strings ONLY when detectLang() returns 'ar' or\n\/\/ 'fr_FR' \u2014 i.e. when the URL is \/ar\/... or \/fr\/... On the English URL the overlay\n\/\/ is skipped and these literals stay = English. \u2713\nconst i18n = {\n    processing:        \"Processing...\",\n    ready:             \"Ready\",\n    error:             \"Error\",\n    calculating:       \"Calculating...\",\n    calculatingCosts:  \"Calculating costs...\",\n    allFilesProcessed: \"\u2705 All files processed successfully!\",\n    noFiles:           \"Please upload at least one 3D file (STL or OBJ).\",\n    orderCreated:      \"\u2705 Order created successfully! Redirecting to cart...\",\n    orderError:        \"\u274c Error: Unable to create order.\",\n    connectionError:   \"\u274c Connection error. Please contact us directly.\",\n    fileCountPrefix:   \"\ud83d\udcca\",\n    noFileSelected:    \"\ud83d\udcc1 No file selected\",\n    loadingViewer:     \"Loading 3D viewer...\",\n    uploadPlaceholder: \"\ud83d\udce4 Upload 3D file (STL or OBJ)\",\n    dimensions: {\n        width:  \"Width\",\n        height: \"Height\",\n        depth:  \"Depth\"\n    },\n    volume:               \"Volume\",\n    printability:         \"Printability\",\n    totalHT:              \"Total excl. tax:\",\n    totalTTC:             \"Total incl. tax:\",\n    filesSelectedSuffix:  \"file(s) selected\",\n    fileUnreadable:    \"Unable to read file\",\n    fileUnreadableMsg: \"We're sorry, we couldn't read this file. Please contact us directly and we'll take care of it.\",\n    fileTypeUnsupported: \"This file type is not supported. Please upload .stl or .obj files only.\",\n    printPopupBlocked:   \"Your browser blocked the print window. Please allow pop-ups for this site, then try again.\",\n    viewerDragHint:    \"\ud83d\uddb1\ufe0f Drag to rotate \u2022 Scroll to zoom\",\n    viewerLoadError:   \"Failed to load 3D model\",\n    viewerError:       \"3D viewer error\",\n    dimensionsMm:      \"Dimensions (mm)\",\n    cartProcessing:    \"\u23f3 Processing...\",\n    gateCardLocked:    \"Exact price after sign-in\",\n    gateOptionsLocked: \"Material and quality unlock when you sign in.\",\n    lowQualityWarning: \"Low quality suits prototypes best. For a functional part, ask us.\",\n    \/\/ Form labels\n    labelTechnology:   \"Technology\",\n    labelMaterial:     \"Material\",\n    labelColor:        \"Color\",\n    labelQuality:      \"Quality\",\n    labelQuantity:     \"Quantity\",\n    \/\/ Quality options\n    qualityLow:        \"Low\",\n    qualityStandard:   \"Standard\",\n    qualityHigh:       \"High\",\n    \/\/ Color names\n    colorWhite:  \"White\",  colorBlack:  \"Black\",  colorRed:    \"Red\",\n    colorGreen:  \"Green\",  colorBlue:   \"Blue\",   colorGray:   \"Gray\",\n    colorYellow: \"Yellow\",\n    \/\/ SLA material display names\n    matStandardResin: \"Standard Resin\",\n    matHighResResin:  \"High Resolution Resin\",\n    \/\/ Printability scores\n    printabilityExcellent:     \"Printability: Excellent\",\n    printabilityGood:          \"Printability: Good\",\n    printabilityFair:          \"Printability: Fair\",\n    printabilityPoor:          \"Printability: Poor\",\n    printabilityFairEstimated: \"Printability: Fair (Estimated)\",\n    printabilityUnknown:       \"Printability: not measured\",\n    notMeasured:               \"Could not read\",\n    noPriceableFiles:          \"We could not measure these files, so we cannot price them. Use the button on each file to send it to our team and we will get back to you with a quote.\",\n    \/\/ Per-locale translations \u2014 only applied when JS detectLang() returns 'ar' or\n    \/\/ 'fr_FR' (i.e. URL has \/ar\/ or \/fr\/ prefix). Built from raw .mo file reads,\n    \/\/ so no gettext filter (and hence no TRP) can pollute these values.\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\",\"printabilityUnknown\":\"Imprimabilit\\u00e9 : non mesur\\u00e9e\",\"notMeasured\":\"Lecture impossible\",\"noPriceableFiles\":\"Nous n'avons pas pu mesurer ces fichiers, nous ne pouvons donc pas les chiffrer. Utilisez le bouton sur chaque fichier pour l'envoyer \\u00e0 notre \\u00e9quipe et nous reviendrons vers vous avec un devis.\",\"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.\",\"fileTypeUnsupported\":\"Ce type de fichier n'est pas pris en charge. Veuillez t\\u00e9l\\u00e9charger uniquement des fichiers .stl ou .obj.\",\"printPopupBlocked\":\"Votre navigateur a bloqu\\u00e9 la fen\\u00eatre d'impression. Veuillez autoriser les fen\\u00eatres contextuelles pour ce site, puis r\\u00e9essayer.\",\"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...\",\"gateCardLocked\":\"Prix exact apr\\u00e8s connexion\",\"gateOptionsLocked\":\"Le mat\\u00e9riau et la qualit\\u00e9 se d\\u00e9bloquent \\u00e0 la connexion.\",\"lowQualityWarning\":\"La qualit\\u00e9 basse convient surtout aux prototypes. Pour une pi\\u00e8ce fonctionnelle, demandez-nous conseil.\",\"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\",\"printabilityUnknown\":\"\\u0642\\u0627\\u0628\\u0644\\u064a\\u0629 \\u0627\\u0644\\u0637\\u0628\\u0627\\u0639\\u0629: \\u063a\\u064a\\u0631 \\u0645\\u0642\\u0627\\u0633\\u0629\",\"notMeasured\":\"\\u062a\\u0639\\u0630\\u0651\\u0631\\u062a \\u0627\\u0644\\u0642\\u0631\\u0627\\u0621\\u0629\",\"noPriceableFiles\":\"\\u0644\\u0645 \\u0646\\u062a\\u0645\\u0643\\u0651\\u0646 \\u0645\\u0646 \\u0642\\u064a\\u0627\\u0633 \\u0647\\u0630\\u0647 \\u0627\\u0644\\u0645\\u0644\\u0641\\u0627\\u062a\\u060c \\u0644\\u0630\\u0644\\u0643 \\u0644\\u0627 \\u064a\\u0645\\u0643\\u0646\\u0646\\u0627 \\u062a\\u0633\\u0639\\u064a\\u0631\\u0647\\u0627. \\u0627\\u0633\\u062a\\u062e\\u062f\\u0645 \\u0627\\u0644\\u0632\\u0631 \\u0627\\u0644\\u0645\\u0648\\u062c\\u0648\\u062f \\u0639\\u0644\\u0649 \\u0643\\u0644 \\u0645\\u0644\\u0641 \\u0644\\u0625\\u0631\\u0633\\u0627\\u0644\\u0647 \\u0625\\u0644\\u0649 \\u0641\\u0631\\u064a\\u0642\\u0646\\u0627 \\u0648\\u0633\\u0646\\u0639\\u0648\\u062f \\u0625\\u0644\\u064a\\u0643 \\u0628\\u0639\\u0631\\u0636 \\u0633\\u0639\\u0631.\",\"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.\",\"fileTypeUnsupported\":\"\\u0647\\u0630\\u0627 \\u0627\\u0644\\u0646\\u0648\\u0639 \\u0645\\u0646 \\u0627\\u0644\\u0645\\u0644\\u0641\\u0627\\u062a \\u063a\\u064a\\u0631 \\u0645\\u062f\\u0639\\u0648\\u0645. \\u064a\\u064f\\u0631\\u062c\\u0649 \\u062a\\u062d\\u0645\\u064a\\u0644 \\u0645\\u0644\\u0641\\u0627\\u062a .stl \\u0623\\u0648 .obj \\u0641\\u0642\\u0637.\",\"printPopupBlocked\":\"\\u062d\\u062c\\u0628 \\u0645\\u062a\\u0635\\u0641\\u062d\\u0643 \\u0646\\u0627\\u0641\\u0630\\u0629 \\u0627\\u0644\\u0637\\u0628\\u0627\\u0639\\u0629. \\u064a\\u064f\\u0631\\u062c\\u0649 \\u0627\\u0644\\u0633\\u0645\\u0627\\u062d \\u0628\\u0627\\u0644\\u0646\\u0648\\u0627\\u0641\\u0630 \\u0627\\u0644\\u0645\\u0646\\u0628\\u062b\\u0642\\u0629 \\u0644\\u0647\\u0630\\u0627 \\u0627\\u0644\\u0645\\u0648\\u0642\\u0639 \\u062b\\u0645 \\u0627\\u0644\\u0645\\u062d\\u0627\\u0648\\u0644\\u0629 \\u0645\\u0631\\u0629 \\u0623\\u062e\\u0631\\u0649.\",\"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...\",\"gateCardLocked\":\"\\u0627\\u0644\\u0633\\u0639\\u0631 \\u0627\\u0644\\u062f\\u0642\\u064a\\u0642 \\u0628\\u0639\\u062f \\u062a\\u0633\\u062c\\u064a\\u0644 \\u0627\\u0644\\u062f\\u062e\\u0648\\u0644\",\"gateOptionsLocked\":\"\\u062a\\u064f\\u0641\\u062a\\u062d \\u0627\\u0644\\u0645\\u0627\\u062f\\u0629 \\u0648\\u0627\\u0644\\u062c\\u0648\\u062f\\u0629 \\u0639\\u0646\\u062f \\u062a\\u0633\\u062c\\u064a\\u0644 \\u0627\\u0644\\u062f\\u062e\\u0648\\u0644.\",\"lowQualityWarning\":\"\\u0627\\u0644\\u062c\\u0648\\u062f\\u0629 \\u0627\\u0644\\u0645\\u0646\\u062e\\u0641\\u0636\\u0629 \\u062a\\u0646\\u0627\\u0633\\u0628 \\u0627\\u0644\\u0646\\u0645\\u0627\\u0630\\u062c \\u0627\\u0644\\u0623\\u0648\\u0644\\u064a\\u0629. \\u0644\\u0644\\u0642\\u0637\\u0639 \\u0627\\u0644\\u0648\\u0638\\u064a\\u0641\\u064a\\u0629\\u060c \\u0627\\u0633\\u062a\\u0634\\u0631\\u0646\\u0627.\",\"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)\"}}};\n\/\/ Apply translation overlay for inline i18n labels (function defined in tdp-pricing.js)\nif (typeof window.tdpApplyI18n === 'function') {\n    window.tdpApplyI18n(i18n, 'i18n');\n} else {\n    console.warn('[3dp-pricing] tdpApplyI18n not found \u2014 external tdp-pricing.js did not load');\n}\n\n\/\/ Sync the static-HTML label with i18n.totalHT so the \"Total excl. tax:\" line above\n\/\/ the breakdown is always in the same language as the JS-rendered cost-item-pro lines.\n\/\/ Done after overlay so we pick up the right AR\/FR string (or the English default on\n\/\/ the English page). textContent on a span \u2014 TRP can't grab onto it because the parent\n\/\/ element keeps notranslate semantics via the i18n source it pulled from.\n(function syncStaticLabels() {\n    var lbl = document.getElementById('breakdownHTLabel');\n    if (lbl) lbl.textContent = i18n.totalHT;\n})();\n\n\/\/ Mapping des couleurs pour les mat\u00e9riaux\nconst materialColors = {\n    'White': 0xFFFFFF, 'Black': 0x111111, 'Red': 0xFF0000, 'Green': 0x00FF00,\n    'Blue': 0x0000FF, 'Gray': 0x808080, 'Yellow': 0xFFFF00\n};\n\n\/\/ =====================================================================\n\/\/ CALCULATEUR DE VOLUME ET SURFACE STL COMPLET\n\/\/ =====================================================================\n\n\/\/ =====================================================================\n\/\/ THE PRINTABILITY RULE REGISTRY\n\/\/ =====================================================================\n\/\/ Every threshold behind the printability bar and the design notes comes from\n\/\/ config\/printability-rules.json. Three readers share that one file: this page,\n\/\/ the read-only screen at Tunisia3DPrint \u2192 Printability, and the local harness\n\/\/ `node tools\/printability-check.js`.\n\/\/\n\/\/ \u26a0\ufe0f The literals passed as the SECOND argument to TDPGeometryAnalyzer.cfg()\n\/\/ are FALLBACKS ONLY. They exist so that a missing or malformed registry\n\/\/ degrades to today's behaviour instead of breaking a customer's quote, and\n\/\/ they must stay equal to the registry. The harness asserts exactly that, so\n\/\/ the duplication is machine-checked rather than trusted.\nwindow.tdpRules = {\"version\":\"1.0\",\"limits\":{\"max_analysis_triangles\":100000,\"min_thickness_samples\":5,\"min_segment_mm\":0.05},\"bands\":{\"excellent\":70,\"good\":45,\"fair\":25},\"floors\":{\"thickness_critical\":{\"when\":\"Wall thickness is critical.\",\"caps_at\":\"good\"},\"mesh_critical\":{\"when\":\"The file is not a closed solid (open edges over 1%, or more than 10 non-manifold edges).\",\"caps_at\":\"good\"}},\"factors\":{\"aspect\":{\"label\":\"Aspect ratio\",\"points\":15,\"measures\":\"Longest bounding-box edge divided by the shortest.\",\"compare\":\"lte\",\"steps\":[[5,15],[10,10],[20,5]],\"else\":0},\"efficiency\":{\"label\":\"Volume efficiency\",\"points\":15,\"measures\":\"How much of its own bounding box the part actually fills.\",\"compare\":\"gte\",\"steps\":[[50,15],[30,11],[15,6]],\"else\":2,\"flat_object\":{\"aspect_ratio_gt\":8,\"efficiency_gt\":50,\"score\":12}},\"overhang\":{\"label\":\"Overhang severity\",\"points\":35,\"measures\":\"Downward-facing surface that would need support, checked across 6 build orientations.\",\"critical_angle_deg\":70,\"span_mm\":10,\"area_pct\":0.02,\"plate_eps_mm\":0.1,\"plate_eps_relative\":0.001,\"compare\":\"lte\",\"steps\":[[2,35],[5,26],[10,14]],\"else\":0,\"warning_pct_gt\":2,\"critical_pct_gt\":10},\"thickness\":{\"label\":\"Wall thickness\",\"points\":25,\"measures\":\"5th-percentile wall thickness, sampled with axis-aligned rays.\",\"compare\":\"gte\",\"steps\":[[3,25],[2,20],[1.2,15],[1,8],[0.6,3]],\"else\":0,\"critical_below_mm\":0.6,\"warning_below_mm\":1,\"rays_per_direction\":100},\"mesh\":{\"label\":\"Mesh integrity\",\"points\":10,\"measures\":\"Open edges and non-manifold edges.\",\"start_score\":10,\"vertex_round_mm\":0.001,\"deductions\":{\"non_manifold_many\":{\"edges_gt\":10,\"or_pct_gt\":0.005,\"minus\":6},\"non_manifold_some\":{\"edges_gt\":3,\"minus\":3},\"non_manifold_any\":{\"edges_gt\":0,\"minus\":1},\"open_pct\":{\"pct_gt\":0.01,\"minus\":3},\"open_many\":{\"edges_gt\":10,\"minus\":2},\"open_any\":{\"edges_gt\":0,\"minus\":1}},\"critical\":{\"non_manifold_edges_gt\":10,\"open_pct_gt\":0.01}}},\"notes\":[{\"id\":\"ready\",\"factor\":\"-\",\"severity\":\"none\",\"fires\":\"Nothing was found that affects printing.\",\"msgid\":\"Your design is ready to print \\u2014 we found nothing that needs special attention.\"},{\"id\":\"minor_support\",\"factor\":\"overhang\",\"severity\":\"minor\",\"fires\":\"Overhang only, at warning level: over 2% of the surface in the best orientation, but under 10%.\",\"msgid\":\"Some overhangs \\u2014 we'll add the supports for you. Nothing to change on your side.\"},{\"id\":\"minor_thin\",\"factor\":\"thickness\",\"severity\":\"minor\",\"fires\":\"Thin walls only, at warning level: 5th-percentile between 0.6 mm and 1.0 mm.\",\"msgid\":\"A few thin areas \\u2014 we'll check them before printing. Nothing to change on your side.\"},{\"id\":\"minor_several\",\"factor\":\"-\",\"severity\":\"minor\",\"fires\":\"More than one thing fired, none of them serious.\",\"msgid\":\"A few small points \\u2014 we'll handle them during preparation. Nothing to change on your side.\"},{\"id\":\"major_support\",\"factor\":\"overhang\",\"severity\":\"major\",\"fires\":\"Overhang is critical \\u2014 over 10% of the surface even in the best orientation \\u2014 and nothing else fired.\",\"msgid\":\"This design needs a good amount of support \\u2014 place your order and we'll contact you to agree the best approach.\"},{\"id\":\"major_thin\",\"factor\":\"thickness\",\"severity\":\"major\",\"fires\":\"Wall thickness is critical, under 0.6 mm, and nothing else fired.\",\"msgid\":\"Some walls are very thin \\u2014 place your order and we'll contact you to agree the best approach.\"},{\"id\":\"major_mesh\",\"factor\":\"mesh\",\"severity\":\"major\",\"fires\":\"The file is not a closed solid \\u2014 open edges over 1%, or more than 10 non-manifold edges \\u2014 and nothing else fired.\",\"msgid\":\"This file isn't a fully closed solid \\u2014 place your order and we'll contact you to confirm the details.\"},{\"id\":\"major_several\",\"factor\":\"-\",\"severity\":\"major\",\"fires\":\"More than one thing fired and at least one is serious.\",\"msgid\":\"A few points to sort out \\u2014 place your order and we'll contact you to agree the best approach.\"},{\"id\":\"quote_unreadable\",\"factor\":\"-\",\"severity\":\"major\",\"fires\":\"Nothing could be read from the file at all - not even its size. THE FILE IS NOT PRICED: its line contributes 0, and if no file in the basket is priceable the order is refused. An order note alerts the team.\",\"msgid\":\"We could not read this file, so we have not priced it \\u2014 send it to our team below and we will quote it by hand.\"},{\"id\":\"quote_no_volume\",\"factor\":\"-\",\"severity\":\"major\",\"fires\":\"The shape was read - the dimensions are real - but its solid volume came out as good as zero, and volume is what the price is made of. THE FILE IS NOT PRICED. An order note alerts the team.\",\"msgid\":\"We can read this file's size but not its volume, so we have not priced it \\u2014 send it to our team below and we will quote it by hand.\"}],\"retired_notes\":[{\"id\":\"overhang_critical\",\"factor\":\"overhang\",\"severity\":\"critical\",\"fires\":\"Even standing the part the best way up, more than 10% of its surface still overhangs \\u2014 a lot of support is unavoidable.\",\"msgid\":\"This design has overhangs \\u2014 we'll manage all the support structures to deliver a clean, high-quality print.\"},{\"id\":\"overhang_warning\",\"factor\":\"overhang\",\"severity\":\"warning\",\"fires\":\"The best orientation still leaves more than 2% of the surface overhanging, but under 10% \\u2014 some support is unavoidable.\",\"msgid\":\"We noticed some overhangs in your design \\u2014 we'll handle everything, no action needed from you.\"},{\"id\":\"thickness_critical\",\"factor\":\"thickness\",\"severity\":\"critical\",\"fires\":\"5th-percentile wall thickness is below 0.6 mm.\",\"msgid\":\"We noticed very thin walls in this design \\u2014 we'll review it and contact you with the best solution before printing.\"},{\"id\":\"thickness_warning\",\"factor\":\"thickness\",\"severity\":\"warning\",\"fires\":\"5th-percentile wall thickness is between 0.6 mm and 1.0 mm.\",\"msgid\":\"We noticed some thin areas in this design \\u2014 we'll review and advise you if anything needs attention.\"},{\"id\":\"mesh_critical\",\"factor\":\"mesh\",\"severity\":\"critical\",\"fires\":\"More than 10 non-manifold edges, or more than 1% open edges, or volume consistency below 0.50.\",\"msgid\":\"Our analysis flagged a few points on this design \\u2014 we'll review it carefully and contact you before we start printing.\"},{\"id\":\"mesh_warning\",\"factor\":\"mesh\",\"severity\":\"warning\",\"fires\":\"Any non-manifold or open edge, or volume consistency below 0.95, without reaching critical.\",\"msgid\":\"Our analysis flagged a few minor technical notes \\u2014 we'll handle everything during the preparation stage.\"},{\"id\":\"aspect_tall\",\"factor\":\"aspect\",\"severity\":\"shape\",\"fires\":\"Aspect ratio is the weakest factor and is above 15.\",\"msgid\":\"We noticed this design is tall and narrow \\u2014 we'll manage the orientation and supports to get you the best result.\"},{\"id\":\"aspect_flat\",\"factor\":\"aspect\",\"severity\":\"shape\",\"fires\":\"Aspect ratio is the weakest factor and is 15 or below.\",\"msgid\":\"This is a flat design \\u2014 we'll orient it on the print bed for the best finish.\"},{\"id\":\"efficiency_low\",\"factor\":\"efficiency\",\"severity\":\"shape\",\"fires\":\"Volume efficiency is the weakest factor.\",\"msgid\":\"This is an open, spread-out shape \\u2014 we'll optimize how it sits on the print bed and handle any supports it needs.\"},{\"id\":\"mesh_low\",\"factor\":\"mesh\",\"severity\":\"shape\",\"fires\":\"Mesh integrity is the weakest factor and the file was readable.\",\"msgid\":\"Our analysis has a few technical notes on this design \\u2014 we'll review everything before printing.\"},{\"id\":\"mesh_unknown\",\"factor\":\"mesh\",\"severity\":\"shape\",\"fires\":\"The file could not be parsed into triangles at all.\",\"msgid\":\"Our analysis could not fully evaluate this design \\u2014 we'll review it directly and contact you with any notes.\"},{\"id\":\"excellent_clean\",\"factor\":\"-\",\"severity\":\"\",\"fires\":\"Verdict is Excellent and no rule fired.\",\"msgid\":\"Great design \\u2014 everything looks good. Go ahead and place your order!\"},{\"id\":\"good_clean\",\"factor\":\"-\",\"severity\":\"\",\"fires\":\"Verdict is Good and no rule fired.\",\"msgid\":\"Your design looks great \\u2014 go ahead and place your order!\"},{\"id\":\"good_minor\",\"factor\":\"-\",\"severity\":\"\",\"fires\":\"Verdict is Good and at least one rule fired; prefixes the notes.\",\"msgid\":\"Your design prints well. We'll take care of a few minor points during production:\"}]};\n\n\/\/ The analyzer and the STL reader live in assets\/js\/printability-core.js so\n\/\/ that this page, the \"test a file\" panel on Tunisia3DPrint \u2192 Printability and\n\/\/ the local harness all run the same code. It is INLINED rather than enqueued:\n\/\/ no extra request, no ?ver= to forget, nothing cacheable between the file on\n\/\/ disk and the browser. See the header of that file for why.\n\/**\n * PRINTABILITY CORE \u2014 the geometry analyzer and the STL reader.\n *\n * Extracted from templates\/upload-form-pro.php on 2026-08-25 so that the\n * customer's quote page, the \"test a file\" panel on Tunisia3DPrint \u2192\n * Printability, and the local harness all run THE SAME CODE. A second copy of\n * an analyzer is a second set of answers, and the whole point of the rule\n * registry is that there is one.\n *\n * Two consumers, deliberately loaded two different ways:\n *\n *   1. templates\/upload-form-pro.php INLINES this file with file_get_contents()\n *      into the script block where these classes used to live. That keeps the\n *      customer-facing page byte-identical in load semantics: no extra request,\n *      no ?ver= to forget, nothing between the file on disk and the browser.\n *      This project has lost days to a stale asset served from a cache layer\n *      (dc-header.css) \u2014 the quote page does not get to fail that way.\n *   2. includes\/class-printability-admin.php ENQUEUES it normally. wp-admin has\n *      no such cache in front of it and nothing else to order against.\n *\n * \u26a0\ufe0f NO PHP INTERPOLATION IN THIS FILE, EVER. It is loaded as a plain .js by the\n * admin screen and read as text by tools\/printability-check.js. A `<?php` tag\n * here would be served raw to the browser.\n *\n * \u26a0\ufe0f Every threshold comes from config\/printability-rules.json via\n * TDPGeometryAnalyzer.cfg(). The literals passed as cfg()'s SECOND argument are\n * fallbacks only, so a missing registry degrades to today's behaviour instead of\n * breaking a customer's quote. tools\/printability-check.js asserts each one\n * still equals the registry.\n *\n * Reads two optional globals, both absent on the admin screen:\n *   window.tdpRules \u2014 the rule registry (falls back to the literals above)\n *   i18n            \u2014 the quote page's translated label bundle (falls back to\n *                     the plain English level name)\n *\/\n\n\/**\n * One label from the quote page's i18n bundle.\n *\n * The customer's page supplies a translated bundle; the admin test panel and the\n * harness do not. Falling back to the English level name keeps this file usable\n * outside the quote page without giving it a second, drifting copy of the\n * translations. The try\/catch is for the temporal dead zone: `i18n` is a const\n * in the consuming page's script, and typeof on a not-yet-initialised const\n * throws rather than returning \"undefined\".\n *\/\nfunction tdpI18nText(key, fallback) {\n    try {\n        if (typeof i18n !== 'undefined' && i18n && i18n[key]) return i18n[key];\n    } catch (e) { \/* not declared here *\/ }\n    return fallback;\n}\n\n\/\/ Maximum number of triangles for which the deep analysis (overhangs in\n\/\/ 6 orientations, grid-based wall thickness, edge-map mesh integrity) is\n\/\/ run. Above this, the calculator falls back to single-orientation overhang\n\/\/ + 3V\/SA thickness so the browser stays responsive.\nconst TDP_MAX_ANALYSIS_TRIANGLES =\n    (window.tdpRules && window.tdpRules.limits && window.tdpRules.limits.max_analysis_triangles) || 100000;\n\n\/\/ =====================================================================\n\/\/ GEOMETRY ANALYZER \u2014 overhangs, thickness, mesh integrity\n\/\/ =====================================================================\nclass TDPGeometryAnalyzer {\n\n    \/**\n     * One value from the rule registry, by dotted path.\n     * `fallback` is used only when the registry is missing or malformed \u2014 see\n     * the note above TDP_MAX_ANALYSIS_TRIANGLES.\n     *\/\n    static cfg(dotted, fallback) {\n        let node = (typeof tdpRules !== 'undefined' && tdpRules) ? tdpRules : null;\n        if (!node) return fallback;\n        for (const key of dotted.split('.')) {\n            if (!node || typeof node !== 'object' || !(key in node)) return fallback;\n            node = node[key];\n        }\n        return (node === null || node === undefined) ? fallback : node;\n    }\n\n    \/**\n     * Walk a factor's scoring ladder from the registry.\n     * compare 'gte': first step whose limit <= value (descending ladders).\n     * compare 'lte': first step whose limit >= value (ascending ladders).\n     *\/\n    static ladder(factor, value, fbSteps, fbCompare, fbElse) {\n        const steps = this.cfg('factors.' + factor + '.steps',   fbSteps);\n        const cmp   = this.cfg('factors.' + factor + '.compare', fbCompare);\n        for (let i = 0; i < steps.length; i++) {\n            const limit = steps[i][0], score = steps[i][1];\n            if (cmp === 'gte' ? value >= limit : value <= limit) return score;\n        }\n        return this.cfg('factors.' + factor + '.else', fbElse);\n    }\n\n    static thicknessToScore(thicknessMm) {\n        return this.ladder('thickness', thicknessMm,\n            [[3.0, 25], [2.0, 20], [1.2, 15], [1.0, 8], [0.6, 3]], 'gte', 0);\n    }\n\n    \/**\n     * Mesh integrity via edge map + signed-vs-absolute volume.\n     * Returns { score: 0..20, data: { ... } } or null if triangles unavailable.\n     *\/\n    static analyzeMeshIntegrity(triangles, signedVolume, absoluteVolume) {\n        if (!triangles || triangles.length === 0) return null;\n\n        const edgeMap = new Map();\n        \/\/ The weld tolerance was hardcoded at 0.001 while the registry carried a\n        \/\/ vertex_round_mm the code never read - a number on the admin screen that\n        \/\/ documented nothing. It is read now, and PR4's region clustering reads\n        \/\/ the same key, because \"is this the same point?\" is one question.\n        \/\/ round(v * SCALE) \/ SCALE, never round(v \/ eps) * eps - see the note in\n        \/\/ analyzeOverhangs; the two forms disagree in the last bits.\n        const WELD_SCALE = 1 \/ this.cfg('factors.mesh.vertex_round_mm', 0.001);\n        const round = v => Math.round(v * WELD_SCALE) \/ WELD_SCALE;\n\n        for (let i = 0; i < triangles.length; i++) {\n            const t = triangles[i];\n            const verts = [\n                [round(t.v1.x), round(t.v1.y), round(t.v1.z)],\n                [round(t.v2.x), round(t.v2.y), round(t.v2.z)],\n                [round(t.v3.x), round(t.v3.y), round(t.v3.z)]\n            ];\n\n            for (let e = 0; e < 3; e++) {\n                const a = verts[e];\n                const b = verts[(e + 1) % 3];\n                \/\/ sort vertices so edge AB and BA collide\n                const cmp = (a[0] - b[0]) || (a[1] - b[1]) || (a[2] - b[2]);\n                const key = cmp < 0\n                    ? a[0] + ',' + a[1] + ',' + a[2] + '_' + b[0] + ',' + b[1] + ',' + b[2]\n                    : b[0] + ',' + b[1] + ',' + b[2] + '_' + a[0] + ',' + a[1] + ',' + a[2];\n                edgeMap.set(key, (edgeMap.get(key) || 0) + 1);\n            }\n        }\n\n        let nonManifoldEdges = 0;\n        let openEdges = 0;\n        for (const c of edgeMap.values()) {\n            if (c > 2)      nonManifoldEdges++;\n            else if (c === 1) openEdges++;\n        }\n        const uniqueEdges = edgeMap.size;\n        const nonManifoldPct = uniqueEdges > 0 ? nonManifoldEdges \/ uniqueEdges : 0;\n        const openEdgePct    = uniqueEdges > 0 ? openEdges \/ uniqueEdges : 0;\n        \/\/ ---- P0-13: MEASURE THE VOLUMES ABOUT THE MESH'S OWN CENTRE ---------\n        \/\/ \ud83d\udd34 THE CALLER'S absoluteVolume IS TAKEN ABOUT THE COORDINATE ORIGIN,\n        \/\/ SO IT MEASURES DISTANCE FROM THE ORIGIN, NOT MESH INTEGRITY. Move a\n        \/\/ mesh away from the origin and every tetrahedron grows while the signed\n        \/\/ sum stays constant, so signed\/absolute collapses. Measured 2026-08-25\n        \/\/ on the owner's own 60x60x100 cube, byte-identical, only translated:\n        \/\/\n        \/\/     at the origin   consistency 1.0000  ->  100\/100 excellent\n        \/\/     at x + 1000     consistency 0.0826  ->   92\/100, mesh CRITICAL\n        \/\/\n        \/\/ Zero open edges, zero non-manifold edges, both times. A part exported\n        \/\/ out of a CAD assembly looks exactly like the second row, which is why\n        \/\/ this note reached most customers - it fired on 9 of the 19 regression\n        \/\/ fixtures, as often as \"everything is fine\".\n        \/\/\n        \/\/ It also HID real faults: a cube with its top and bottom faces wound\n        \/\/ backwards scored 1.0000, because faces lying in the plane z = 0 have\n        \/\/ zero-volume tetrahedra about the origin.\n        \/\/\n        \/\/ Taking the apex at the centre of the mesh's own bounding box makes the\n        \/\/ ratio translation-invariant, which is what it was always meant to be.\n        \/\/ \u26a0\ufe0f A concave solid still scores below 1.0 even so - a face that points\n        \/\/ back towards the centre genuinely cancels - so this number remains a\n        \/\/ weak signal, which is why the mesh factor is now worth 10 points\n        \/\/ rather than 20 and no longer produces a customer note on its own.\n        let bx0 = Infinity, by0 = Infinity, bz0 = Infinity;\n        let bx1 = -Infinity, by1 = -Infinity, bz1 = -Infinity;\n        for (let i = 0; i < triangles.length; i++) {\n            const t = triangles[i];\n            const vs = [t.v1, t.v2, t.v3];\n            for (let k = 0; k < 3; k++) {\n                const v = vs[k];\n                if (v.x < bx0) bx0 = v.x; if (v.x > bx1) bx1 = v.x;\n                if (v.y < by0) by0 = v.y; if (v.y > by1) by1 = v.y;\n                if (v.z < bz0) bz0 = v.z; if (v.z > bz1) bz1 = v.z;\n            }\n        }\n        const ox = (bx0 + bx1) \/ 2, oy = (by0 + by1) \/ 2, oz = (bz0 + bz1) \/ 2;\n\n        let centredSigned = 0, centredAbsolute = 0;\n        for (let i = 0; i < triangles.length; i++) {\n            const t = triangles[i];\n            const ax = t.v1.x - ox, ay = t.v1.y - oy, az = t.v1.z - oz;\n            const bxx = t.v2.x - ox, byy = t.v2.y - oy, bzz = t.v2.z - oz;\n            const cxx = t.v3.x - ox, cyy = t.v3.y - oy, czz = t.v3.z - oz;\n            const tet = (ax * (byy * czz - bzz * cyy)\n                       + ay * (bzz * cxx - bxx * czz)\n                       + az * (bxx * cyy - byy * cxx)) \/ 6;\n            centredSigned   += tet;\n            centredAbsolute += Math.abs(tet);\n        }\n\n        const volumeConsistency = centredAbsolute > 0\n            ? Math.abs(centredSigned) \/ centredAbsolute\n            : ( absoluteVolume > 0 ? Math.abs(signedVolume) \/ absoluteVolume : 0 );\n\n        \/\/ Deduction thresholds from config\/printability-rules.json.\n        const d = p => this.cfg('factors.mesh.deductions.' + p[0] + '.' + p[1], p[2]);\n\n        let meshScore = this.cfg('factors.mesh.start_score', 10);\n\n        if (nonManifoldEdges > d(['non_manifold_many', 'edges_gt', 10])\n            || nonManifoldPct > d(['non_manifold_many', 'or_pct_gt', 0.005]))  meshScore -= d(['non_manifold_many', 'minus', 6]);\n        else if (nonManifoldEdges > d(['non_manifold_some', 'edges_gt', 3]))   meshScore -= d(['non_manifold_some', 'minus', 3]);\n        else if (nonManifoldEdges > d(['non_manifold_any',  'edges_gt', 0]))   meshScore -= d(['non_manifold_any',  'minus', 1]);\n\n        if (openEdgePct > d(['open_pct', 'pct_gt', 0.01]))     meshScore -= d(['open_pct',  'minus', 3]);\n        else if (openEdges > d(['open_many', 'edges_gt', 10])) meshScore -= d(['open_many', 'minus', 2]);\n        else if (openEdges > d(['open_any',  'edges_gt', 0]))  meshScore -= d(['open_any',  'minus', 1]);\n\n        \/\/ \ud83d\udd34 THE THREE volume_* DEDUCTIONS WERE DELETED ON 2026-08-25, NOT\n        \/\/ SCALED DOWN. Measured over the 19 regression fixtures, the\n        \/\/ signed-to-absolute ratio is ANTI-CORRELATED with mesh health: the two\n        \/\/ genuinely broken meshes score 1.0000 (an open box missing a face) and\n        \/\/ 0.7778 (a non-manifold box soup), while four perfectly valid closed\n        \/\/ manifold parts score 0.06 to 0.42 \u2014 because a hollow or concave solid's\n        \/\/ inner surface genuinely cancels against its outer one. It was\n        \/\/ penalising a part for being hollow, which is most of what we print.\n        \/\/ volumeConsistency is still computed and reported, as a diagnostic on\n        \/\/ the test panel; it no longer decides anything.\n\n        meshScore = Math.max(0, meshScore);\n\n        \/\/ What remains says something a customer could act on: the file is not\n        \/\/ a closed solid. See the note above about volumeConsistency, which used\n        \/\/ to be a third criterion here and was the reason this fired on 9 of the\n        \/\/ 19 fixtures \u2014 as often as \"everything is fine\".\n        const isCritical = nonManifoldEdges > this.cfg('factors.mesh.critical.non_manifold_edges_gt', 10)\n                        || openEdgePct      > this.cfg('factors.mesh.critical.open_pct_gt', 0.01);\n        const isWarning  = (nonManifoldEdges > 0 || openEdges > 0 || volumeConsistency < 0.95) && !isCritical;\n\n        return {\n            score: meshScore,\n            data: {\n                nonManifoldEdges,\n                openEdges,\n                volumeConsistency,\n                isCritical,\n                isWarning\n            }\n        };\n    }\n\n    \/**\n     * Overhang analysis. Runs single Z-down check by default; if triangle\n     * count <= TDP_MAX_ANALYSIS_TRIANGLES, runs all 6 build orientations.\n     * Returns { score: 0..30, data: { ... } } or null if triangles unavailable.\n     *\n     * THE FACE RESTING ON THE BUILD PLATE IS NOT AN OVERHANG, AND EXCLUDING IT\n     * IS LOAD-BEARING - DO NOT REMOVE THE plateProj TEST BELOW. A downward face\n     * only needs support when there is empty space beneath it; the model's own\n     * base is carried by the plate. Without the exclusion EVERY solid tripped\n     * EVERY orientation: a plain 60x60x100 box measured 6\/6 critical\n     * orientations, scored 0\/30 here, and told the customer \"This design has\n     * overhangs\". The same bug also left that box on exactly 70 points - the\n     * bottom edge of \"Excellent\" - so one cause produced a false warning AND a\n     * wrong verdict. Reported by the owner and fixed 2026-08-25.\n     *\n     * The 6 orientations are axis-aligned, so the plate plane is simply a face\n     * of the bounding box: the exclusion costs no extra pass over the triangles.\n     *\/\n    static analyzeOverhangs(triangles, totalSurfaceMm2, bbox) {\n        if (!triangles || triangles.length === 0) return null;\n\n        \/\/ Bounding box: use the caller's when supplied, else derive it here.\n        \/\/ Deliberately self-sufficient - a future caller that forgets the third\n        \/\/ argument must not silently lose the plate exclusion and resurrect the\n        \/\/ \"every solid has overhangs\" bug.\n        let bb = bbox;\n        if (!bb || bb.minX === undefined) {\n            bb = { minX: Infinity, minY: Infinity, minZ: Infinity,\n                   maxX: -Infinity, maxY: -Infinity, maxZ: -Infinity };\n            for (let i = 0; i < triangles.length; i++) {\n                const t = triangles[i];\n                const vs = [t.v1, t.v2, t.v3];\n                for (let k = 0; k < 3; k++) {\n                    const v = vs[k];\n                    if (v.x < bb.minX) bb.minX = v.x;\n                    if (v.y < bb.minY) bb.minY = v.y;\n                    if (v.z < bb.minZ) bb.minZ = v.z;\n                    if (v.x > bb.maxX) bb.maxX = v.x;\n                    if (v.y > bb.maxY) bb.maxY = v.y;\n                    if (v.z > bb.maxZ) bb.maxZ = v.z;\n                }\n            }\n        }\n\n        \/\/ proj(v) = v . d, so the material closest to the plate has the MAX\n        \/\/ projection - that value is the plate plane for this orientation.\n        const orientations = [\n            { n: 'Z-down', d: [ 0,  0, -1], plate: -bb.minZ, extent: bb.maxZ - bb.minZ },  \/\/ default build orientation\n            { n: 'Z-up',   d: [ 0,  0,  1], plate:  bb.maxZ, extent: bb.maxZ - bb.minZ },  \/\/ printed inverted\n            { n: 'Y-down', d: [ 0, -1,  0], plate: -bb.minY, extent: bb.maxY - bb.minY },\n            { n: 'Y-up',   d: [ 0,  1,  0], plate:  bb.maxY, extent: bb.maxY - bb.minY },\n            { n: 'X-down', d: [-1,  0,  0], plate: -bb.minX, extent: bb.maxX - bb.minX },\n            { n: 'X-up',   d: [ 1,  0,  0], plate:  bb.maxX, extent: bb.maxX - bb.minX }\n        ];\n\n        const fullCheck = triangles.length <= TDP_MAX_ANALYSIS_TRIANGLES;\n        const orientList = fullCheck ? orientations : [orientations[0]];\n\n        \/\/ Thresholds from config\/printability-rules.json (fallbacks are checked\n        \/\/ against it by tools\/printability-check.js).\n        const SPAN_THRESHOLD_MM  = this.cfg('factors.overhang.span_mm',            10);\n        const AREA_PCT_THRESHOLD = this.cfg('factors.overhang.area_pct',           0.02);\n        \/\/ Face angle from VERTICAL: 0 = upright wall, 90 = horizontal ceiling.\n        \/\/ \u26a0\ufe0f Owner's calibration 2026-08-25: a 50 degree face does NOT need\n        \/\/ support on our machines, so this stays at 70. Do NOT lower it to the\n        \/\/ textbook 45 \u2014 that was proposed and explicitly overruled.\n        const CRITICAL_ANGLE_DEG = this.cfg('factors.overhang.critical_angle_deg', 70);\n        const PLATE_EPS_MM       = this.cfg('factors.overhang.plate_eps_mm',       0.1);\n        const PLATE_EPS_REL      = this.cfg('factors.overhang.plate_eps_relative', 0.001);\n        const RAD_TO_DEG         = 180 \/ Math.PI;\n\n        \/\/ ---- VERTEX WELDING, SHARED BY ALL SIX ORIENTATIONS (PR4) ----------\n        \/\/ Region clustering needs to know when two triangles touch, and an STL\n        \/\/ stores every vertex three times over with float noise, so raw\n        \/\/ coordinates never compare equal. The tolerance is the SAME one the\n        \/\/ mesh factor uses to decide manifoldness - it is literally the same\n        \/\/ question (\"is this the same point?\"), so it is read from the same\n        \/\/ registry key rather than copied into a second one.\n        \/\/\n        \/\/ Written as round(v * SCALE) \/ SCALE, NOT round(v \/ eps) * eps: the two\n        \/\/ disagree in the last bits for about 13% of values, which would give a\n        \/\/ different weld key and silently move the mesh score too. Measured.\n        const WELD_SCALE = 1 \/ this.cfg('factors.mesh.vertex_round_mm', 0.001);\n        const vertIds = new Int32Array(triangles.length * 3).fill(-1);\n        const vertMap = new Map();\n        \/\/ Numeric edge keys need a multiplier larger than any vertex id; ids are\n        \/\/ bounded by 3 per triangle, so this bound is exact and known upfront.\n        const VK = triangles.length * 3 + 1;\n        const vertexId = (ti, corner, v) => {\n            const slot = ti * 3 + corner;\n            if (vertIds[slot] >= 0) return vertIds[slot];\n            const k = (Math.round(v.x * WELD_SCALE) \/ WELD_SCALE) + ',' +\n                      (Math.round(v.y * WELD_SCALE) \/ WELD_SCALE) + ',' +\n                      (Math.round(v.z * WELD_SCALE) \/ WELD_SCALE);\n            let id = vertMap.get(k);\n            if (id === undefined) { id = vertMap.size; vertMap.set(k, id); }\n            vertIds[slot] = id;\n            return id;\n        };\n\n        let criticalOrientations = 0;\n        let maxOverhangAngle = 0;\n        let worstCritPct = 0;\n        let worstSpanMm = 0;\n        let worstPlatePct = 0;\n        \/\/ PR5: the BEST orientation is what decides the score - see the note at\n        \/\/ the scoring block below.\n        let bestCritPct = Infinity;\n        let bestSpanMm  = 0;\n        let bestRegions = 0;\n        let bestName    = '';\n\n        for (let oi = 0; oi < orientList.length; oi++) {\n            const dx = orientList[oi].d[0], dy = orientList[oi].d[1], dz = orientList[oi].d[2];\n            const plateProj = orientList[oi].plate;\n            \/\/ A face whose every vertex sits within this distance of the plate\n            \/\/ plane is lying on the plate. Absolute floor so a small part still\n            \/\/ gets a sane tolerance; relative term for very large parts.\n            const plateEps = Math.max(PLATE_EPS_MM, (orientList[oi].extent || 0) * PLATE_EPS_REL);\n            let platedArea = 0;\n            const candidates = [];\n\n            for (let i = 0; i < triangles.length; i++) {\n                const tri = triangles[i];\n                \/\/ Dot product of outward normal with build-down direction.\n                \/\/ Positive => normal aligns with \"down\" => face is an overhang.\n                const dot = tri.nx * dx + tri.ny * dy + tri.nz * dz;\n                if (dot <= 0) continue;\n\n                const clamped = dot > 1 ? 1 : dot;\n                \/\/ severity in degrees: 0 = vertical wall, 90 = horizontal floor\n                const severity = 90 - Math.acos(clamped) * RAD_TO_DEG;\n\n                if (severity >= CRITICAL_ANGLE_DEG) {\n                    \/\/ -- The base of the model is not an overhang. --\n                    \/\/ Only a near-horizontal face can lie flat on the plate, so\n                    \/\/ testing inside this branch keeps the cost off every\n                    \/\/ downward triangle while still catching every base face.\n                    const p1 = tri.v1.x * dx + tri.v1.y * dy + tri.v1.z * dz;\n                    const p2 = tri.v2.x * dx + tri.v2.y * dy + tri.v2.z * dz;\n                    const p3 = tri.v3.x * dx + tri.v3.y * dy + tri.v3.z * dz;\n                    if (Math.min(p1, p2, p3) >= plateProj - plateEps) {\n                        platedArea += tri.area;\n                        continue;   \/\/ rests on the plate - never supported, never an overhang\n                    }\n\n                    \/\/ NOTE: the span test is deliberately NOT applied here. It\n                    \/\/ is applied to the CONNECTED REGION these faces form - see\n                    \/\/ the clustering block below (PR4).\n                    candidates.push(i);\n                }\n\n                \/\/ Reached only by faces that are genuinely unsupported, so a\n                \/\/ clean box now correctly reports maxOverhangAngle = 0.\n                if (severity > maxOverhangAngle) maxOverhangAngle = severity;\n            }\n\n            \/\/ ---- PR4: CLUSTER THE UNSUPPORTED FACES INTO REGIONS -------------\n            \/\/ THE OLD RULE ASKED \"IS THIS ONE TRIANGLE 10mm WIDE?\", WHICH IS A\n            \/\/ QUESTION ABOUT THE EXPORT, NOT ABOUT THE PART. Measured on a\n            \/\/ sealed 60mm block with a 50x50x50 void, i.e. a 50x50mm\n            \/\/ unsupported roof that no orientation can avoid:\n            \/\/\n            \/\/     coarse CAD export (2 triangles per face)   -> 70\/100, warning\n            \/\/     fine mesh export  (200 triangles per face)  -> 88\/100, CLEAN\n            \/\/\n            \/\/ Same solid: same volume, surface, bounding box, normals and\n            \/\/ winding. The customer was told the part was fine purely because\n            \/\/ their exporter used a finer tolerance. The 3D cross fixture lost\n            \/\/ its critical note the same way, which would have silently\n            \/\/ disarmed the fixture that guards PR5.\n            \/\/\n            \/\/ So the span is measured on the connected REGION the unsupported\n            \/\/ faces form - which is what the rule was always reaching for.\n            \/\/ Triangles join a region when they share a welded edge.\n            \/\/\n            \/\/ The region's span is sqrt(regionArea): the exact generalisation of\n            \/\/ the per-triangle test one level up, so the registry threshold\n            \/\/ keeps its meaning (\"a patch about 10mm across\") and needed no\n            \/\/ recalibration. The alternative - the region's narrowest bounding\n            \/\/ extent - was rejected because it reads a ring or a U-shape wildly\n            \/\/ wrong, reporting the outer diameter of an annulus as its width.\n            let criticalArea   = 0;\n            let largestRegion  = 0;\n            let countedRegions = 0;\n\n            if (candidates.length > 0) {\n                const parent = new Int32Array(candidates.length);\n                for (let ci = 0; ci < candidates.length; ci++) parent[ci] = ci;\n                const find = x => {\n                    while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }\n                    return x;\n                };\n\n                \/\/ First candidate seen on an edge owns it; every later one joins.\n                \/\/ A non-manifold edge shared by three or more candidates still\n                \/\/ collapses to a single region, which is the right answer.\n                const edgeOwner = new Map();\n                for (let ci = 0; ci < candidates.length; ci++) {\n                    const ti = candidates[ci], t = triangles[ti];\n                    const va = vertexId(ti, 0, t.v1),\n                          vb = vertexId(ti, 1, t.v2),\n                          vc = vertexId(ti, 2, t.v3);\n                    const edges = [\n                        va < vb ? va * VK + vb : vb * VK + va,\n                        vb < vc ? vb * VK + vc : vc * VK + vb,\n                        vc < va ? vc * VK + va : va * VK + vc\n                    ];\n                    for (let k = 0; k < 3; k++) {\n                        const owner = edgeOwner.get(edges[k]);\n                        if (owner === undefined) {\n                            edgeOwner.set(edges[k], ci);\n                        } else {\n                            const ra = find(ci), rb = find(owner);\n                            if (ra !== rb) parent[rb] = ra;\n                        }\n                    }\n                }\n\n                const regionArea = new Map();\n                for (let ci = 0; ci < candidates.length; ci++) {\n                    const r = find(ci);\n                    regionArea.set(r, (regionArea.get(r) || 0) + triangles[candidates[ci]].area);\n                }\n                for (const areaSum of regionArea.values()) {\n                    if (Math.sqrt(areaSum) >= SPAN_THRESHOLD_MM) {\n                        criticalArea += areaSum;\n                        countedRegions++;\n                        if (areaSum > largestRegion) largestRegion = areaSum;\n                    }\n                }\n            }\n\n            const critPct  = totalSurfaceMm2 > 0 ? criticalArea \/ totalSurfaceMm2 : 0;\n            const platePct = totalSurfaceMm2 > 0 ? platedArea   \/ totalSurfaceMm2 : 0;\n            if (platePct > worstPlatePct) worstPlatePct = platePct;\n\n            \/\/ The cheapest way to stand this part up.\n            if (critPct < bestCritPct) {\n                bestCritPct = critPct;\n                bestSpanMm  = Math.sqrt(largestRegion);\n                bestRegions = countedRegions;\n                bestName    = orientList[oi].n;\n            }\n\n            \/\/ Kept as a diagnostic only - it no longer decides anything.\n            if (critPct > AREA_PCT_THRESHOLD) {\n                criticalOrientations++;\n                if (critPct > worstCritPct) {\n                    worstCritPct = critPct;\n                    worstSpanMm  = Math.sqrt(largestRegion);\n                }\n            }\n        }\n\n        if (!isFinite(bestCritPct)) bestCritPct = 0;\n\n        \/\/ \ud83d\udd34 THE SCORE COMES FROM THE BEST ORIENTATION, NOT FROM A COUNT OF BAD\n        \/\/ ONES (PR5, 2026-08-25). The workshop decides how a part stands on the\n        \/\/ plate; the customer's file has no \"up\". So the only honest question is\n        \/\/ how much support the CHEAPEST orientation still needs.\n        \/\/\n        \/\/ What it replaced counted how many of the six orientations were awkward.\n        \/\/ Measured on an 80-degree pyramid: 0.0% overhang printed base-down \u2014\n        \/\/ no support needed at all \u2014 yet it fired in the four side-on\n        \/\/ orientations nobody would ever choose and came out CRITICAL. Every\n        \/\/ part with sloped sides hit that: a cone, a boss, a chamfered block.\n        \/\/ Reported by the owner, who tested a 50-degree part.\n        \/\/\n        \/\/ \u26a0\ufe0f bestCritPct is a FRACTION; the registry is in PERCENT, because a\n        \/\/ human reading the screen should see \"2%\", not \"0.02\".\n        const bestPct = bestCritPct * 100;\n\n        const overhangScore = this.ladder('overhang', bestPct,\n            [[2, 35], [5, 26], [10, 14]], 'lte', 0);\n\n        \/\/ If some way of standing the part up needs no support, there is nothing\n        \/\/ to warn the customer about.\n        const isCritical = bestPct > this.cfg('factors.overhang.critical_pct_gt', 10);\n        const isWarning  = !isCritical && bestPct > this.cfg('factors.overhang.warning_pct_gt', 2);\n\n        return {\n            score: overhangScore,\n            data: {\n                \/\/ What the score and the notes are actually based on.\n                bestOrientation: bestName,\n                bestAreaPercent: bestPct,\n                \/\/ PR4: the span of the LARGEST connected unsupported region in\n                \/\/ the best orientation, and how many regions were large enough\n                \/\/ to count. bestSpanMm used to be sqrt(total critical area),\n                \/\/ which mixed unrelated patches into one meaningless number.\n                bestSpanMm:      bestSpanMm,\n                bestRegions:     bestRegions,\n                \/\/ Diagnostics. criticalAreaPercent is the WORST orientation and\n                \/\/ is no longer load-bearing \u2014 it is kept because it answers\n                \/\/ \"how much worse could this get if we stand it up wrong?\".\n                criticalOrientations,\n                maxOverhangAngle,\n                criticalAreaPercent: worstCritPct * 100,\n                spanEstimateMm: worstSpanMm,\n                \/\/ Share of surface discounted as \"resting on the build plate\".\n                \/\/ Diagnostic only - nothing branches on it; it is what a future\n                \/\/ test panel shows to explain why a flat-bottomed part is clean.\n                plateAreaPercent: worstPlatePct * 100,\n                skippedMultiOrientation: !fullCheck,\n                isCritical,\n                isWarning\n            }\n        };\n    }\n\n    \/**\n     * Grid ray-cast wall thickness. Shoots ~100 rays per axis (X\/Y\/Z),\n     * pairs ray-mesh entry\/exit hits as inside-segments. p5 (5th percentile)\n     * of segment lengths drives the score (catches thin tabs); median is the\n     * \"average\" thickness reported in the tip text.\n     * Falls back to 3V\/SA approximation when triangles unavailable or > cap.\n     * Returns { score: 0..20, data: { ... } }.\n     *\/\n    static analyzeThickness(triangles, dimensions, volumeCm3, surfaceAreaCm2, bbox) {\n        const THIN_CRITICAL_MM = this.cfg('factors.thickness.critical_below_mm', 0.6);\n        const THIN_WARNING_MM  = this.cfg('factors.thickness.warning_below_mm',  1.0);\n\n        const fallbackMm = surfaceAreaCm2 > 0\n            ? (3 * volumeCm3 * 1000) \/ (surfaceAreaCm2 * 100)\n            : 0;\n\n        const useGrid = triangles && triangles.length > 0 && triangles.length <= TDP_MAX_ANALYSIS_TRIANGLES;\n        if (!useGrid) {\n            return {\n                score: this.thicknessToScore(fallbackMm),\n                data: {\n                    avgThicknessMm: fallbackMm,\n                    minThicknessMm: fallbackMm,\n                    p5ThicknessMm:  fallbackMm,\n                    medianThicknessMm: fallbackMm,\n                    isCritical: fallbackMm < THIN_CRITICAL_MM,\n                    isWarning:  fallbackMm < THIN_WARNING_MM && fallbackMm >= THIN_CRITICAL_MM,\n                    method: 'approximation',\n                    sampleCount: 0\n                }\n            };\n        }\n\n        const W = dimensions.width;\n        const H = dimensions.height;\n        const D = dimensions.depth;\n        const TARGET_RAYS_PER_DIR = 100;\n        const segments = [];\n\n        \/\/ Pre-compute per-triangle 2D bboxes for quick rejection in each\n        \/\/ ray-direction pass. Three projection planes: XY (Z-rays), XZ (Y-rays), YZ (X-rays).\n        const minXa = new Float32Array(triangles.length);\n        const maxXa = new Float32Array(triangles.length);\n        const minYa = new Float32Array(triangles.length);\n        const maxYa = new Float32Array(triangles.length);\n        const minZa = new Float32Array(triangles.length);\n        const maxZa = new Float32Array(triangles.length);\n        for (let i = 0; i < triangles.length; i++) {\n            const t = triangles[i];\n            const x1 = t.v1.x, x2 = t.v2.x, x3 = t.v3.x;\n            const y1 = t.v1.y, y2 = t.v2.y, y3 = t.v3.y;\n            const z1 = t.v1.z, z2 = t.v2.z, z3 = t.v3.z;\n            minXa[i] = x1 < x2 ? (x1 < x3 ? x1 : x3) : (x2 < x3 ? x2 : x3);\n            maxXa[i] = x1 > x2 ? (x1 > x3 ? x1 : x3) : (x2 > x3 ? x2 : x3);\n            minYa[i] = y1 < y2 ? (y1 < y3 ? y1 : y3) : (y2 < y3 ? y2 : y3);\n            maxYa[i] = y1 > y2 ? (y1 > y3 ? y1 : y3) : (y2 > y3 ? y2 : y3);\n            minZa[i] = z1 < z2 ? (z1 < z3 ? z1 : z3) : (z2 < z3 ? z2 : z3);\n            maxZa[i] = z1 > z2 ? (z1 > z3 ? z1 : z3) : (z2 > z3 ? z2 : z3);\n        }\n\n        \/\/ Z-axis rays at (x, y), parameterized by t = z\n        const sZ = Math.max(5, Math.sqrt((W * H) \/ TARGET_RAYS_PER_DIR));\n        const nZx = Math.max(2, Math.floor(W \/ sZ));\n        const nZy = Math.max(2, Math.floor(H \/ sZ));\n        for (let i = 0; i < nZx; i++) {\n            const x = bbox.minX + (i + 0.5) * (W \/ nZx);\n            for (let j = 0; j < nZy; j++) {\n                const y = bbox.minY + (j + 0.5) * (H \/ nZy);\n                const hits = [];\n                for (let k = 0; k < triangles.length; k++) {\n                    if (x < minXa[k] || x > maxXa[k]) continue;\n                    if (y < minYa[k] || y > maxYa[k]) continue;\n                    const t = triangles[k];\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);\n                    if (denom < 1e-10 && denom > -1e-10) continue;\n                    const a = ((t.v2.y - t.v3.y) * (x - t.v3.x) + (t.v3.x - t.v2.x) * (y - t.v3.y)) \/ denom;\n                    if (a < -1e-9) continue;\n                    const b = ((t.v3.y - t.v1.y) * (x - t.v3.x) + (t.v1.x - t.v3.x) * (y - t.v3.y)) \/ denom;\n                    if (b < -1e-9) continue;\n                    const c = 1 - a - b;\n                    if (c < -1e-9) continue;\n                    hits.push(a * t.v1.z + b * t.v2.z + c * t.v3.z);\n                }\n                if (hits.length < 2) continue;\n                hits.sort((p, q) => p - q);\n                for (let m = 0; m + 1 < hits.length; m += 2) {\n                    const len = hits[m + 1] - hits[m];\n                    if (len > 0.05) segments.push(len);\n                }\n            }\n        }\n\n        \/\/ Y-axis rays at (x, z)\n        const sY = Math.max(5, Math.sqrt((W * D) \/ TARGET_RAYS_PER_DIR));\n        const nYx = Math.max(2, Math.floor(W \/ sY));\n        const nYz = Math.max(2, Math.floor(D \/ sY));\n        for (let i = 0; i < nYx; i++) {\n            const x = bbox.minX + (i + 0.5) * (W \/ nYx);\n            for (let j = 0; j < nYz; j++) {\n                const z = bbox.minZ + (j + 0.5) * (D \/ nYz);\n                const hits = [];\n                for (let k = 0; k < triangles.length; k++) {\n                    if (x < minXa[k] || x > maxXa[k]) continue;\n                    if (z < minZa[k] || z > maxZa[k]) continue;\n                    const t = triangles[k];\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);\n                    if (denom < 1e-10 && denom > -1e-10) continue;\n                    const a = ((t.v2.z - t.v3.z) * (x - t.v3.x) + (t.v3.x - t.v2.x) * (z - t.v3.z)) \/ denom;\n                    if (a < -1e-9) continue;\n                    const b = ((t.v3.z - t.v1.z) * (x - t.v3.x) + (t.v1.x - t.v3.x) * (z - t.v3.z)) \/ denom;\n                    if (b < -1e-9) continue;\n                    const c = 1 - a - b;\n                    if (c < -1e-9) continue;\n                    hits.push(a * t.v1.y + b * t.v2.y + c * t.v3.y);\n                }\n                if (hits.length < 2) continue;\n                hits.sort((p, q) => p - q);\n                for (let m = 0; m + 1 < hits.length; m += 2) {\n                    const len = hits[m + 1] - hits[m];\n                    if (len > 0.05) segments.push(len);\n                }\n            }\n        }\n\n        \/\/ X-axis rays at (y, z)\n        const sX = Math.max(5, Math.sqrt((H * D) \/ TARGET_RAYS_PER_DIR));\n        const nXy = Math.max(2, Math.floor(H \/ sX));\n        const nXz = Math.max(2, Math.floor(D \/ sX));\n        for (let i = 0; i < nXy; i++) {\n            const y = bbox.minY + (i + 0.5) * (H \/ nXy);\n            for (let j = 0; j < nXz; j++) {\n                const z = bbox.minZ + (j + 0.5) * (D \/ nXz);\n                const hits = [];\n                for (let k = 0; k < triangles.length; k++) {\n                    if (y < minYa[k] || y > maxYa[k]) continue;\n                    if (z < minZa[k] || z > maxZa[k]) continue;\n                    const t = triangles[k];\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);\n                    if (denom < 1e-10 && denom > -1e-10) continue;\n                    const a = ((t.v2.z - t.v3.z) * (y - t.v3.y) + (t.v3.y - t.v2.y) * (z - t.v3.z)) \/ denom;\n                    if (a < -1e-9) continue;\n                    const b = ((t.v3.z - t.v1.z) * (y - t.v3.y) + (t.v1.y - t.v3.y) * (z - t.v3.z)) \/ denom;\n                    if (b < -1e-9) continue;\n                    const c = 1 - a - b;\n                    if (c < -1e-9) continue;\n                    hits.push(a * t.v1.x + b * t.v2.x + c * t.v3.x);\n                }\n                if (hits.length < 2) continue;\n                hits.sort((p, q) => p - q);\n                for (let m = 0; m + 1 < hits.length; m += 2) {\n                    const len = hits[m + 1] - hits[m];\n                    if (len > 0.05) segments.push(len);\n                }\n            }\n        }\n\n        if (segments.length < 5) {\n            return {\n                score: this.thicknessToScore(fallbackMm),\n                data: {\n                    avgThicknessMm:    fallbackMm,\n                    minThicknessMm:    fallbackMm,\n                    p5ThicknessMm:     fallbackMm,\n                    medianThicknessMm: fallbackMm,\n                    isCritical: fallbackMm < THIN_CRITICAL_MM,\n                    isWarning:  fallbackMm < THIN_WARNING_MM && fallbackMm >= THIN_CRITICAL_MM,\n                    method: 'approximation_after_grid',\n                    sampleCount: segments.length\n                }\n            };\n        }\n\n        segments.sort((a, b) => a - b);\n        const minThicknessMm    = segments[0];\n        const p5ThicknessMm     = segments[Math.floor(segments.length * 0.05)];\n        const medianThicknessMm = segments[Math.floor(segments.length * 0.5)];\n\n        return {\n            score: this.thicknessToScore(p5ThicknessMm),\n            data: {\n                avgThicknessMm:    medianThicknessMm,\n                minThicknessMm:    minThicknessMm,\n                p5ThicknessMm:     p5ThicknessMm,\n                medianThicknessMm: medianThicknessMm,\n                isCritical: p5ThicknessMm < THIN_CRITICAL_MM,\n                isWarning:  p5ThicknessMm < THIN_WARNING_MM && p5ThicknessMm >= THIN_CRITICAL_MM,\n                method: 'grid_sampling',\n                sampleCount: segments.length\n            }\n        };\n    }\n}\n\nclass AccurateSTLVolumeCalculator {\n    static calculateVolumeAndSurface(arrayBuffer) {\n        try {\n            const data = new DataView(arrayBuffer);\n            if (arrayBuffer.byteLength < 84) {\n                throw new Error('Fichier trop petit pour \u00eatre un STL valide');\n            }\n\n            let minX = Infinity, minY = Infinity, minZ = Infinity;\n            let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\n            let totalVolume = 0;\n            let totalSurfaceArea = 0;\n            let triangleCount = 0;\n            \n            const header = new TextDecoder().decode(new DataView(arrayBuffer, 0, 80));\n            const isAscii = this.isAsciiSTL(header, arrayBuffer);\n\n            if (isAscii) {\n                const result = this.processAsciiSTL(arrayBuffer);\n                return this.finalizeVolumeAndSurface(result);\n            } else {\n                const result = this.processBinarySTL(data, arrayBuffer);\n                return this.finalizeVolumeAndSurface(result);\n            }\n            \n        } catch (error) {\n            console.error('Erreur dans le calcul du volume et surface:', error);\n            return this.estimateVolumeAndSurfaceFromFileSize(arrayBuffer.byteLength);\n        }\n    }\n\n    static isAsciiSTL(header, arrayBuffer) {\n        const headerLower = header.toLowerCase();\n        const hasSolid = headerLower.includes('solid');\n        const hasFacet = headerLower.includes('facet');\n        \n        if (hasSolid && hasFacet) return true;\n        \n        if (arrayBuffer.byteLength >= 84) {\n            const triangleCount = new DataView(arrayBuffer).getUint32(80, true);\n            const expectedSize = 84 + (triangleCount * 50);\n            if (Math.abs(arrayBuffer.byteLength - expectedSize) <= 2) return false;\n        }\n        \n        return hasSolid;\n    }\n\n    static processAsciiSTL(arrayBuffer) {\n        const text = new TextDecoder().decode(arrayBuffer);\n        let minX = Infinity, minY = Infinity, minZ = Infinity;\n        let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\n        let totalVolume = 0;\n        let totalSurfaceArea = 0;\n        let absoluteVolume = 0;\n        let triangleCount = 0;\n\n        const vertexRegex = \/vertex\\s+([-\\d.eE]+)\\s+([-\\d.eE]+)\\s+([-\\d.eE]+)\/gi;\n        let match;\n        const foundVertices = [];\n\n        while ((match = vertexRegex.exec(text)) !== null) {\n            const x = parseFloat(match[1]);\n            const y = parseFloat(match[2]);\n            const z = parseFloat(match[3]);\n\n            if (isNaN(x) || isNaN(y) || isNaN(z)) continue;\n\n            foundVertices.push({x, y, z});\n            if (x < minX) minX = x; if (x > maxX) maxX = x;\n            if (y < minY) minY = y; if (y > maxY) maxY = y;\n            if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;\n        }\n\n        const expectedTriangles = Math.floor(foundVertices.length \/ 3);\n        const storeTriangles = expectedTriangles <= TDP_MAX_ANALYSIS_TRIANGLES;\n        const triangles = storeTriangles ? [] : null;\n\n        for (let i = 0; i + 2 < foundVertices.length; i += 3) {\n            const v1 = foundVertices[i];\n            const v2 = foundVertices[i + 1];\n            const v3 = foundVertices[i + 2];\n\n            const ex = v2.x - v1.x, ey = v2.y - v1.y, ez = v2.z - v1.z;\n            const fx = v3.x - v1.x, fy = v3.y - v1.y, fz = v3.z - v1.z;\n            const cx = ey * fz - ez * fy;\n            const cy = ez * fx - ex * fz;\n            const cz = ex * fy - ey * fx;\n            const cMag = Math.sqrt(cx * cx + cy * cy + cz * cz);\n            const area = cMag * 0.5;\n\n            const tetra = (v1.x * (v2.y * v3.z - v2.z * v3.y) +\n                           v1.y * (v2.z * v3.x - v2.x * v3.z) +\n                           v1.z * (v2.x * v3.y - v2.y * v3.x)) \/ 6.0;\n\n            totalVolume      += tetra;\n            absoluteVolume   += Math.abs(tetra);\n            totalSurfaceArea += area;\n            triangleCount++;\n\n            if (storeTriangles) {\n                triangles.push({\n                    v1, v2, v3,\n                    nx: cMag > 0 ? cx \/ cMag : 0,\n                    ny: cMag > 0 ? cy \/ cMag : 0,\n                    nz: cMag > 0 ? cz \/ cMag : 1,\n                    area: area\n                });\n            }\n        }\n\n        return {\n            minX, minY, minZ, maxX, maxY, maxZ,\n            totalVolume, totalSurfaceArea, absoluteVolume,\n            triangleCount,\n            triangles,\n            estimated: false,\n            accurate: triangleCount > 0\n        };\n    }\n\n    static processBinarySTL(data, arrayBuffer) {\n        let minX = Infinity, minY = Infinity, minZ = Infinity;\n        let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\n        let totalVolume = 0;\n        let totalSurfaceArea = 0;\n        let absoluteVolume = 0;\n\n        let triangleCount = data.getUint32(80, true);\n        const maxReasonableTriangles = (arrayBuffer.byteLength - 84) \/ 50;\n        if (triangleCount > maxReasonableTriangles) {\n            triangleCount = data.getUint32(80, false);\n            if (triangleCount > maxReasonableTriangles) throw new Error('Nombre de triangles invalide');\n        }\n\n        \/\/ Only retain triangles for deep analysis if the count is within the\n        \/\/ performance budget. For huge meshes we still parse the whole file\n        \/\/ to compute volume\/surface accurately, but we don't keep the raw\n        \/\/ triangles in memory.\n        const storeTriangles = triangleCount <= TDP_MAX_ANALYSIS_TRIANGLES;\n        const triangles = storeTriangles ? new Array(triangleCount) : null;\n\n        let offset = 84;\n        for (let i = 0; i < triangleCount; i++) {\n            if (offset + 48 > arrayBuffer.byteLength) { triangleCount = i; break; }\n            offset += 12; \/\/ skip the file's normal \u2014 we recompute it from vertices\n\n            const v1x = data.getFloat32(offset,      true);\n            const v1y = data.getFloat32(offset + 4,  true);\n            const v1z = data.getFloat32(offset + 8,  true);\n            const v2x = data.getFloat32(offset + 12, true);\n            const v2y = data.getFloat32(offset + 16, true);\n            const v2z = data.getFloat32(offset + 20, true);\n            const v3x = data.getFloat32(offset + 24, true);\n            const v3y = data.getFloat32(offset + 28, true);\n            const v3z = data.getFloat32(offset + 32, true);\n\n            if (isNaN(v1x) || isNaN(v1y) || isNaN(v1z) || isNaN(v2x) || isNaN(v2y) || isNaN(v2z) || isNaN(v3x) || isNaN(v3y) || isNaN(v3z)) {\n                throw new Error('Donn\u00e9es de vertex invalides');\n            }\n\n            \/\/ Bounding box\n            if (v1x < minX) minX = v1x; if (v1x > maxX) maxX = v1x;\n            if (v1y < minY) minY = v1y; if (v1y > maxY) maxY = v1y;\n            if (v1z < minZ) minZ = v1z; if (v1z > maxZ) maxZ = v1z;\n            if (v2x < minX) minX = v2x; if (v2x > maxX) maxX = v2x;\n            if (v2y < minY) minY = v2y; if (v2y > maxY) maxY = v2y;\n            if (v2z < minZ) minZ = v2z; if (v2z > maxZ) maxZ = v2z;\n            if (v3x < minX) minX = v3x; if (v3x > maxX) maxX = v3x;\n            if (v3y < minY) minY = v3y; if (v3y > maxY) maxY = v3y;\n            if (v3z < minZ) minZ = v3z; if (v3z > maxZ) maxZ = v3z;\n\n            \/\/ Edge vectors v1\u2192v2 and v1\u2192v3\n            const ex = v2x - v1x, ey = v2y - v1y, ez = v2z - v1z;\n            const fx = v3x - v1x, fy = v3y - v1y, fz = v3z - v1z;\n\n            \/\/ Cross product (e \u00d7 f) = 2 \u00d7 area-vector, also serves as un-normalized normal\n            const cx = ey * fz - ez * fy;\n            const cy = ez * fx - ex * fz;\n            const cz = ex * fy - ey * fx;\n            const cMag = Math.sqrt(cx * cx + cy * cy + cz * cz);\n            const area = cMag * 0.5;\n\n            \/\/ Signed tetrahedron volume from origin (same formula as before)\n            const tetra = (v1x * (v2y * v3z - v2z * v3y) +\n                           v1y * (v2z * v3x - v2x * v3z) +\n                           v1z * (v2x * v3y - v2y * v3x)) \/ 6.0;\n\n            totalVolume      += tetra;\n            absoluteVolume   += Math.abs(tetra);\n            totalSurfaceArea += area;\n\n            if (storeTriangles) {\n                triangles[i] = {\n                    v1: { x: v1x, y: v1y, z: v1z },\n                    v2: { x: v2x, y: v2y, z: v2z },\n                    v3: { x: v3x, y: v3y, z: v3z },\n                    nx: cMag > 0 ? cx \/ cMag : 0,\n                    ny: cMag > 0 ? cy \/ cMag : 0,\n                    nz: cMag > 0 ? cz \/ cMag : 1,\n                    area: area\n                };\n            }\n\n            offset += 36 + 2; \/\/ 9 floats + attribute byte count\n        }\n\n        \/\/ If we overran (offset issue truncated the loop), trim the array.\n        if (storeTriangles && triangles.length !== triangleCount) {\n            triangles.length = triangleCount;\n        }\n\n        return {\n            minX, minY, minZ, maxX, maxY, maxZ,\n            totalVolume, totalSurfaceArea, absoluteVolume,\n            triangleCount,\n            triangles,\n            estimated: false,\n            accurate: true\n        };\n    }\n\n    static calculateTetrahedronVolume(v1, v2, v3) {\n        const crossX = (v2.y * v3.z) - (v2.z * v3.y);\n        const crossY = (v2.z * v3.x) - (v2.x * v3.z);\n        const crossZ = (v2.x * v3.y) - (v2.y * v3.x);\n        const dot = (v1.x * crossX) + (v1.y * crossY) + (v1.z * crossZ);\n        return dot \/ 6.0;\n    }\n\n    static calculateTriangleArea(v1, v2, v3) {\n        const ab = { x: v2.x - v1.x, y: v2.y - v1.y, z: v2.z - v1.z };\n        const ac = { x: v3.x - v1.x, y: v3.y - v1.y, z: v3.z - v1.z };\n        \n        const crossX = ab.y * ac.z - ab.z * ac.y;\n        const crossY = ab.z * ac.x - ab.x * ac.z;\n        const crossZ = ab.x * ac.y - ab.y * ac.x;\n        \n        const magnitude = Math.sqrt(crossX * crossX + crossY * crossY + crossZ * crossZ);\n        return magnitude \/ 2.0;\n    }\n\n    static finalizeVolumeAndSurface(result) {\n        const {\n            minX, minY, minZ, maxX, maxY, maxZ,\n            totalVolume, totalSurfaceArea, absoluteVolume,\n            triangleCount, triangles,\n            estimated, accurate\n        } = result;\n\n        const dimensions = {\n            width:  Math.max(maxX - minX, 0.001),\n            height: Math.max(maxY - minY, 0.001),\n            depth:  Math.max(maxZ - minZ, 0.001)\n        };\n\n        const actualVolumeMm3 = Math.abs(totalVolume);            \/\/ mm\u00b3\n        const actualSurfaceMm2 = Math.abs(totalSurfaceArea);      \/\/ mm\u00b2\n        const actualVolume     = actualVolumeMm3 \/ 1000;          \/\/ cm\u00b3 (for display)\n        const actualSurfaceArea = actualSurfaceMm2 \/ 100;         \/\/ cm\u00b2 (for display)\n        const boundingBoxVolume = (dimensions.width * dimensions.height * dimensions.depth) \/ 1000;\n        let finalVolume = Math.max(actualVolume, 0.001);\n        let finalSurfaceArea = Math.max(actualSurfaceArea, 0.001);\n\n        \/\/ \ud83d\udd34 THE VOLUME CAME OUT AS GOOD AS ZERO ON A PART THAT CLEARLY HAS A\n        \/\/ SIZE \u2014 so we know its dimensions but not how much material it needs,\n        \/\/ and material is what the price is made of.\n        \/\/\n        \/\/ This used to replace the volume with boundingBox x 0.3 and quote that.\n        \/\/ It no longer does (2026-08-25, owner's instruction): the volume stays\n        \/\/ zero, the line contributes nothing to the total, and the customer is\n        \/\/ told we could read the size but not the volume, and asked to send the\n        \/\/ file to the team. The dimensions ARE real here, which is why this gets\n        \/\/ its own note rather than sharing the \"we could not read it\" one.\n        let volumeRescued = false;\n        if (actualVolume < 0.01 && boundingBoxVolume > 1.0) {\n            finalVolume = 0;\n            finalSurfaceArea = 0;\n            volumeRescued = true;\n        }\n\n        const efficiency = actualVolume > 0 ? (actualVolume \/ boundingBoxVolume) * 100 : 0;\n\n        \/\/ ===== Factor 1 \u2014 Aspect Ratio (15 pts) =====\n        \/\/ Thresholds come from config\/printability-rules.json via the analyzer's\n        \/\/ registry reader \u2014 this class has no reader of its own on purpose, so\n        \/\/ there is exactly one.\n        const R = TDPGeometryAnalyzer;\n\n        const sortedDims = [dimensions.width, dimensions.height, dimensions.depth].sort((a, b) => a - b);\n        const aspectRatio = sortedDims[2] \/ Math.max(sortedDims[0], 0.001);\n        const aspectScore = R.ladder('aspect', aspectRatio, [[5, 15], [10, 10], [20, 5]], 'lte', 0);\n\n        \/\/ ===== Factor 2 \u2014 Volume Efficiency (15 pts) =====\n        const isFlatObject = aspectRatio > R.cfg('factors.efficiency.flat_object.aspect_ratio_gt', 8)\n                          && efficiency   > R.cfg('factors.efficiency.flat_object.efficiency_gt', 50);\n        const efficiencyScore = isFlatObject\n            ? R.cfg('factors.efficiency.flat_object.score', 12)\n            : R.ladder('efficiency', efficiency, [[50, 15], [30, 11], [15, 6]], 'gte', 2);\n\n        \/\/ ===== Factor 3 \u2014 Overhang Severity (30 pts) =====\n        \/\/ The bbox is what lets the analyzer discount the face resting on the\n        \/\/ build plate. Without it every solid reports overhangs in all six\n        \/\/ orientations - see the note above analyzeOverhangs().\n        const overhang = TDPGeometryAnalyzer.analyzeOverhangs(\n            triangles,\n            actualSurfaceMm2,\n            { minX, minY, minZ, maxX, maxY, maxZ }\n        );\n        const overhangScore = overhang ? overhang.score : 22; \/\/ neutral if no triangles\n        const overhangData  = overhang ? overhang.data : {\n            bestOrientation: '',\n            bestAreaPercent: 0,\n            bestSpanMm: 0,\n            bestRegions: 0,\n            criticalOrientations: 0,\n            maxOverhangAngle: 0,\n            criticalAreaPercent: 0,\n            spanEstimateMm: 0,\n            plateAreaPercent: 0,\n            skippedMultiOrientation: triangles ? false : true,\n            isCritical: false,\n            isWarning: false\n        };\n\n        \/\/ ===== Factor 4 \u2014 Wall Thickness (20 pts) =====\n        const thickness = TDPGeometryAnalyzer.analyzeThickness(\n            triangles,\n            dimensions,\n            actualVolume,\n            actualSurfaceArea,\n            { minX, minY, minZ, maxX, maxY, maxZ }\n        );\n        const thicknessScore = thickness.score;\n        const thicknessData  = thickness.data;\n\n        \/\/ ===== Factor 5 \u2014 Mesh Integrity (20 pts) =====\n        const integrity = TDPGeometryAnalyzer.analyzeMeshIntegrity(\n            triangles,\n            totalVolume,\n            absoluteVolume !== undefined ? absoluteVolume : Math.abs(totalVolume)\n        );\n        const meshScore = integrity ? integrity.score : 14; \/\/ neutral if no triangles\n        const meshData  = integrity ? integrity.data : {\n            nonManifoldEdges: 0,\n            openEdges: 0,\n            volumeConsistency: 1,\n            isCritical: false,\n            isWarning: false\n        };\n\n        const totalScore = aspectScore + efficiencyScore + overhangScore + thicknessScore + meshScore;\n\n        \/\/ ===== Map score to level =====\n        \/\/ printabilityText reads from i18n at call time so it follows the active language.\n        let printability, printabilityText, printabilityReason;\n        if (totalScore >= R.cfg('bands.excellent', 70)) {\n            printability = 'excellent'; printabilityText = tdpI18nText('printabilityExcellent', 'excellent');\n            printabilityReason = 'Well-proportioned, clean geometry, solid walls';\n        } else if (totalScore >= R.cfg('bands.good', 45)) {\n            printability = 'good'; printabilityText = tdpI18nText('printabilityGood', 'good');\n            printabilityReason = 'Geometry suitable for printing';\n        } else if (totalScore >= R.cfg('bands.fair', 25)) {\n            printability = 'fair'; printabilityText = tdpI18nText('printabilityFair', 'fair');\n            printabilityReason = 'Some printability concerns';\n        } else {\n            printability = 'poor'; printabilityText = tdpI18nText('printabilityPoor', 'poor');\n            printabilityReason = 'Multiple printability issues';\n        }\n\n        \/\/ \ud83d\udd34 TWO HARD FLOORS WERE REMOVED ON 2026-08-25, and they were doing\n        \/\/ more damage than the notes.\n        \/\/\n        \/\/   'mesh_critical caps at Fair' \u2014 parts scoring 88, 92 and 94 out of\n        \/\/   100 all DISPLAYED as Fair, purely because of the volume-agreement\n        \/\/   number now known to be anti-correlated with mesh health.\n        \/\/\n        \/\/   'efficiency < 10% forces Poor' \u2014 normal for any thin-walled hollow\n        \/\/   part; a 100mm sphere with 1mm walls sits near 6%. It handed the most\n        \/\/   discouraging verdict on the site to a perfectly printable object, on\n        \/\/   the strength of a factor this project's own audit calls a proxy for\n        \/\/   nothing.\n        \/\/\n        \/\/ Owner's instruction, 2026-08-25: the printability verdict must never\n        \/\/ read as a reason not to order. What a broken mesh costs is now the\n        \/\/ mesh factor's 10 points, and nothing more.\n        \/\/\n        \/\/ Two floors remain, BOTH capping at Good rather than Fair, so the\n        \/\/ verdict stays encouraging while refusing to call an unverifiable part\n        \/\/ perfect. Walls under 0.6mm are a real print risk; a file that is not a\n        \/\/ closed solid cannot be priced or printed correctly, and with mesh now\n        \/\/ worth only 10 points an open surface model would otherwise score\n        \/\/ 97\/100 and display EXCELLENT.\n        if (meshData.isCritical && printability === 'excellent') {\n            printability = 'good';\n            printabilityText = tdpI18nText('printabilityGood', 'good');\n        }\n        if (thicknessData.isCritical && printability === 'excellent') {\n            printability = 'good';\n            printabilityText = tdpI18nText('printabilityGood', 'good');\n        }\n\n        return {\n            volume: finalVolume,\n            surfaceArea: finalSurfaceArea,\n            dimensions,\n            triangleCount,\n            boundingBoxVolume,\n            printability,\n            printabilityText,\n            printabilityReason,\n            estimated,\n            accurate,\n            volumeRescued,\n            \/\/ Nothing downstream may price this line. Read by updatePricesPro()\n            \/\/ and by the add-to-cart guard.\n            noQuote: volumeRescued,\n            printabilityScore: totalScore,\n            printabilityFactors: {\n                aspectScore, efficiencyScore,\n                overhangScore, thicknessScore, meshScore,\n                aspectRatio, isFlatObject, efficiency\n            },\n            overhangData,\n            thicknessData,\n            meshData\n        };\n    }\n\n    \/**\n     * The file could not be parsed at all.\n     *\n     * \ud83d\udd34 IT NO LONGER RETURNS A PRICE, AND THAT IS THE POINT (2026-08-25).\n     * It used to derive a volume from the FILE SIZE \u2014 bytes \/ 50 for a triangle\n     * count, then a cube root \u2014 and hand that back as a quote labelled\n     * \"Printability: Fair (Estimated)\". Owner: \"how can we give quotation if we\n     * are not able to read the file \u2026 quotation shouldn't be given if we can't\n     * estimate the volume and basic information\". He is right: that number was\n     * invented, and a customer could place an order against it.\n     *\n     * So volume, surface and dimensions come back as ZERO and `noQuote` is set.\n     * Everything downstream already knows what to do with that: updatePricesPro()\n     * contributes 0 to the total, TDP_Pricing::calculate_order_total() skips any\n     * line with volume <= 0 server-side, and the note asks the customer to send\n     * the file to the team, who quote it by hand.\n     *\n     * \u26a0\ufe0f The SHAPE of the returned object is unchanged, deliberately \u2014 every\n     * caller reads `dimensions.width` and `volume` without guarding first.\n     *\/\n    static estimateVolumeAndSurfaceFromFileSize(fileSize) {\n        return {\n            volume: 0,\n            surfaceArea: 0,\n            dimensions: { width: 0, height: 0, depth: 0 },\n            triangleCount: 0,\n            boundingBoxVolume: 0,\n            \/\/ No verdict, because nothing was measured. The bar is hidden rather\n            \/\/ than shown at some invented level.\n            printability: 'unknown',\n            printabilityText: tdpI18nText('printabilityUnknown', 'Printability: not measured'),\n            printabilityReason: 'The file could not be read',\n            estimated: true,\n            unreadable: true,\n            noQuote: true,\n            accurate: false,\n            printabilityScore: 40,\n            printabilityFactors: {\n                aspectScore:     15,\n                efficiencyScore: 0,\n                overhangScore:   15,\n                thicknessScore:  10,\n                meshScore:       0,\n                aspectRatio:     1,\n                isFlatObject:    false,\n                efficiency:      0\n            },\n            overhangData: {\n                bestOrientation: '',\n                bestAreaPercent: 0,\n                bestSpanMm: 0,\n                bestRegions: 0,\n                criticalOrientations: 0,\n                maxOverhangAngle: 0,\n                criticalAreaPercent: 0,\n                spanEstimateMm: 0,\n                plateAreaPercent: 0,\n                skippedMultiOrientation: true,\n                isCritical: false,\n                isWarning: false\n            },\n            thicknessData: {\n                avgThicknessMm: 0,\n                minThicknessMm: 0,\n                p5ThicknessMm: 0,\n                medianThicknessMm: 0,\n                isCritical: false,\n                isWarning: false,\n                method: 'estimated_from_filesize',\n                sampleCount: 0\n            },\n            meshData: {\n                nonManifoldEdges: 0,\n                openEdges: 0,\n                volumeConsistency: 0,\n                isCritical: false,\n                isWarning: true\n            }\n        };\n    }\n}\n\n\n\/\/ =====================================================================\n\/\/ WHICH NOTE FIRES \u2014 the decision layer\n\/\/ =====================================================================\n\n\/**\n * Decide the ONE design note a result earns. Returns an id, never text.\n *\n * \ud83d\udd34 REWRITTEN 2026-08-25 ON THE OWNER'S INSTRUCTION. What it replaced could\n * concatenate up to three sentences, and the mesh sentence \u2014 \"Our analysis\n * flagged a few points on this design\" \u2014 fired on 9 of the 19 regression\n * fixtures, as often as \"everything is fine\". His words: *\"this message is\n * repetitive in most of the results and weak even good printable parts\"*, and\n * *\"try to combine notes in only one \u2026 always try to have a message less than\n * 2 lines\"*.\n *\n * The four rules it now follows, in his order of priority:\n *\n *   1. ONE note. Never a paragraph. Every message is a single sentence that\n *      fits in two lines.\n *   2. IT MUST NEVER READ AS A REASON NOT TO ORDER. *\"this printability\n *      shouldn't be a marketing blocking action, user should always be\n *      confident that we solve everything\"*. So a minor finding says we handle\n *      it and nothing is needed; a serious one says place the order anyway and\n *      we will make contact.\n *   3. MESH FAULTS ARE MOSTLY THE FILE, NOT THE DESIGN, so they are silent\n *      unless the file is genuinely not a closed solid. There is deliberately\n *      no `minor_mesh`.\n *   4. IF WE CANNOT PRICE IT HONESTLY, SAY SO AND GET NOTIFIED. That is the one\n *      case where the team is alerted (an order note at checkout) and where we\n *      promise a call.\n *\n * \u26a0\ufe0f When more than one thing fires, the note becomes the generic \"several\"\n * wording rather than gluing subjects together. Building \"thin walls and\n * overhangs\" from fragments is exactly the sentence-splitting this project\n * forbids: French and Arabic word order and agreement do not survive it. Each\n * id below is one complete, separately translated sentence.\n *\n * @param  {object} result  a finalizeVolumeAndSurface() result\n * @return {{level:string, id:string, subjects:string[], severity:string,\n *          canQuote:boolean, all:string[]}}\n *\/\nfunction tdpEvaluateNotes(result) {\n    const level = result.printability;\n    const ov = result.overhangData  || {};\n    const th = result.thicknessData || {};\n    const me = result.meshData      || {};\n\n    \/\/ Could we price this honestly at all? Rule 4.\n    \/\/\n    \/\/ \u26a0\ufe0f Two different answers, because they are two different conversations.\n    \/\/ `quote_unreadable` means we know nothing \u2014 not even how big it is.\n    \/\/ `quote_no_volume` means we read the shape but its volume is degenerate, so\n    \/\/ we can state the size and only the price is missing. Owner, 2026-08-25:\n    \/\/ \"if these basic information are possible to know, let's change the message\n    \/\/ to avoid saying we are quoting without knowing what he uploaded\".\n    const unreadable = result.unreadable || (result.triangleCount || 0) === 0;\n    const noVolume   = !unreadable && !!result.volumeRescued;\n    const canQuote   = !unreadable && !noVolume;\n\n    let id;\n    const subjects = [];\n    let severity = 'none';\n\n    if (!canQuote) {\n        id = unreadable ? 'quote_unreadable' : 'quote_no_volume';\n        severity = 'major';\n        subjects.push('quote');\n\n    } else {\n        const major = [];\n        const minor = [];\n\n        if (ov.isCritical)     major.push('support');\n        else if (ov.isWarning) minor.push('support');\n\n        if (th.isCritical)     major.push('thin');\n        else if (th.isWarning) minor.push('thin');\n\n        \/\/ Rule 3: a mesh WARNING says nothing. Only \"this is not a closed\n        \/\/ solid\" is worth a customer's attention, because it is the one mesh\n        \/\/ fault that also makes the price wrong.\n        if (me.isCritical)     major.push('mesh');\n\n        Array.prototype.push.apply(subjects, major);\n        Array.prototype.push.apply(subjects, minor);\n\n        if (subjects.length === 0) {\n            id = 'ready';\n        } else if (major.length > 0) {\n            severity = 'major';\n            id = (subjects.length === 1) ? 'major_' + major[0] : 'major_several';\n        } else {\n            severity = 'minor';\n            id = (subjects.length === 1) ? 'minor_' + minor[0] : 'minor_several';\n        }\n    }\n\n    return {\n        level: level,\n        id: id,\n        subjects: subjects,\n        severity: severity,\n        canQuote: canQuote,\n        \/\/ Kept as an array so PR6's stored shape and every caller are unchanged.\n        \/\/ It now always holds exactly one id.\n        all: [ id ]\n    };\n}\n\n\/\/ =====================================================================\n\/\/ CALCULATEUR DE VOLUME ET SURFACE OBJ\n\/\/ =====================================================================\n\nclass AccurateOBJVolumeCalculator {\n    static calculateVolumeAndSurface(arrayBuffer) {\n        try {\n            const text  = new TextDecoder().decode(arrayBuffer);\n            const lines = text.split('\\n');\n\n            const vertices = [];\n            const faces    = [];\n\n            for (const line of lines) {\n                const trimmed = line.trim();\n                if (!trimmed || trimmed.startsWith('#')) continue;\n\n                const parts = trimmed.split(\/\\s+\/);\n                if (parts[0] === 'v') {\n                    \/\/ Geometric vertex: v x y z [w]\n                    const x = parseFloat(parts[1]);\n                    const y = parseFloat(parts[2]);\n                    const z = parseFloat(parts[3]);\n                    if (!isNaN(x) && !isNaN(y) && !isNaN(z)) {\n                        vertices.push({ x, y, z });\n                    }\n                } else if (parts[0] === 'f') {\n                    \/\/ Face: indices are 1-based; tokens may be \"v\", \"v\/vt\", or \"v\/vt\/vn\"\n                    const faceVerts = parts.slice(1).map(p => parseInt(p.split('\/')[0], 10) - 1);\n                    \/\/ Fan-triangulate from first vertex (handles triangles, quads, n-gons)\n                    for (let i = 1; i < faceVerts.length - 1; i++) {\n                        faces.push([ faceVerts[0], faceVerts[i], faceVerts[i + 1] ]);\n                    }\n                }\n            }\n\n            if (vertices.length === 0 || faces.length === 0) {\n                throw new Error('No geometry found in OBJ file');\n            }\n\n            let minX = Infinity, minY = Infinity, minZ = Infinity;\n            let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;\n            let totalVolume      = 0;\n            let totalSurfaceArea = 0;\n            let absoluteVolume   = 0;\n\n            const storeTriangles = faces.length <= TDP_MAX_ANALYSIS_TRIANGLES;\n            const triangles = storeTriangles ? [] : null;\n            let triangleCount = 0;\n\n            for (const [i0, i1, i2] of faces) {\n                const v1 = vertices[i0];\n                const v2 = vertices[i1];\n                const v3 = vertices[i2];\n                if (!v1 || !v2 || !v3) continue;\n\n                \/\/ Bounding box update\n                if (v1.x < minX) minX = v1.x; if (v1.x > maxX) maxX = v1.x;\n                if (v1.y < minY) minY = v1.y; if (v1.y > maxY) maxY = v1.y;\n                if (v1.z < minZ) minZ = v1.z; if (v1.z > maxZ) maxZ = v1.z;\n                if (v2.x < minX) minX = v2.x; if (v2.x > maxX) maxX = v2.x;\n                if (v2.y < minY) minY = v2.y; if (v2.y > maxY) maxY = v2.y;\n                if (v2.z < minZ) minZ = v2.z; if (v2.z > maxZ) maxZ = v2.z;\n                if (v3.x < minX) minX = v3.x; if (v3.x > maxX) maxX = v3.x;\n                if (v3.y < minY) minY = v3.y; if (v3.y > maxY) maxY = v3.y;\n                if (v3.z < minZ) minZ = v3.z; if (v3.z > maxZ) maxZ = v3.z;\n\n                const ex = v2.x - v1.x, ey = v2.y - v1.y, ez = v2.z - v1.z;\n                const fx = v3.x - v1.x, fy = v3.y - v1.y, fz = v3.z - v1.z;\n                const cx = ey * fz - ez * fy;\n                const cy = ez * fx - ex * fz;\n                const cz = ex * fy - ey * fx;\n                const cMag = Math.sqrt(cx * cx + cy * cy + cz * cz);\n                const area = cMag * 0.5;\n\n                const tetra = (v1.x * (v2.y * v3.z - v2.z * v3.y) +\n                               v1.y * (v2.z * v3.x - v2.x * v3.z) +\n                               v1.z * (v2.x * v3.y - v2.y * v3.x)) \/ 6.0;\n\n                totalVolume      += tetra;\n                absoluteVolume   += Math.abs(tetra);\n                totalSurfaceArea += area;\n                triangleCount++;\n\n                if (storeTriangles) {\n                    triangles.push({\n                        v1, v2, v3,\n                        nx: cMag > 0 ? cx \/ cMag : 0,\n                        ny: cMag > 0 ? cy \/ cMag : 0,\n                        nz: cMag > 0 ? cz \/ cMag : 1,\n                        area: area\n                    });\n                }\n            }\n\n            return AccurateSTLVolumeCalculator.finalizeVolumeAndSurface({\n                minX, minY, minZ, maxX, maxY, maxZ,\n                totalVolume, totalSurfaceArea, absoluteVolume,\n                triangleCount,\n                triangles,\n                estimated: false,\n                accurate:  triangleCount > 0,\n            });\n\n        } catch (error) {\n            console.error('OBJ volume calculation error:', error);\n            return AccurateSTLVolumeCalculator.estimateVolumeAndSurfaceFromFileSize(arrayBuffer.byteLength);\n        }\n    }\n}\n\n\/\/ =====================================================================\n\/\/ VISIONNEUSE 3D COMPL\u00c8TE\n\/\/ =====================================================================\n\nclass Real3DViewer {\n    static viewers = new Map();\n    \n    static initViewer(containerId, fileData, fileName, dimensions, initialColor = 0xdc3545) {\n        const container = document.getElementById(containerId);\n        container.innerHTML = `<div class=\"viewer-placeholder-pro\"><div style=\"font-size: 32px; margin-bottom: 10px;\">\ud83d\udd04<\/div><div>${i18n.loadingViewer}<\/div><\/div>`;\n\n        const isOBJ = fileName.toLowerCase().endsWith('.obj');\n        const checkThreeJS = setInterval(() => {\n            \/\/ For OBJ files also wait for OBJLoader; for STL wait for STLLoader\n            const baseReady = typeof THREE !== 'undefined' && typeof THREE.OrbitControls !== 'undefined';\n            const loaderReady = isOBJ\n                ? baseReady && typeof THREE.OBJLoader !== 'undefined'\n                : baseReady && typeof THREE.STLLoader !== 'undefined';\n            if (loaderReady) {\n                clearInterval(checkThreeJS);\n                this.create3DViewer(containerId, fileData, fileName, dimensions, initialColor);\n            }\n        }, 100);\n    }\n    \n    static create3DViewer(containerId, fileData, fileName, dimensions, initialColor) {\n        const container = document.getElementById(containerId);\n        container.innerHTML = '';\n\n        try {\n            const scene    = new THREE.Scene();\n            scene.background = new THREE.Color(0xf8f9fa);\n            const camera   = new THREE.PerspectiveCamera(75, container.clientWidth \/ container.clientHeight, 0.1, 1000);\n            const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });\n            renderer.setSize(container.clientWidth, container.clientHeight);\n            renderer.setClearColor(0x000000, 0);\n            container.appendChild(renderer.domElement);\n\n            const ambientLight = new THREE.AmbientLight(0x404040, 0.6);\n            scene.add(ambientLight);\n            const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.8);\n            directionalLight1.position.set(1, 1, 1).normalize();\n            scene.add(directionalLight1);\n            const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5);\n            directionalLight2.position.set(-1, -1, -1).normalize();\n            scene.add(directionalLight2);\n\n            const controls = new THREE.OrbitControls(camera, renderer.domElement);\n            controls.enableDamping = true;\n            controls.dampingFactor = 0.05;\n\n            const isOBJ    = fileName.toLowerCase().endsWith('.obj');\n            const material = new THREE.MeshPhongMaterial({\n                color: initialColor, specular: 0x111111, shininess: 100, transparent: true, opacity: 0.9\n            });\n            const blob = new Blob([fileData], { type: 'application\/octet-stream' });\n            const url  = URL.createObjectURL(blob);\n\n            \/\/ Unified post-load handler: works for both STL (BufferGeometry) and OBJ (THREE.Group)\n            const onModelLoad = (object) => {\n                let rootObject;\n\n                if (isOBJ) {\n                    \/\/ OBJLoader returns a THREE.Group \u2014 apply shared material to every mesh child\n                    object.traverse(child => {\n                        if (child.isMesh) { child.material = material; }\n                    });\n                    scene.add(object);\n                    rootObject = object;\n                } else {\n                    \/\/ STLLoader returns a BufferGeometry\n                    object.computeVertexNormals();\n                    const mesh = new THREE.Mesh(object, material);\n                    scene.add(mesh);\n                    rootObject = mesh;\n                }\n\n                \/\/ Centre and fit camera to bounding box\n                const box    = new THREE.Box3().setFromObject(rootObject);\n                const center = new THREE.Vector3();\n                box.getCenter(center);\n                rootObject.position.sub(center);\n\n                const size   = new THREE.Vector3();\n                box.getSize(size);\n                const maxDim = Math.max(size.x, size.y, size.z);\n                camera.position.z = maxDim * 2;\n                camera.position.y = maxDim * 0.5;\n                controls.update();\n\n                const infoDiv     = document.createElement('div');\n                infoDiv.className = 'viewer-info';\n                infoDiv.innerHTML = i18n.viewerDragHint;\n                container.appendChild(infoDiv);\n\n                \/\/ Store material + renderer reference so updateColor() and disposal work\n                this.viewers.set(containerId, { scene, mesh: rootObject, material, renderer });\n\n                function animate() {\n                    requestAnimationFrame(animate);\n                    controls.update();\n                    renderer.render(scene, camera);\n                }\n                animate();\n\n                window.addEventListener('resize', () => {\n                    camera.aspect = container.clientWidth \/ container.clientHeight;\n                    camera.updateProjectionMatrix();\n                    renderer.setSize(container.clientWidth, container.clientHeight);\n                });\n            };\n\n            const onError = (error) => {\n                console.error('Error loading 3D model:', error);\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>`;\n            };\n\n            if (isOBJ) {\n                const loader = new THREE.OBJLoader();\n                loader.load(url, onModelLoad, undefined, onError);\n            } else {\n                const loader = new THREE.STLLoader();\n                loader.load(url, onModelLoad, undefined, onError);\n            }\n\n        } catch (error) {\n            console.error('3D viewer initialisation error:', error);\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>`;\n        }\n    }\n    \n    static updateColor(containerId, colorName) {\n        const viewer = this.viewers.get(containerId);\n        if (viewer && viewer.material) {\n            const colorHex = materialColors[colorName] || 0xdc3545;\n            viewer.material.color.setHex(colorHex);\n            viewer.material.needsUpdate = true;\n        }\n    }\n\n    static disposeAllViewers() {\n        this.viewers.forEach((viewer, containerId) => {\n            try {\n                if (viewer.renderer) {\n                    viewer.renderer.forceContextLoss();\n                    viewer.renderer.dispose();\n                }\n            } catch(e) { \/* ignore disposal errors *\/ }\n        });\n        this.viewers.clear();\n    }\n}\n\n\/\/ =====================================================================\n\/\/ CALCULATEUR DE PRIX (avec r\u00e9ductions par pi\u00e8ce)\n\/\/ =====================================================================\n\nconst pricingPro = {\n    \/*\n     * PRICING MODEL \u2014 rebuilt 2026-09-02. Read this before changing any number.\n     *\n     *   price of ONE piece in an order of N = max( floor , P1 x N^-k )\n     *       cost  = material + machine x coc + handling\n     *       floor = cost x floor_markup      <- never sold under this, at any N\n     *       P1    = (setup + cost) x markup  <- the single-piece price\n     *\n     * \ud83d\udd34 WHY THE OLD LADDER IS GONE. It gave 30% off pieces 30-99 and 35% off\n     * pieces 100+, on top of a markup of only 25-30%. A discount larger than the\n     * markup IS a loss: price = cost x markup x (1 - discount), so 1.25 x 0.65\n     * is 0.8125 of cost. Measured on a real customer part, a 100-piece order was\n     * sold 8 TND under cost and a 500-piece order 78 TND under. The marginal\n     * piece was below cost from the SECOND one onward, worst at piece 11, which\n     * cost the customer 1.03 TND against a 3.12 average (the markup tier dropped\n     * from 1.30 to 1.25 for the whole order at exactly the same moment piece 11\n     * took its own 20%). The floor now makes selling under cost impossible, and\n     * the smooth curve has no cliff for the marginal piece.\n     *\n     * \u26a0\ufe0f HANDLING AND SETUP RAMP WITH THE PRINTED WEIGHT. Taking a 4 g part off\n     * the plate, cleaning, checking and bagging it is not the same job as a 200 g\n     * part. RAMPED, not banded: a hard band edge would price a 19.9 g part and a\n     * 20.1 g part differently, which is the piece-11 cliff one level up.\n     *\n     * \u26a0\ufe0f THE WEIGHT IS THE PRINTED WEIGHT, so it already follows the quality\n     * setting. That is deliberate: the same part at Low and at Standard both stay\n     * in the small band, which keeps handling quality-independent as it should be.\n     *\n     * \ud83d\udd34 THIS IS MIRRORED IN includes\/class-pricing.php AND THE SERVER REFUSES\n     * ANY ORDER WHERE THE TWO DISAGREE BY MORE THAN max(0.01, 0.5%). CHANGE BOTH\n     * IN THE SAME COMMIT or every legitimate order is rejected as tampered.\n     *\/\n    calculateDetailedPrice: function(volume, surfaceArea, technology, material, quality, quantity, color) {\n        let breakdown = { priceHT: 0, priceTTC: 0, costPerUnit: 0, totalCost: 0 };\n\n        try {\n            const qualitySlug = quality.toLowerCase().replace(' ', '-');\n            const qci = parseFloat(qualityConfigPro[qualitySlug + '_qci'] || this.getDefaultQci(quality));\n            const qct = parseFloat(qualityConfigPro[qualitySlug + '_qct'] || this.getDefaultQct(quality));\n\n            const materialSlug = material.toLowerCase().replace('+', '-plus').replace(' ', '-');\n            const materialPrice = parseFloat(materialConfigPro[materialSlug + '_price'] || this.getDefaultMaterialPrice(material));\n            const materialDensity = parseFloat(materialConfigPro[materialSlug + '_density'] || this.getDefaultMaterialDensity(material));\n            const materialCoc = parseFloat(materialConfigPro[materialSlug + '_coc'] || this.getDefaultMaterialCoc(material));\n\n            const wasteFactor = parseFloat(generalConfigPro.waste_factor || 1.05);\n            const machineSpeed = parseFloat(machineConfigPro.machine_speed || 0.4);\n            const machineHourlyCost = parseFloat(machineConfigPro.machine_hourly_cost || 2.0);\n\n            const colorCoef = (color === 'White' || color === 'Black') ?\n                parseFloat(materialConfigPro.color_standard_coef || 1.0) :\n                parseFloat(materialConfigPro.color_premium_coef || 1.1);\n\n            const qty = Math.max(1, parseInt(quantity, 10) || 1);\n\n            \/\/ Printed weight decides the handling and setup band.\n            const grams = this.calculateGrams(volume, surfaceArea, qci, qct, materialDensity, wasteFactor);\n            const handling = this.handlingCost(grams);\n            const setup    = this.setupCost(grams);\n\n            const materialCostPerUnit = this.calculateMaterialCost(volume, surfaceArea, qci, qct, materialDensity, wasteFactor, materialPrice, colorCoef);\n            const machineCostPerUnit = this.calculateBaseMachineCost(volume, surfaceArea, qci, qct, machineSpeed, machineHourlyCost);\n            const costPerUnit = materialCostPerUnit + (machineCostPerUnit * materialCoc) + handling;\n\n            \/\/ Material difficulty surcharge \u2014 see class-pricing.php, 2026-09-08.\n            \/\/ Added to SETUP ONLY, never to cost\/floor, so it has zero effect\n            \/\/ once a part\/quantity is big enough that the floor governs. PLA\n            \/\/ (coc 1.0) is always untouched. Not applied to the SLA branch\n            \/\/ below, which prices off plaCoc (always 1.0) and already has its\n            \/\/ own resin differentiation via the sla multipliers.\n            const difficultyFee = parseFloat(generalConfigPro.difficulty_fee || 1.10);\n            const setupWithDifficulty = setup + (difficultyFee * Math.max(0, materialCoc - 1));\n\n            let totalPriceHT = this.unitPriceHT(costPerUnit, setupWithDifficulty, qty) * qty;\n\n            \/\/ SLA is priced on a PLA basis and then multiplied \u2014 kept from the\n            \/\/ original model. The multiplier scales the floor too, which is right.\n            if (technology === 'SLA') {\n                const plaPrice = parseFloat(materialConfigPro['pla_price'] || 80.00);\n                const plaDensity = parseFloat(materialConfigPro['pla_density'] || 1.24);\n                const plaCoc = parseFloat(materialConfigPro['pla_coc'] || 1.0);\n\n                const plaMaterialCost = this.calculateMaterialCost(volume, surfaceArea, qci, qct, plaDensity, wasteFactor, plaPrice, colorCoef);\n                const plaMachineCost = this.calculateBaseMachineCost(volume, surfaceArea, qci, qct, machineSpeed, machineHourlyCost);\n                const plaCostPerUnit = plaMaterialCost + (plaMachineCost * plaCoc) + handling;\n\n                let slaMultiplier = (material === 'High Resolution Resin')\n                    ? parseFloat(machineConfigPro.sla_highres_multiplier || 2.5)\n                    : parseFloat(machineConfigPro.sla_standard_multiplier || 2.2);\n\n                totalPriceHT = this.unitPriceHT(plaCostPerUnit, setup, qty) * qty * slaMultiplier;\n            }\n\n            breakdown.priceHT = totalPriceHT;\n            breakdown.costPerUnit = totalPriceHT \/ qty;\n            breakdown.totalCost = totalPriceHT;\n\n            const tvaRate = parseFloat(generalConfigPro.tva_rate || 0.19);\n            breakdown.priceTTC = breakdown.priceHT * (1 + tvaRate);\n\n        } catch (error) {\n            console.error('Erreur calcul:', error);\n        }\n\n        return breakdown;\n    },\n\n    \/** Printed weight in grams \u2014 the same expression the material cost uses. *\/\n    calculateGrams: function(volume, surfaceArea, qci, qct, density, wasteFactor) {\n        const totalVolume = (volume * qci) + (surfaceArea * (qct \/ 10));\n        return totalVolume * density * wasteFactor;\n    },\n\n    \/**\n     * Straight line from `lo` (at or below weightLo grams) to `hi` (at or above\n     * weightHi). No step, so no price cliff at a band edge.\n     *\/\n    rampByWeight: function(grams, lo, hi) {\n        const wLo = parseFloat(generalConfigPro.weight_band_lo || 20);\n        const wHi = parseFloat(generalConfigPro.weight_band_hi || 100);\n        if (!(wHi > wLo)) return lo;\n        if (grams <= wLo) return lo;\n        if (grams >= wHi) return hi;\n        return lo + ((grams - wLo) \/ (wHi - wLo)) * (hi - lo);\n    },\n\n    \/** Per PIECE: off the plate, supports off, checked, bagged. *\/\n    handlingCost: function(grams) {\n        return this.rampByWeight(\n            grams,\n            parseFloat(generalConfigPro.handling_min || 0.48),\n            parseFloat(generalConfigPro.handling_max || 3.00)\n        );\n    },\n\n    \/** Per ORDER LINE: file prep, slicing, plate setup. Charged once, then spread. *\/\n    setupCost: function(grams) {\n        return this.rampByWeight(\n            grams,\n            parseFloat(generalConfigPro.setup_min || 4.00),\n            parseFloat(generalConfigPro.setup_max || 15.00)\n        );\n    },\n\n    \/**\n     * Price of one piece in an order of `quantity`.\n     *\n     * \u26a0\ufe0f The floor is the whole safety property of this model: whatever the curve\n     * says, a piece is never priced under cost x floor_markup. Do not remove it\n     * to make a big order look cheaper \u2014 that is exactly what the old ladder did.\n     *\/\n    unitPriceHT: function(costPerUnit, setup, quantity) {\n        const markup      = parseFloat(quantityConfigPro.markup || 1.30);\n        const floorMarkup = parseFloat(quantityConfigPro.floor_markup || 1.30);\n        const curveK      = parseFloat(quantityConfigPro.curve_k || 0.28);\n\n        const floor = costPerUnit * floorMarkup;\n        const p1    = (setup + costPerUnit) * markup;\n        const curve = p1 * Math.pow(Math.max(1, quantity), -curveK);\n\n        return Math.max(floor, curve);\n    },\n\n    calculateMaterialCost: function(volume, surfaceArea, qci, qct, density, wasteFactor, materialPrice, colorCoef) {\n        const volumePart = volume * qci;\n        const surfacePart = surfaceArea * (qct \/ 10);\n        const totalVolume = volumePart + surfacePart;\n        const weight = totalVolume * density * wasteFactor;\n        const cost = (weight \/ 1000) * materialPrice * colorCoef;\n        return Math.max(cost, 0.001);\n    },\n    \n    calculateBaseMachineCost: function(volume, surfaceArea, qci, qct, machineSpeed, machineHourlyCost) {\n        const volumePart = volume * qci;\n        const surfacePart = surfaceArea * (qct \/ 10);\n        const totalWork = volumePart + surfacePart;\n        const cost = (machineHourlyCost \/ (60 * machineSpeed)) * totalWork;\n        return Math.max(cost, 0.001);\n    },\n    \n    \/*\n     * REMOVED 2026-09-02: calculateManufacturingCost(), calculatePriceHT() and\n     * getReductionRateForPiece(). The first two amortised a flat 5 TND setup and\n     * applied a markup that DROPPED from 1.30 to 1.25 the moment an order reached\n     * 11 pieces; the third was the per-piece discount ladder that reached 35%.\n     * Together they sold below cost from piece 30 on. Nothing calls them now \u2014\n     * do not reintroduce them, and note that the four `*_markup` settings and\n     * `fixed_cost` are retired: `markup`, `setup_min` and `setup_max` replace them.\n     *\/\n\n\n    \/\/ Valeurs par d\u00e9faut\n    getDefaultQci: function(quality) {\n        const defaults = { 'Basse': 0.1, 'Standard': 0.3, 'Haute': 1.0 };\n        return defaults[quality] || 0.3;\n    },\n    getDefaultQct: function(quality) {\n        const defaults = { 'Basse': 0.8, 'Standard': 1.0, 'Haute': 2.0 };\n        return defaults[quality] || 1.0;\n    },\n    getDefaultMaterialPrice: function(material) {\n        const defaults = { \n            'PLA': 80.00, 'PLA+': 95.00, 'PETG': 130.00, \n            'ABS': 155.00, 'TPU': 165.00,\n            'Standard Resin': 100.00, 'High Resolution Resin': 150.00 \n        };\n        return defaults[material] || 80.00;\n    },\n    getDefaultMaterialDensity: function(material) {\n        const defaults = { \n            'PLA': 1.24, 'PLA+': 1.24, 'PETG': 1.27, \n            'ABS': 1.04, 'TPU': 1.20,\n            'Standard Resin': 1.10, 'High Resolution Resin': 1.10 \n        };\n        return defaults[material] || 1.24;\n    },\n    getDefaultMaterialCoc: function(material) {\n        const defaults = { \n            'PLA': 1.0, 'PLA+': 1.2, 'PETG': 1.5, \n            'ABS': 2.2, 'TPU': 3.0,\n            'Standard Resin': 1.0, 'High Resolution Resin': 1.0 \n        };\n        return defaults[material] || 1.0;\n    }\n};\n\n\/\/ =====================================================================\n\/\/ APPLICATION PRINCIPALE\n\/\/ =====================================================================\n\ndocument.addEventListener(\"DOMContentLoaded\", function() {\n    const MAX_FILES      = 10;\n    const MAX_VIEWERS    = 6;  \/\/ WebGL context limit \u2014 browsers cap at ~8-16; stay safe at 6\n    const MAX_FILE_SIZE  = 100 * 1024 * 1024; \/\/ 100 MB per file\n    const MAX_TOTAL_SIZE = 200 * 1024 * 1024; \/\/ 200 MB across all files\n    \n    const elements = {\n        fileInput: document.getElementById('stlFilesPro'),\n        alertContainer: document.getElementById('alertContainerPro'),\n        rateLimitBanner: document.getElementById('rateLimitBannerPro'),\n        fileCounter: document.getElementById('fileCounterPro'),\n        filesList: document.getElementById('stlFilesListPro'),\n        totalPrice: document.getElementById('totalPricePro'),\n        submitBtn: document.getElementById('submitStlOrderPro'),\n        cartBtn: document.getElementById('addToCartPro'),\n        quoteCartBtn: document.getElementById('quoteAddToCartPro'),\n        uploadArea: document.getElementById('uploadAreaPro'),\n        quotationModal: document.getElementById('quotationModalPro'),\n        quotationContent: document.getElementById('quotationContentPro')\n    };\n    \n    let state = {\n        files: [],\n        processing: 0,\n        fileData: new Map(),\n        hasLargeDimensions: false,\n        quotationNumber: null   \/\/ set server-side before quote is shown\n    };\n\n    \/\/ Guest info state\n    let guestInfo = {\n        identified: false,\n        name: '',\n        email: '',\n        phone: '',\n        gdpr_consent: 0,\n        wp_user_id: null,\n        guest_identifier: '',\n        billing: {\n            company:   '',\n            address_1: '',\n            address_2: '',\n            city:      '',\n            postcode:  '',\n            country:   ''\n        }\n    };\n\n    \n\n    const technologyMaterials = {\n        'FDM': ['PLA', 'PLA+', 'PETG', 'ABS', 'TPU'],\n        \/\/ SLA names: value stays English for pricing lookup; display uses i18n\n        'SLA': [\n            { value: 'Standard Resin',        label: () => i18n.matStandardResin },\n            { value: 'High Resolution Resin', label: () => i18n.matHighResResin  },\n        ]\n    };\n\n    \/\/ Color value stays English (used for order\/pricing lookups); name is translated\n    const colorOptions = [\n        { name: () => i18n.colorWhite,  value: 'White'  },\n        { name: () => i18n.colorBlack,  value: 'Black'  },\n        { name: () => i18n.colorRed,    value: 'Red'    },\n        { name: () => i18n.colorGreen,  value: 'Green'  },\n        { name: () => i18n.colorBlue,   value: 'Blue'   },\n        { name: () => i18n.colorGray,   value: 'Gray'   },\n        { name: () => i18n.colorYellow, value: 'Yellow' },\n    ];\n    \n    \/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n    \/\/ THE PRICE GATE\n    \/\/\n    \/\/ Everything the visitor gets for free stays free: preview, dimensions,\n    \/\/ printability, tips, materials, delivery. What needs an account is the exact\n    \/\/ money, the devis and the order.\n    \/\/\n    \/\/ \ud83d\udd34 THE SERVER DECIDES. window.tdpGate.locked comes from TDP_Quote_Gate::locked()\n    \/\/ and is flipped in the browser only when the sign-in dialog reports a real\n    \/\/ session. Never re-derive it from anything the page can see.\n    \/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n    function tdpGateLocked() {\n        return !!(window.tdpGate && window.tdpGate.locked);\n    }\n\n    \/**\n     * Every technology\/material a customer could pick, derived from the same map the\n     * <select> is built from so the two can never drift. If a material is added to\n     * technologyMaterials, the range widens by itself.\n     *\/\n    function tdpRangeCombos() {\n        const out = [];\n        Object.keys(technologyMaterials).forEach(function (tech) {\n            (technologyMaterials[tech] || []).forEach(function (m) {\n                out.push({ tech: tech, material: (m && typeof m === 'object') ? m.value : m });\n            });\n        });\n        return out;\n    }\n\n    \/**\n     * Round DOWN to a readable step, for the bottom of the range.\n     * The step grows with the amount, because \"247\" reads like a precise figure\n     * while \"240\" reads like an estimate, which is what it is.\n     *\/\n    function tdpTeaserStep(v) {\n        return v < 20 ? 1 : (v < 200 ? 5 : 10);\n    }\n\n    function tdpTeaserFloor(v) {\n        v = Number(v) || 0;\n        if (v <= 0) return 0;\n\n        var f = Math.floor(v \/ tdpTeaserStep(v)) * tdpTeaserStep(v);\n\n        \/\/ A sub-step quote must not round to zero and claim the job is free.\n        if (f <= 0) f = Math.floor(v * 10) \/ 10;\n        if (f <= 0) f = v;\n\n        return f;\n    }\n\n    \/** Round UP, for the top of the range. *\/\n    function tdpTeaserCeil(v) {\n        v = Number(v) || 0;\n        if (v <= 0) return 0;\n        return Math.ceil(v \/ tdpTeaserStep(v)) * tdpTeaserStep(v);\n    }\n\n    function tdpTeaserFormat(v) {\n        return (v === Math.round(v)) ? String(Math.round(v)) : v.toFixed(1);\n    }\n\n    \/**\n     * Show what the project might cost.\n     *\n     * \ud83d\udd34 THE ROUNDING GOES OUTWARD ON PURPOSE: the bottom rounds DOWN and the top\n     * rounds UP, so the exact price the customer sees after signing in is always\n     * INSIDE the range they were shown. Rounding both to nearest would be prettier\n     * and would sometimes quote a range the real price falls outside \u2014 which is the\n     * one thing a price estimate must never do.\n     *\/\n    function tdpShowTeaser(minTTC, maxTTC) {\n        var box = document.getElementById('tdpTeaserPro');\n        var lo  = document.getElementById('tdpTeaserMinPro');\n        var hi  = document.getElementById('tdpTeaserMaxPro');\n        if (!box || !lo || !hi) return;\n\n        var a = tdpTeaserFloor(minTTC);\n        var b = tdpTeaserCeil(maxTTC);\n\n        if (a <= 0 && b <= 0) {\n            box.hidden = true;\n            return;\n        }\n\n        \/\/ A single readable figure beats \"45 \u2013 45\" when rounding collapses the range.\n        var dash = box.querySelector('.tdp-teaser-dash-pro');\n        if (a === b) {\n            lo.textContent = tdpTeaserFormat(a);\n            hi.textContent = '';\n            if (dash) dash.textContent = '';\n        } else {\n            lo.textContent = tdpTeaserFormat(a);\n            hi.textContent = tdpTeaserFormat(b);\n            if (dash) dash.textContent = ' \\u2013 ';\n        }\n\n        box.hidden = false;\n        tdpFunnel('locked');\n    }\n\n    \/**\n     * Gate one action.\n     *\n     * @param {Function} [after] Run once the visitor is signed in. Deliberately\n     *        OMITTED by the two money buttons: resuming \"add to cart\" the moment a\n     *        dialog closes would create an order before the customer has seen the\n     *        exact price they just signed in to see. They press again, informed.\n     * @returns {boolean} true when the caller may proceed right now.\n     *\/\n    function tdpRequireAccount(after) {\n        if (!tdpGateLocked()) return true;\n\n        tdpPending = (typeof after === 'function') ? after : null;\n\n        if (window.masAuth && typeof window.masAuth.open === 'function') {\n            window.masAuth.open(window.tdpGate.modal);\n            tdpFunnel('modal');\n            return false;\n        }\n\n        \/*\n         * No dialog on the page (modern-auth-system inactive, or its script blocked).\n         * \u26a0\ufe0f mas_redirect, NOT redirect_to: mas_handle_login() reads only\n         * $_GET['mas_redirect'], so the old parameter landed every customer who\n         * complied on their dashboard instead of back here. The files cannot survive\n         * a navigation either way \u2014 that is exactly why the dialog exists \u2014 but the\n         * fallback should at least return them to the tool.\n         *\/\n        window.location.href = 'https:\/\/tunisia3dprint.com\/ar\/login\/?mas_redirect=' + encodeURIComponent(window.location.href);\n        return false;\n    }\n\n    var tdpPending = null;\n\n    \/**\n     * The visitor signed in without leaving the page. Reveal the money.\n     *\/\n    function tdpUnlock(payload) {\n        payload = payload || {};\n\n        if (!payload.logged_in) return;\n\n        window.tdpGate.locked = false;\n\n        \/*\n         * \ud83d\udd34 SWAP THE NONCES BEFORE ANYTHING ELSE RUNS. Every nonce printed on this\n         * page was minted for user 0, and wp_create_nonce() hashes the user id and the\n         * session token \u2014 so all of them stopped verifying the instant the cookie was\n         * set. Without this line the customer's very next click (Add to cart) fails\n         * with a security error, on a page that has just told them they are signed in.\n         *\/\n        if (payload.nonces) {\n            if (payload.nonces.pro_ajax_nonce)     window.pro_ajax_nonce     = payload.nonces.pro_ajax_nonce;\n            if (payload.nonces.t3d_tracking_nonce) window.t3d_tracking_nonce = payload.nonces.t3d_tracking_nonce;\n            \/\/ The \"send to our team\" button reads its own copy.\n            if (window.tdpTips && payload.nonces.t3d_tracking_nonce) {\n                window.tdpTips.sendNonce  = payload.nonces.t3d_tracking_nonce;\n                window.tdpTips.isLoggedIn = true;\n            }\n        }\n\n        if (payload.user) {\n            guestInfo.identified   = true;\n            guestInfo.name         = payload.user.name  || guestInfo.name;\n            guestInfo.email        = payload.user.email || guestInfo.email;\n            guestInfo.phone        = payload.user.phone || guestInfo.phone;\n            guestInfo.wp_user_id   = payload.user.id    || guestInfo.wp_user_id;\n            guestInfo.gdpr_consent = 1;\n            if (payload.user.billing) {\n                guestInfo.billing = payload.user.billing;\n            }\n            if (window.tdpTips) window.tdpTips.userEmail = guestInfo.email;\n        }\n\n        var teaser = document.getElementById('tdpTeaserPro');\n        if (teaser) teaser.hidden = true;\n\n        var exact = document.getElementById('tdpExactTotalPro');\n        if (exact) exact.hidden = false;\n\n        \/*\n         * Hand back the three controls the gate froze, and drop the line that\n         * explained why they were frozen. Done here rather than by re-rendering the\n         * cards, because renderFiles() would rebuild the 3D viewers and throw away\n         * the previews the customer is looking at \u2014 the whole point of signing in\n         * without leaving the page.\n         *\/\n        document.querySelectorAll('.tech-pro, .material-pro, .quality-pro').forEach(function (el) {\n            el.disabled = false;\n        });\n        document.querySelectorAll('.tdp-locked-opts-pro').forEach(function (el) {\n            el.parentNode.removeChild(el);\n        });\n\n        \/\/ Redraws every card's cost box and the totals, now on the unlocked branch.\n        updatePricesPro();\n\n        if (payload.is_new && payload.verify_notice) {\n            var vn = document.getElementById('tdpVerifyNoticePro');\n            if (vn) {\n                vn.textContent = payload.verify_notice;\n                vn.hidden = false;\n            }\n        }\n\n        tdpFunnel(payload.is_new ? 'registered' : 'signed_in');\n\n        var next = tdpPending;\n        tdpPending = null;\n        if (next) { try { next(); } catch (e) { console.error('[3dp-pricing] resume failed', e); } }\n    }\n\n    \/**\n     * One beacon per step per page load. Fire-and-forget: a lost count is a lost\n     * count, and a customer must never see anything because analytics failed.\n     *\/\n    var tdpFunnelSent = {};\n\n    function tdpFunnel(step) {\n        if (tdpFunnelSent[step]) return;\n        tdpFunnelSent[step] = true;\n\n        try {\n            jQuery.ajax({\n                type: 'POST',\n                url: window.tdpGate.ajaxUrl,\n                data: { action: 'tdp_funnel_event', step: step, nonce: window.t3d_tracking_nonce }\n            });\n        } catch (e) { \/* never surfaced *\/ }\n    }\n\n    function init() {\n        elements.fileInput.addEventListener('change', handleFileSelect);\n        elements.submitBtn.addEventListener('click', handleSubmit);\n        elements.cartBtn.addEventListener('click', function () { handleAddToCart(elements.cartBtn); });\n\n        \/\/ The same action, offered again at the foot of the quotation the customer has\n        \/\/ just read \u2014 which is the moment they have actually decided.\n        if (elements.quoteCartBtn) {\n            elements.quoteCartBtn.addEventListener('click', function () {\n                handleAddToCart(elements.quoteCartBtn);\n            });\n        }\n        elements.uploadArea.addEventListener('dragover', handleDragOver);\n        elements.uploadArea.addEventListener('dragleave', handleDragLeave);\n        elements.uploadArea.addEventListener('drop', handleFileDrop);\n        \n        window.pro_ajax_nonce = '9404974cc9';\n        window.t3d_tracking_nonce = '3826d6a4a4';\n\n        \/*\n         * The unlock CTA is a real <a href=\"\/login\/?mas_redirect=\u2026\"> so it works with\n         * no JavaScript at all. Here we take the click over and open the dialog\n         * instead, which is what keeps the uploaded files alive.\n         *\/\n        var unlock = document.getElementById('tdpUnlockPro');\n        if (unlock) {\n            unlock.addEventListener('click', function (e) {\n                if (window.masAuth && typeof window.masAuth.open === 'function') {\n                    e.preventDefault();\n                    window.masAuth.open(window.tdpGate.modal);\n                    tdpFunnel('modal');\n                }\n            });\n        }\n\n        if (window.masAuth && typeof window.masAuth.onSignedIn === 'function') {\n            window.masAuth.onSignedIn(tdpUnlock);\n        }\n\n        updateFileCounter();\n    }\n    \n    function handleDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('dragover'); }\n    function handleDragLeave(e) { e.preventDefault(); e.currentTarget.classList.remove('dragover'); }\n    \n    function handleFileDrop(e) {\n        e.preventDefault();\n        e.currentTarget.classList.remove('dragover');\n        \/\/ Do NOT filter here. A file we cannot print must be REPORTED, not dropped\n        \/\/ in silence - processFiles() owns the extension check for BOTH entry points\n        \/\/ (drop and file picker), so the two can never disagree again.\n        processFiles(Array.from(e.dataTransfer.files));\n    }\n    \n    function handleFileSelect(event) {\n        const files = Array.from(event.target.files);\n        processFiles(files);\n    }\n    \n    function processFiles(newFiles) {\n        const validFiles = [];\n        const errors = [];\n        \/\/ Running total of bytes already queued (existing + newly accepted)\n        let accumulatedSize = state.files.reduce((sum, f) => sum + f.size, 0);\n\n        newFiles.forEach(file => {\n            if (!\/\\.(stl|obj)$\/i.test(file.name)) {\n                \/\/ iOS\/Safari honours the accept=\"\" attribute only loosely, so this is\n                \/\/ the real gate: without it a .pdf was accepted, parsed as STL and\n                \/\/ shown with a green \"Ready\" badge.\n                errors.push(`${file.name}: ${i18n.fileTypeUnsupported}`);\n            } else if (file.size > MAX_FILE_SIZE) {\n                errors.push(`${file.name} exceeds 100MB limit`);\n            } else if (state.files.length + validFiles.length >= MAX_FILES) {\n                errors.push(`Maximum ${MAX_FILES} files allowed`);\n            } else if (state.files.some(f => f.name === file.name)) {\n                errors.push(`${file.name} is already added`);\n            } else if (accumulatedSize + file.size > MAX_TOTAL_SIZE) {\n                errors.push(`Adding ${file.name} would exceed the 200MB total upload limit`);\n            } else {\n                validFiles.push(file);\n                accumulatedSize += file.size;\n            }\n        });\n        \n        if (errors.length > 0) showAlert(errors.join('<br>'), 'error');\n        if (validFiles.length === 0) return;\n\n        \/\/ Rate limit check for unidentified guests\n        if (!guestInfo.identified) {\n            jQuery.ajax({\n                type: 'POST',\n                url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\n                data: {\n                    action: 't3d_check_rate_limit',\n                    nonce: window.t3d_tracking_nonce,\n                    email: guestInfo.email\n                },\n                success: function(response) {\n                    if (response.success) {\n                        if (response.data.allowed) {\n                            \/\/ Log upload attempt\n                            if (response.data.guest_identifier) {\n                                guestInfo.guest_identifier = response.data.guest_identifier;\n                            }\n                            doProcessFiles(validFiles);\n                            logUploadAttempt();\n                        } else {\n                            \/*\n                             * Guest upload rate limit reached. This used to navigate to\n                             * \/login\/?redirect_to=\u2026 \u2014 a parameter mas_handle_login() does not\n                             * read, so the visitor landed on a dashboard with their files gone.\n                             * Now it offers the same dialog as the price gate and RESUMES the\n                             * upload afterwards: processing files is not a money action, so\n                             * continuing is what the visitor asked for.\n                             *\/\n                            tdpRequireAccount(function () {\n                                doProcessFiles(validFiles);\n                                logUploadAttempt();\n                            });\n                        }\n                    } else {\n                        doProcessFiles(validFiles);\n                    }\n                },\n                error: function() {\n                    doProcessFiles(validFiles);\n                }\n            });\n        } else {\n            doProcessFiles(validFiles);\n        }\n    }\n\n    \/*\n     * \ud83d\udd34 THIS USED TO POST TO t3d_capture_guest, AND THAT IS WHY THE GUEST UPLOAD\n     * LIMIT HAD NEVER FIRED ONCE. handle_rate_limit_ajax() counts events of type\n     * 'upload_attempt'; t3d_capture_guest writes 'guest_captured' and, worse, REFUSES\n     * outright unless a name, a valid email and GDPR consent are all present. A\n     * visitor who has just dropped a file has supplied none of them \u2014 so the call\n     * errored, nothing was logged, the count stayed 0, and every guest was waved\n     * through for ever. Nothing reported a problem, because a limit that never\n     * triggers looks exactly like a limit nobody has reached.\n     *\n     * The two calls are kept separate deliberately. The first records that a file was\n     * put in the box; the second records that somebody told us who they are, and only\n     * fires when they actually have.\n     *\/\n    function logUploadAttempt() {\n        jQuery.ajax({\n            type: 'POST',\n            url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\n            data: {\n                action: 't3d_log_upload_attempt',\n                nonce: window.t3d_tracking_nonce,\n                name: guestInfo.name,\n                email: guestInfo.email,\n                file_count: state.files.length,\n                gdpr_consent: guestInfo.gdpr_consent\n            }\n        });\n\n        if (guestInfo.email && guestInfo.gdpr_consent) {\n            jQuery.ajax({\n                type: 'POST',\n                url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\n                data: {\n                    action: 't3d_capture_guest',\n                    nonce: window.t3d_tracking_nonce,\n                    name: guestInfo.name,\n                    email: guestInfo.email,\n                    phone: guestInfo.phone,\n                    gdpr_consent: guestInfo.gdpr_consent\n                }\n            });\n        }\n    }\n\n    function doProcessFiles(validFiles) {\n        if (elements.rateLimitBanner) {\n            elements.rateLimitBanner.style.display = 'none';\n        }\n        state.files.push(...validFiles);\n        updateFileCounter();\n        renderFiles();\n        processAllFiles();\n    }\n    \n    function renderFiles() {\n        elements.filesList.innerHTML = '';\n        \n        state.files.forEach((file, index) => {\n            const fileId = `file_${index}`;\n            const viewerId = `viewer_${fileId}`;\n            \n            const card = document.createElement('div');\n            card.className = 'stl-file-card-pro';\n            card.innerHTML = `\n                <button class=\"remove-file-pro\" onclick=\"removeFilePro(${index})\" title=\"Remove file\">\u00d7<\/button>\n                \n                <div class=\"file-header-pro\">\n                    <div class=\"file-name-pro\" title=\"${file.name}\">${file.name}<\/div>\n                    <div class=\"file-status-pro status-processing-pro\" id=\"status_${fileId}\">\u23f3 ${i18n.processing}<\/div>\n                <\/div>\n                \n                <div class=\"preview-section-pro\">\n                    <div class=\"viewer-container-pro\" id=\"${viewerId}\">\n                        ${index < MAX_VIEWERS\n                            ? `<div class=\"viewer-placeholder-pro\"><div style=\"font-size:32px;margin-bottom:10px;\">\ud83d\udce4<\/div><div>${i18n.uploadPlaceholder}<\/div><\/div>`\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>`\n                        }<\/div>\n                    \n                    <div class=\"info-panel-pro\">\n                        <div class=\"dimension-box-pro\">\n                            <strong>${i18n.dimensionsMm}<\/strong><br>\n                            <div class=\"info-row-pro\">\n                                <span class=\"info-label-pro\">${i18n.dimensions.width}:<\/span>\n                                <span class=\"info-value-pro\" id=\"width_${fileId}\">--<\/span>\n                            <\/div>\n                            <div class=\"info-row-pro\">\n                                <span class=\"info-label-pro\">${i18n.dimensions.height}:<\/span>\n                                <span class=\"info-value-pro\" id=\"height_${fileId}\">--<\/span>\n                            <\/div>\n                            <div class=\"info-row-pro\">\n                                <span class=\"info-label-pro\">${i18n.dimensions.depth}:<\/span>\n                                <span class=\"info-value-pro\" id=\"depth_${fileId}\">--<\/span>\n                            <\/div>\n                        <\/div>\n                        \n                        <div class=\"volume-box-pro\">\n                            <div style=\"font-size: 12px; color: #666;\">${i18n.volume}<\/div>\n                            <div class=\"volume-value-pro\" id=\"vol_${fileId}\">--<\/div>\n                            <div style=\"font-size: 11px; color: #666;\">cm\u00b3<\/div>\n                        <\/div>\n                        \n                        <div class=\"printability-bar-pro\">\n                            <div class=\"printability-fill-pro\" id=\"printabilityBar_${fileId}\"><\/div>\n                        <\/div>\n                        <!-- notranslate \/ translate=\"no\" \/ data-no-translation: blocks TranslatePress's\n                             Auto-Translate Addon from overwriting our JS-set printability text. Without\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\n                             entry it cached during a previous fr session. -->\n                        <div class=\"printability-label-pro notranslate\" translate=\"no\" data-no-translation id=\"printabilityLabel_${fileId}\">${i18n.calculating}<\/div>\n                    <\/div>\n                <\/div>\n                \n                <div class=\"cost-breakdown-pro\" id=\"costBreakdown_${fileId}\">\n                    ${tdpGateLocked()\n                        ? `<div class=\"tdp-locked-line-pro\">\ud83d\udd12 ${i18n.gateCardLocked}<\/div>`\n                        : `<div class=\"cost-item-pro\"><span>${i18n.totalHT}<\/span><span>-- TND<\/span><\/div>\n                           <div class=\"cost-item-pro\"><span>${i18n.totalTTC}<\/span><span>-- TND<\/span><\/div>`\n                    }\n                <\/div>\n                \n                <div class=\"settings-grid-pro\">\n                    <!--\n                        \\u26a0\\ufe0f WHILE THE GATE IS ON, THE THREE SELECTS THAT MOVE THE PRICE ARE\n                        FROZEN at FDM \/ PLA \/ Standard, and the teaser above quotes a RANGE\n                        across every material and quality instead. The two go together: a\n                        range is only honest if the visitor cannot already be sitting on a\n                        combination outside it, and a locked control is only fair if the page\n                        says what unlocks it. Quantity and colour stay editable \\u2014 quantity is\n                        what the customer wants rather than a price option, and the range is\n                        computed at whatever colour they pick, so it stays true.\n                    -->\n                    <div class=\"form-group-pro\">\n                        <label>${i18n.labelTechnology}<\/label>\n                        <select class=\"tech-pro notranslate\" translate=\"no\" data-no-translation\n                                ${tdpGateLocked() ? 'disabled' : ''}\n                                onchange=\"updateMaterialsPro(${index})\">\n                            <option value=\"FDM\" class=\"notranslate\" translate=\"no\">FDM<\/option>\n                            <option value=\"SLA\" class=\"notranslate\" translate=\"no\">SLA<\/option>\n                        <\/select>\n                    <\/div>\n\n                    <div class=\"form-group-pro\">\n                        <label>${i18n.labelMaterial}<\/label>\n                        <!-- notranslate on parent: PLA, PLA+, PETG, ABS, TPU are abbreviations,\n                             blocks TranslatePress Auto-Translate Addon from mistranslating them\n                             (it was rendering \"PLA\" as \"Chinese People's Liberation Army\" in Arabic). -->\n                        <select class=\"material-pro notranslate\" translate=\"no\" data-no-translation\n                                ${tdpGateLocked() ? 'disabled' : ''}\n                                id=\"material_${fileId}\"><\/select>\n                    <\/div>\n\n                    <div class=\"form-group-pro\">\n                        <label>${i18n.labelColor}<\/label>\n                        <select class=\"color-pro\" onchange=\"updateColorAndPricePro(${index}, this.value)\">\n                            ${colorOptions.map(color => `<option value=\"${color.value}\">${color.name()}<\/option>`).join('')}\n                        <\/select>\n                    <\/div>\n\n                    <div class=\"form-group-pro\">\n                        <label>${i18n.labelQuality}<\/label>\n                        <select class=\"quality-pro\" ${tdpGateLocked() ? 'disabled' : ''}>\n                            <option value=\"Basse\">${i18n.qualityLow}<\/option>\n                            <option value=\"Standard\" selected>${i18n.qualityStandard}<\/option>\n                            <option value=\"Haute\">${i18n.qualityHigh}<\/option>\n                        <\/select>\n                    <\/div>\n\n                    <div class=\"form-group-pro\">\n                        <label>${i18n.labelQuantity}<\/label>\n                        <input type=\"number\" class=\"quantity-pro\" value=\"1\" min=\"1\" max=\"1000\">\n                    <\/div>\n\n                    ${tdpGateLocked()\n                        ? `<div class=\"tdp-locked-opts-pro\">\\u{1F512} ${i18n.gateOptionsLocked}<\/div>`\n                        : ''}\n\n                    <!--\n                        Shown only while this part's quality is \"Basse\". It sits INSIDE the\n                        settings grid, directly under the control that causes it, because a\n                        note the customer has to go looking for is a note they will not read.\n                        updatePricesPro() toggles it on every change.\n\n                        \u26a0\ufe0f TONE IS DELIBERATE (owner, 2026-08-25): informative, not a warning.\n                        Low quality only affects function in limited cases, so a red alarm\n                        overstated it and made the cheapest option feel unsafe. It now says\n                        what the setting is GOOD for and points at the team, which is the\n                        real answer for anyone unsure. Keep it that way if the wording is\n                        ever revisited \u2014 \ud83d\udca1 not \u26a0, muted grey not red.\n                    -->\n                    <div class=\"tdp-lowq-warn-pro\" id=\"qualityWarn_${fileId}\" hidden>\ud83d\udca1 ${i18n.lowQualityWarning}<\/div>\n                <\/div>\n                \n                <input type=\"hidden\" class=\"volume-pro\" value=\"0\">\n                <input type=\"hidden\" class=\"surface-pro\" value=\"0\">\n                <input type=\"hidden\" class=\"bounding-volume-pro\" value=\"0\">\n                <input type=\"hidden\" class=\"unit-price-ht-pro\" value=\"0\">\n            `;\n            \n            elements.filesList.appendChild(card);\n            updateMaterialsPro(index);\n            \n            setTimeout(() => {\n                const cardElement = document.querySelectorAll('.stl-file-card-pro')[index];\n                const inputs = cardElement.querySelectorAll('input, select');\n                inputs.forEach(input => {\n                    input.addEventListener('change', updatePricesPro);\n                });\n            }, 100);\n        });\n    }\n    \n    \/\/ Global functions\n    window.removeFilePro = function(fileIndex) {\n        \/\/ Dispose all active WebGL contexts before re-rendering to avoid context leaks\n        Real3DViewer.disposeAllViewers();\n\n        state.files.splice(fileIndex, 1);\n        const newFileData = new Map();\n        state.files.forEach((file, index) => {\n            const oldFileId = `file_${fileIndex <= index ? index + 1 : index}`;\n            const newFileId = `file_${index}`;\n            if (state.fileData.has(oldFileId)) newFileData.set(newFileId, state.fileData.get(oldFileId));\n        });\n        state.fileData = newFileData;\n        renderFiles();\n        processAllFiles();\n        updatePricesPro();\n        updateFileCounter();\n    };\n    \n    window.updateMaterialsPro = function(fileIndex) {\n        const fileId = `file_${fileIndex}`;\n        const card = document.getElementById(`viewer_${fileId}`)?.closest('.stl-file-card-pro');\n        if (!card) return;\n        \n        const techSelect = card.querySelector('.tech-pro');\n        const materialSelect = card.querySelector('.material-pro');\n        const selectedTech = techSelect.value;\n        const materials = technologyMaterials[selectedTech] || [];\n        \n        materialSelect.innerHTML = '';\n        materials.forEach(material => {\n            const option = document.createElement('option');\n            if (typeof material === 'object') {\n                \/\/ SLA materials: {value: 'Standard Resin', label: () => i18n.matXxx}\n                option.value       = material.value;\n                option.textContent = material.label();\n            } else {\n                \/\/ FDM materials are abbreviations (PLA, PLA+, PETG, ABS, TPU) \u2014\n                \/\/ identical in every language. Mark as untranslatable so\n                \/\/ TranslatePress \/ Google Translate \/ browser tools leave them alone.\n                \/\/ (Without this, TRP has been auto-translating \"PLA\" to wrong Arabic.)\n                option.value       = material;\n                option.textContent = material;\n                option.classList.add('notranslate');\n                option.setAttribute('translate', 'no');\n            }\n            materialSelect.appendChild(option);\n        });\n        \n        updatePricesPro();\n    };\n    \n    window.updateColorAndPricePro = function(fileIndex, colorName) {\n        const fileId = `file_${fileIndex}`;\n        const viewerId = `viewer_${fileId}`;\n        Real3DViewer.updateColor(viewerId, colorName);\n        updatePricesPro();\n    };\n    \n    function handleFileError(fileId) {\n        \/\/ Status badge\n        const statusEl = document.getElementById(`status_${fileId}`);\n        if (statusEl) {\n            statusEl.className = 'file-status-pro status-error-pro';\n            statusEl.innerHTML = `\u26a0\ufe0f ${i18n.fileUnreadable}`;\n        }\n\n        \/\/ Printability bar \u2014 empty \/ 0\n        const printabilityBar   = document.getElementById(`printabilityBar_${fileId}`);\n        const printabilityLabel = document.getElementById(`printabilityLabel_${fileId}`);\n        if (printabilityBar) {\n            printabilityBar.className = 'printability-fill-pro';\n            printabilityBar.style.width = '0%';\n        }\n        if (printabilityLabel) {\n            \/\/ Preserve \"notranslate\" so TRP keeps its hands off this dynamic text.\n            printabilityLabel.className = 'printability-label-pro printability-unreadable-text-pro notranslate';\n            printabilityLabel.textContent = i18n.fileUnreadableMsg;\n        }\n\n        \/\/ Zero out dimension \/ volume display\n        ['width', 'height', 'depth', 'vol'].forEach(id => {\n            const el = document.getElementById(`${id}_${fileId}`);\n            if (el) el.textContent = '--';\n        });\n\n        \/\/ Zero out hidden price inputs so the card contributes 0\n        const card = document.getElementById(`viewer_${fileId}`)?.closest('.stl-file-card-pro');\n        if (card) {\n            ['volume-pro', 'surface-pro', 'bounding-volume-pro'].forEach(cls => {\n                const inp = card.querySelector('.' + cls);\n                if (inp) inp.value = '0';\n            });\n        }\n\n        \/\/ Store error sentinel \u2014 updatePricesPro will render 0.000 for this card\n        state.fileData.set(fileId, { error: true });\n    }\n\n    function processAllFiles() {\n        state.processing = 0;\n        elements.submitBtn.disabled = true;\n        elements.cartBtn.disabled = true;\n        \n        if (state.files.length === 0) {\n            checkAllProcessed();\n            return;\n        }\n        \n        state.files.forEach((file, index) => processSingleFile(file, index));\n    }\n    \n    function processSingleFile(file, index) {\n        const fileId = `file_${index}`;\n        const viewerId = `viewer_${fileId}`;\n        const reader = new FileReader();\n        \n        reader.onload = function(e) {\n            try {\n                const isOBJ  = file.name.toLowerCase().endsWith('.obj');\n                const result = isOBJ\n                    ? AccurateOBJVolumeCalculator.calculateVolumeAndSurface(e.target.result)\n                    : AccurateSTLVolumeCalculator.calculateVolumeAndSurface(e.target.result);\n                state.fileData.set(fileId, result);\n                \n                \/\/ \u26a0\ufe0f A DASH, NOT A NUMBER, FOR ANYTHING WE DID NOT MEASURE.\n                \/\/ The old code printed dimensions derived from the FILE SIZE for\n                \/\/ a file it could not read \u2014 2.2 x 2.2 x 2.2 mm, confidently.\n                \/\/ A dash says \"unknown\"; a number says \"we checked\".\n                const dash = '\u2014';\n                const dim  = v => result.unreadable ? dash : v.toFixed(1);\n\n                const statusEl = document.getElementById(`status_${fileId}`);\n                statusEl.className = result.noQuote\n                    ? 'file-status-pro status-warning-pro'\n                    : 'file-status-pro status-success-pro';\n                statusEl.innerHTML = result.noQuote\n                    ? `\u26a0 ${i18n.notMeasured}`\n                    : `\u2705 ${i18n.ready}`;\n\n                document.getElementById(`width_${fileId}`).textContent  = dim(result.dimensions.width);\n                document.getElementById(`height_${fileId}`).textContent = dim(result.dimensions.height);\n                document.getElementById(`depth_${fileId}`).textContent  = dim(result.dimensions.depth);\n                \/\/ The volume is unknown in BOTH no-quote cases \u2014 that is what\n                \/\/ makes the file unpriceable, even when the size is known.\n                document.getElementById(`vol_${fileId}`).textContent =\n                    result.noQuote ? dash : result.volume.toFixed(2);\n                \n                const printabilityBar = document.getElementById(`printabilityBar_${fileId}`);\n                const printabilityLabel = document.getElementById(`printabilityLabel_${fileId}`);\n                \/\/ Nothing was measured, so the verdict BAR is hidden \u2014 leaving it\n                \/\/ at some level would be the same invention as the dimensions.\n                \/\/ The label below it still renders, and says so in words.\n                const barTrack = printabilityBar ? printabilityBar.parentElement : null;\n                if (barTrack) barTrack.hidden = !!result.unreadable;\n                if (printabilityBar && printabilityLabel) {\n                    printabilityBar.className = `printability-fill-pro printability-${result.printability}-pro`;\n                    printabilityLabel.className = `printability-label-pro printability-${result.printability}-text-pro notranslate`;\n                    \/\/ Debug: log what we're actually writing \u2014 if this shows the right language but\n                    \/\/ the on-page text is still wrong, TranslatePress's Auto-Translate Addon is overriding.\n                    console.log('[3dp-pricing] writing printabilityText =', result.printabilityText);\n                    \/\/ Use innerHTML with a fresh inner span carrying every TRP block flag we know.\n                    \/\/ textContent on its own is replayed by TRP's MutationObserver and overwritten;\n                    \/\/ a brand-new span node with notranslate + translate=no + data-no-translation is\n                    \/\/ skipped by TRP entirely, so the value we set sticks.\n                    var _safe = String(result.printabilityText)\n                        .replace(\/&\/g, '&amp;').replace(\/<\/g, '&lt;').replace(\/>\/g, '&gt;');\n                    printabilityLabel.innerHTML =\n                        '<span class=\"notranslate\" translate=\"no\" data-no-translation>' + _safe + '<\/span>';\n                }\n\n                tdpFileStore[fileId] = file;\n                tdpRenderDesignTip(result, file.name, fileId);\n                \n                const card = document.getElementById(viewerId)?.closest('.stl-file-card-pro');\n                if (card) {\n                    card.querySelector('.volume-pro').value = result.volume.toFixed(2);\n                    card.querySelector('.surface-pro').value = result.surfaceArea.toFixed(2);\n                    card.querySelector('.bounding-volume-pro').value = result.boundingBoxVolume.toFixed(2);\n                }\n                \n                if (index < MAX_VIEWERS) {\n                    Real3DViewer.initViewer(viewerId, e.target.result, file.name, result.dimensions, materialColors['White']);\n                }\n                \n            } catch (error) {\n                console.error(`Error processing ${file.name}:`, error);\n                handleFileError(fileId);\n            } finally {\n                state.processing++;\n                checkAllProcessed();\n            }\n        };\n\n        reader.onerror = function() {\n            console.error(`Read error for ${file.name}`);\n            handleFileError(fileId);\n            state.processing++;\n            checkAllProcessed();\n        };\n        \n        reader.readAsArrayBuffer(file);\n    }\n    \n    function checkAllProcessed() {\n        if (state.processing === state.files.length) {\n            const errorCount = [...state.fileData.values()].filter(d => d && d.error).length;\n            const okCount    = state.files.length - errorCount;\n\n            \/*\n             * GA4 quote_file_uploaded (T7). Once per page load: a second batch\n             * is the same visitor still assembling one quote, not a new lead.\n             *\n             * \u26a0\ufe0f FIRED BEFORE updatePricesPro(), NOT AFTER. That call emits\n             * quote_calculated, and a funnel is read in event order \u2014 sending\n             * \"a price was shown\" before \"a file was accepted\" makes the two\n             * steps look reversed in every GA4 exploration built on them.\n             *\/\n            if (okCount > 0 && typeof window.tn3dpTrackOnce === 'function') {\n                window.tn3dpTrackOnce('quote_file_uploaded', {\n                    files: okCount,\n                    files_unreadable: errorCount\n                });\n            }\n\n            updatePricesPro();\n            elements.submitBtn.disabled = okCount === 0 && errorCount === 0;\n            elements.cartBtn.disabled   = okCount === 0 && errorCount === 0;\n            if (okCount > 0) tdpFunnel('upload');\n            if (state.files.length > 0) {\n                if (errorCount === 0) {\n                    showAlert(i18n.allFilesProcessed, 'success');\n                } else if (okCount > 0) {\n                    showAlert(`\u26a0\ufe0f ${okCount} file(s) ready, ${errorCount} could not be read. Please contact us for the unreadable file(s).`, 'error');\n                } else {\n                    showAlert(`\u26a0\ufe0f ${i18n.fileUnreadableMsg}`, 'error');\n                }\n            }\n        }\n    }\n    \n    function updatePricesPro() {\n        let totalHT = 0;\n        let totalTTC = 0;\n        \/\/ Bottom and top of what the project could cost across every material and\n        \/\/ quality \u2014 only accumulated while the gate is on, since that is the only\n        \/\/ time the customer cannot simply pick one and see the real figure.\n        let rangeMin = 0;\n        let rangeMax = 0;\n        const locked = tdpGateLocked();\n        \n        document.querySelectorAll('.stl-file-card-pro').forEach((card, index) => {\n            if (index < state.files.length) {\n                const fileId = `file_${index}`;\n                const fileData = state.fileData.get(fileId);\n\n                \/*\n                 * The low-quality caution is about the SETTING, not the price, so it is\n                 * decided before the pricing branches \u2014 a file we could not read still\n                 * shows it if the customer has chosen Low.\n                 *\/\n                const qualitySel = card.querySelector('.quality-pro');\n                const lowQualityWarn = document.getElementById(`qualityWarn_${fileId}`);\n                if (lowQualityWarn) {\n                    lowQualityWarn.hidden = ! ( qualitySel && 'Basse' === qualitySel.value );\n                }\n\n                \/\/ `noQuote` = we could not measure this file, so we do not price\n                \/\/ it. Owner, 2026-08-25: a quotation must not be given when the\n                \/\/ volume is unknown. It joins the existing `error` path, which\n                \/\/ already contributes 0 and shows 0.000.\n                if (fileData && !fileData.error && !fileData.noQuote) {\n                    const technology = card.querySelector('.tech-pro').value;\n                    const material = card.querySelector('.material-pro').value;\n                    const quality = card.querySelector('.quality-pro').value;\n                    const color = card.querySelector('.color-pro').value;\n                    const materialVolume = parseFloat(card.querySelector('.volume-pro').value) || 0;\n                    const surfaceArea = parseFloat(card.querySelector('.surface-pro').value) || 0;\n                    const quantity = parseInt(card.querySelector('.quantity-pro').value) || 1;\n\n                    const priceResult = pricingPro.calculateDetailedPrice(\n                        materialVolume, surfaceArea, technology, material, quality, quantity, color\n                    );\n\n                    \/*\n                     * \ud83d\udd34 WHILE THE GATE IS ON, NOTHING EXACT IS WRITTEN INTO THE DOM \u2014 not\n                     * the hidden unit price, not the breakdown. The totals are still\n                     * accumulated, because the teaser is derived from them, but they live\n                     * only in these local variables until the visitor signs in.\n                     *\/\n                    if (locked) {\n                        const unitInput = card.querySelector('.unit-price-ht-pro');\n                        if (unitInput) unitInput.value = '0';\n                        const lockedBox = document.getElementById(`costBreakdown_${fileId}`);\n                        if (lockedBox) {\n                            lockedBox.innerHTML = `<div class=\"tdp-locked-line-pro\">\ud83d\udd12 ${i18n.gateCardLocked}<\/div>`;\n                        }\n\n                        \/*\n                         * Price this part under every material and quality, at the colour\n                         * and quantity the customer actually chose, and keep the cheapest\n                         * and dearest. Summing the per-file extremes gives the project's\n                         * true span \u2014 ~21 cheap arithmetic calls per file.\n                         *\n                         * \u26a0\ufe0f The quality list is read from the card's own <option>s rather\n                         * than written out again, for the same reason tdpRangeCombos()\n                         * reads technologyMaterials: a second copy of a list is a list\n                         * that goes stale.\n                         *\/\n                        const qualities = Array.from(card.querySelectorAll('.quality-pro option'))\n                                               .map(function (o) { return o.value; });\n                        let lo = Infinity, hi = 0;\n                        tdpRangeCombos().forEach(function (combo) {\n                            qualities.forEach(function (q) {\n                                const p = pricingPro.calculateDetailedPrice(\n                                    materialVolume, surfaceArea, combo.tech, combo.material, q, quantity, color\n                                );\n                                if (p.priceTTC < lo) lo = p.priceTTC;\n                                if (p.priceTTC > hi) hi = p.priceTTC;\n                            });\n                        });\n                        rangeMin += (lo === Infinity) ? 0 : lo;\n                        rangeMax += hi;\n                    } else {\n                        card.querySelector('.unit-price-ht-pro').value = (priceResult.priceHT \/ quantity).toFixed(3);\n\n                        const breakdownElement = document.getElementById(`costBreakdown_${fileId}`);\n                        if (breakdownElement) {\n                            breakdownElement.innerHTML = `\n                                <div class=\"cost-item-pro\"><span>${i18n.totalHT}<\/span><span>${priceResult.priceHT.toFixed(3)} TND<\/span><\/div>\n                                <div class=\"cost-item-pro\"><span>${i18n.totalTTC}<\/span><span>${priceResult.priceTTC.toFixed(3)} TND<\/span><\/div>\n                            `;\n                        }\n                    }\n\n                    totalHT += priceResult.priceHT;\n                    totalTTC += priceResult.priceTTC;\n\n                } else if (fileData && (fileData.error || fileData.noQuote)) {\n                    \/\/ Not priceable \u2014 show 0 explicitly, contribute nothing to total\n                    const unitInput = card.querySelector('.unit-price-ht-pro');\n                    if (unitInput) unitInput.value = '0.000';\n                    const breakdownElement = document.getElementById(`costBreakdown_${fileId}`);\n                    if (breakdownElement && !locked) {\n                        breakdownElement.innerHTML = `\n                            <div class=\"cost-item-pro\"><span>${i18n.totalHT}<\/span><span>0.000 TND<\/span><\/div>\n                            <div class=\"cost-item-pro\"><span>${i18n.totalTTC}<\/span><span>0.000 TND<\/span><\/div>\n                        `;\n                    }\n                }\n            }\n        });\n        \n        \/*\n         * \ud83d\udd34 #totalPricePro IS WHAT REACHES THE SERVER (doAddToCart reads its text, and\n         * TDP_Pricing refuses an order that diverges by more than max(0.01, 0.5%)). The\n         * teaser therefore lives in its own element, and this one is left at its\n         * placeholder while the gate is on \u2014 never rounded, never approximate.\n         *\/\n        if (locked) {\n            tdpShowTeaser(rangeMin, rangeMax);\n        } else {\n            elements.totalPrice.textContent = totalTTC.toFixed(3);\n            const htBox = document.getElementById('breakdownHT');\n            if (htBox) htBox.textContent = totalHT.toFixed(3) + ' TND';\n        }\n\n        \/\/ Kept so an unlock can redraw without recomputing from the cards.\n        state.totalTTC = totalTTC;\n        state.totalHT  = totalHT;\n\n        \/*\n         * GA4 quote_calculated (T7) \u2014 ONCE PER PAGE LOAD, NOT ONCE PER REDRAW.\n         * This function runs again on every material, quality, colour and\n         * quantity change, so an event fired on each of them would measure\n         * fiddling with the controls rather than reaching a price.\n         *\n         * \u26a0\ufe0f THE TEASER COUNTS AS A PRICE. For a signed-out visitor the range is\n         * what we showed them; treating only the exact figure as \"calculated\"\n         * would report the sign-in gate as a dead end when it is the funnel step\n         * the gate exists to create. `gated` is what separates the two in GA4,\n         * and `value` is sent only when a real total was on screen.\n         *\/\n        if (typeof window.tn3dpTrackOnce === 'function') {\n            const shown = locked ? rangeMax : totalTTC;\n            if (shown > 0) {\n                const gaParams = { gated: locked, files: state.files.length, currency: 'TND' };\n                if (!locked) { gaParams.value = Number(totalTTC.toFixed(3)); }\n                window.tn3dpTrackOnce('quote_calculated', gaParams);\n            }\n        }\n\n        \/*\n         * The simulation record. Debounced inside, and deliberately LAST: it must\n         * never be able to affect the prices this function has just drawn.\n         *\/\n        tdpQueueSimLog(locked, rangeMin, rangeMax);\n    }\n    \n    function handleSubmit() {\n        if (state.files.length === 0) {\n            showAlert(i18n.noFiles, 'error');\n            return;\n        }\n        \/\/ The devis prints the exact prices, so it is behind the gate like the rest.\n        if (!tdpRequireAccount(showQuotationPro)) {\n            return;\n        }\n        showQuotationPro();\n    }\n    \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\n    function amountToWordsFR(amount) {\n        const units  = ['','un','deux','trois','quatre','cinq','six','sept','huit','neuf',\n                        'dix','onze','douze','treize','quatorze','quinze','seize','dix-sept','dix-huit','dix-neuf'];\n        const tens   = ['','','vingt','trente','quarante','cinquante','soixante','soixante','quatre-vingt','quatre-vingt'];\n        function belowHundred(n) {\n            if (n < 20) return units[n];\n            const t = Math.floor(n \/ 10), u = n % 10;\n            if (t === 7 || t === 9) return tens[t] + (u === 0 ? (t===9?'s':'') : '-' + units[10 + u]);\n            if (t === 8) return 'quatre-vingt' + (u === 0 ? 's' : '-' + units[u]);\n            return tens[t] + (u === 1 ? ' et un' : u > 0 ? '-' + units[u] : '');\n        }\n        function belowThousand(n) {\n            if (n < 100) return belowHundred(n);\n            const h = Math.floor(n \/ 100), r = n % 100;\n            return (h === 1 ? 'cent' : units[h] + ' cent') + (r > 0 ? (h===1?'':' ') + ' ' + belowHundred(r) : (h>1?'s':''));\n        }\n        const intPart  = Math.floor(amount);\n        const millPart = Math.round((amount - intPart) * 1000);\n        let words = '';\n        if (intPart === 0) {\n            words = 'z\u00e9ro';\n        } else if (intPart < 1000) {\n            words = belowThousand(intPart);\n        } else {\n            const th = Math.floor(intPart \/ 1000), rem = intPart % 1000;\n            words = (th === 1 ? 'mille' : belowThousand(th) + ' mille') + (rem > 0 ? ' ' + belowThousand(rem) : '');\n        }\n        words = words.charAt(0).toUpperCase() + words.slice(1) + ' dinars';\n        if (millPart > 0) words += ' et ' + belowThousand(millPart) + ' millimes';\n        return words;\n    }\n\n    \/\/ Guarantee state.quotationNumber before anything that writes files or\n    \/\/ creates an order, then run the callback.\n    \/\/\n    \/\/ \u26a0\ufe0f This MUST be called from every path that reaches the server, not just\n    \/\/ from the Devis preview. It previously lived inside showQuotationPro()\n    \/\/ only, so a customer who clicked \"Add to cart\" without opening the\n    \/\/ quotation sent quotation_number: null \u2014 which put their upload in the\n    \/\/ shared quotes\/unknown\/ folder (overwritable by the next customer with the\n    \/\/ same filename), skipped attach_files_to_product(), and left the order with\n    \/\/ a line item named \"3D Printing Order - \" and no reachable file.\n    function ensureQuotationNumber(cb) {\n        if (state.quotationNumber) { cb(); return; }\n\n        const fallback = function() {\n            const yr = String(new Date().getFullYear()).slice(-2);\n            return 'DEV3D' + yr + '-' + Date.now().toString().slice(-4);\n        };\n\n        jQuery.ajax({\n            type: 'POST',\n            url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\n            data: { action: 't3d_generate_quote_number', nonce: window.pro_ajax_nonce },\n            success: function(res) {\n                state.quotationNumber = (res && res.success && res.data && res.data.quote_number)\n                    ? res.data.quote_number\n                    : fallback();\n                cb();\n            },\n            error: function() {\n                state.quotationNumber = fallback();\n                cb();\n            }\n        });\n    }\n\n    function showQuotationPro() {\n        ensureQuotationNumber(renderQuotationPro);\n    }\n\n    function renderQuotationPro() {\n        const today   = new Date();\n        const dateStr = today.toLocaleDateString('fr-TN', { day:'2-digit', month:'2-digit', year:'numeric' });\n\n        \/\/ Collect item data\n        const orderData = state.files.map((file, index) => {\n            const card       = document.querySelectorAll('.stl-file-card-pro')[index];\n            const quantity   = parseInt(card.querySelector('.quantity-pro').value) || 1;\n            const unitPriceHT = parseFloat(card.querySelector('.unit-price-ht-pro').value) || 0;\n            return {\n                ref:          String(index + 1).padStart(3, '0'),\n                description:  file.name.replace(\/\\.[^.]+$\/, '') +\n                              ' \u2014 ' + card.querySelector('.tech-pro').value +\n                              ' \/ ' + card.querySelector('.material-pro').value +\n                              ' \/ ' + card.querySelector('.color-pro').value +\n                              ' \/ ' + card.querySelector('.quality-pro').value,\n                quantity:     quantity,\n                unit_price_ht: unitPriceHT,\n                total_ht:     unitPriceHT * quantity\n            };\n        });\n\n        \/*\n         * \u26a0\ufe0f The quotation is the document the customer keeps and orders from, so the\n         * low-quality note has to be on it too \u2014 not only on the screen they clicked\n         * through. One line, printed only when a line item actually uses it.\n         *\n         * It is FIRST in the notes because it is the only one specific to this order,\n         * and styled exactly like the others so it informs without alarming.\n         *\n         * French literal, like every other note on this devis: the document is French\n         * by design (see the identity block above, which asks tn3dp_legal_fact() for\n         * 'fr'), so it takes no def entry.\n         *\/\n        const hasLowQuality = state.files.some((file, index) => {\n            const c = document.querySelectorAll('.stl-file-card-pro')[index];\n            const q = c && c.querySelector('.quality-pro');\n            return !!q && 'Basse' === q.value;\n        });\n\n        const subtotalHT    = orderData.reduce((s, i) => s + i.total_ht, 0);\n        const vatAmount     = subtotalHT * 0.19;\n\n        \/*\n         * \u26a0\ufe0f THE FISCAL STAMP IS DELIBERATELY NOT IN THE DEVIS (owner, 2026-08-19:\n         * \"we don't need to see the 1 tnd in the quotation, since it is already added\n         * inside the cart later\").\n         *\n         * Since tn3dp-legal v1.4.0 the 1.000 TND stamp is charged as a visible line in\n         * the cart and at checkout (item L4), so showing it here as well would quote it\n         * twice to the reader \u2014 once in a document that has no shipping line, and again\n         * on a checkout page that does.\n         *\n         * \ud83d\udd34 It was never double-CHARGED, and the reason is worth keeping: this\n         * grandTotal is display-only. The figure that actually reaches the cart is\n         * elements.totalPrice.textContent, which is totalTTC \u2014 goods only, no stamp\n         * (see updatePricesPro). If that ever changes, re-check this, because adding the\n         * stamp to the product price while tn3dp-legal also adds its fee would bill the\n         * customer 2.000 TND of stamp duty.\n         *\/\n        const grandTotal    = subtotalHT + vatAmount;\n\n        \/\/ Client address block\n        const b = guestInfo.billing;\n        let clientAddr = '';\n        if (b.company)   clientAddr += `<div style=\"font-weight:700;\">${b.company}<\/div>`;\n        if (b.address_1) clientAddr += `<div>${b.address_1}<\/div>`;\n        if (b.address_2) clientAddr += `<div>${b.address_2}<\/div>`;\n        if (b.city || b.postcode) clientAddr += `<div>${[b.postcode, b.city].filter(Boolean).join(' ')}<\/div>`;\n        if (b.country)   clientAddr += `<div>${b.country}<\/div>`;\n        \/\/ The admin quote tool has no profile to be \"not set\" \u2014 an email request\n        \/\/ rarely gives an address, and there is nowhere on this tool to type one.\n        if (!clientAddr && !window.tdpAdminMode) {\n            clientAddr = `<div style=\"color:#999;font-style:italic;\">\u2014 address not set in profile \u2014<\/div>`;\n        }\n\n        \/\/ Items rows\n        let rowsHTML = '';\n        orderData.forEach(item => {\n            rowsHTML += `\n            <tr>\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:center;font-size:12px;color:#555;\">${item.ref}<\/td>\n                <td style=\"padding:9px 10px;border:1px solid #ddd;font-size:13px;\">${item.description}<\/td>\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:center;\">${item.quantity}<\/td>\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:right;\">${item.unit_price_ht.toFixed(3)}<\/td>\n                <td style=\"padding:9px 10px;border:1px solid #ddd;text-align:right;font-weight:600;\">${item.total_ht.toFixed(3)}<\/td>\n            <\/tr>`;\n        });\n\n                const quotationHTML = `\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;\">\n\n    <!-- HEADER -->\n    <div style=\"display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:28px;padding-bottom:18px;border-bottom:3px solid #1a1a1a;\">\n        <div>\n            <div style=\"font-size:22px;font-weight:900;color:#1a1a1a;letter-spacing:-.5px;\">FM MECA SARL<\/div>\n            <div style=\"font-size:11px;color:#d32f2f;font-weight:700;text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px;\">tunisia3dprint.com<\/div>\n            <div style=\"font-size:12px;color:#555;line-height:1.7;\">\n                Cit\u00e9 Ezzayatine, Ksibet Sousse, 4041 Sousse<br>\n                Tunisie<br>\n                T\u00e9l&nbsp;: +216 56 401 310<br>\n                RNE&nbsp;: C09408712023<br>\n                MF&nbsp;: 1814198PAM000            <\/div>\n        <\/div>\n        <div style=\"text-align:right;\">\n            <div style=\"font-size:28px;font-weight:900;color:#1a1a1a;text-transform:uppercase;letter-spacing:1px;\">Devis<\/div>\n            <div style=\"margin-top:8px;border:1px solid #ddd;border-radius:6px;overflow:hidden;font-size:12px;\">\n                <table style=\"border-collapse:collapse;width:100%;\">\n                    <tr style=\"background:#f4f4f4;\">\n                        <th style=\"padding:6px 12px;border-right:1px solid #ddd;font-weight:700;text-align:left;\">Num\u00e9ro<\/th>\n                        <th style=\"padding:6px 12px;border-right:1px solid #ddd;font-weight:700;text-align:left;\">Date<\/th>\n                        <th style=\"padding:6px 12px;font-weight:700;text-align:left;\">Page<\/th>\n                    <\/tr>\n                    <tr>\n                        <td style=\"padding:6px 12px;border-right:1px solid #ddd;font-weight:700;color:#d32f2f;\">${state.quotationNumber}<\/td>\n                        <td style=\"padding:6px 12px;border-right:1px solid #ddd;\">${dateStr}<\/td>\n                        <td style=\"padding:6px 12px;\">1<\/td>\n                    <\/tr>\n                <\/table>\n            <\/div>\n        <\/div>\n    <\/div>\n\n    <!-- CLIENT BLOCK -->\n    <div style=\"display:flex;justify-content:flex-end;margin-bottom:28px;\">\n        <div style=\"border:1px solid #ddd;border-top:3px solid #d32f2f;border-radius:4px;padding:14px 18px;min-width:260px;font-size:13px;\">\n            <div style=\"font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:#d32f2f;margin-bottom:8px;\">Client<\/div>\n            <div style=\"font-weight:700;margin-bottom:2px;\">${guestInfo.name}<\/div>\n            <div style=\"color:#555;\">${guestInfo.email}<\/div>\n            <div style=\"color:#555;margin-top:4px;\">${clientAddr}<\/div>\n            ${guestInfo.phone ? `<div style=\"color:#555;margin-top:4px;\">T\u00e9l : ${guestInfo.phone}<\/div>` : ''}\n        <\/div>\n    <\/div>\n\n    <!-- ITEMS TABLE -->\n    <table style=\"width:100%;border-collapse:collapse;margin-bottom:24px;font-size:13px;\">\n        <thead>\n            <tr style=\"background:#fff;color:#1a1a1a;border-bottom:2px solid #1a1a1a;\">\n                <th style=\"padding:10px;text-align:center;width:50px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">R\u00e9f.<\/th>\n                <th style=\"padding:10px;text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">Description<\/th>\n                <th style=\"padding:10px;text-align:center;width:50px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">Qt\u00e9<\/th>\n                <th style=\"padding:10px;text-align:right;width:120px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">P.U. HT (TND)<\/th>\n                <th style=\"padding:10px;text-align:right;width:120px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;\">Total HT (TND)<\/th>\n            <\/tr>\n        <\/thead>\n        <tbody>${rowsHTML}<\/tbody>\n        <!-- empty filler rows up to 10 lines total -->\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('')}\n    <\/table>\n\n    <!-- TOTALS -->\n    <div style=\"display:flex;justify-content:flex-end;margin-bottom:24px;\">\n        <table style=\"border-collapse:collapse;font-size:13px;min-width:320px;\">\n            <tr>\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;background:#fafafa;\">Total H.T.<\/td>\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;text-align:right;font-weight:600;\">${subtotalHT.toFixed(3)} TND<\/td>\n            <\/tr>\n            <tr>\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;background:#fafafa;\">TVA (19%)<\/td>\n                <td style=\"padding:9px 14px;border:1px solid #e0e0e0;text-align:right;\">${vatAmount.toFixed(3)} TND<\/td>\n            <\/tr>\n            <tr style=\"border-top:2px solid #1a1a1a;\">\n                <td style=\"padding:11px 14px;border:1px solid #e0e0e0;font-weight:800;font-size:14px;color:#1a1a1a;\">TOTAL T.T.C.<\/td>\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>\n            <\/tr>\n        <\/table>\n    <\/div>\n\n    <!-- AMOUNT IN WORDS -->\n    <div style=\"background:#f8f8f8;border-left:4px solid #d32f2f;padding:10px 14px;font-size:13px;margin-bottom:20px;\">\n        <strong>Arr\u00eat\u00e9 le pr\u00e9sent devis \u00e0 la somme de :<\/strong><br>\n        <span style=\"font-style:italic;\">${amountToWordsFR(grandTotal)}<\/span>\n    <\/div>\n\n    <!-- NOTES -->\n    <div style=\"font-size:12px;color:#555;margin-bottom:16px;\">\n        <strong style=\"color:#1a1a1a;display:block;margin-bottom:6px;\">Notes :<\/strong>\n        ${hasLowQuality ? `<div>\u2014 Qualit\u00e9 basse : convient surtout aux prototypes. Pour une pi\u00e8ce fonctionnelle, demandez-nous conseil.<\/div>` : ''}\n        <div>\u2014 Ce devis est valable un mois \u00e0 compter de sa date d'\u00e9mission.<\/div>\n        <!--\n          \u26a0\ufe0f 7 \u00e0 14 jours ouvrables, PAS 3 \u00e0 5. Cette ligne annon\u00e7ait 3 \u00e0 5 jours alors\n          que les CGV publient 7 \u00e0 14 jours ouvrables au total (fabrication + livraison),\n          d\u00e9lai choisi d\u00e9lib\u00e9r\u00e9ment large par le propri\u00e9taire. Un devis est un document\n          pr\u00e9contractuel : c'est l'engagement le plus court qui nous est oppos\u00e9. Les deux\n          chiffres doivent rester identiques \u2014 si l'un change, changer l'autre dans\n          plugins\/tn3dp-legal\/content\/terms-of-sale.*.md.\n        -->\n        <div>\u2014 D\u00e9lai estim\u00e9 : 7 \u00e0 14 jours ouvrables apr\u00e8s confirmation de commande, fabrication et livraison comprises.<\/div>\n        <div>\u2014 Les prix sont donn\u00e9s hors options non mentionn\u00e9es dans ce devis.<\/div>\n    <\/div>\n\n    <!-- FOOTER -->\n    <div class=\"quote-footer-pro\" style=\"border-top:1px solid #e0e0e0;padding-top:10px;text-align:center;font-size:11px;color:#999;\">\n        FM MECA SARL \u2014 RNE C09408712023 \u2014 MF 1814198PAM000 \u2014 Cit\u00e9 Ezzayatine, Ksibet Sousse, 4041 Sousse, Tunisie \u2014 tunisia3dprint.com \u2014 contact@tunisia3dprint.com    <\/div>\n\n<\/div>`;\n\n        elements.quotationContent.innerHTML = quotationHTML;\n        elements.quotationModal.style.display = 'flex';\n        saveFilesToServer('quote_viewed');\n    }\n    \n    window.closeQuotationPro = function() {\n        elements.quotationModal.style.display = 'none';\n    };\n\n    window.printQuotePro = function() {\n        const doc = document.getElementById('ppp-quote-doc');\n        if (!doc) return;\n\n        const itemCount = state.files.length;\n\n        \/\/ Clone and compact for print\n        const clone = doc.cloneNode(true);\n\n        \/\/ 1. Shrink filler rows: detect by single td with colspan=5 and no text\n        clone.querySelectorAll('tr').forEach(tr => {\n            const tds = tr.querySelectorAll('td');\n            if (tds.length === 1 && tds[0].getAttribute('colspan') === '5' && tds[0].textContent.trim() === '') {\n                tr.style.height = '10px';\n                tds[0].style.padding = '0';\n            }\n        });\n\n        \/\/ 2. Compact all table cells\n        clone.querySelectorAll('td, th').forEach(el => {\n            el.style.padding = '5px 8px';\n            el.style.fontSize = '11px';\n        });\n\n        \/\/ 3. Scale down large decorative font sizes, reduce paddings on divs\n        const fontMap = {'32px':'18px','28px':'18px','22px':'15px','14px':'12px','15px':'12px','13px':'11px','12px':'10px'};\n        clone.querySelectorAll('*').forEach(el => {\n            const fs = el.style.fontSize;\n            if (fontMap[fs]) el.style.fontSize = fontMap[fs];\n            \/\/ Reduce large div paddings\n            const p = el.style.padding;\n            if (p && el.tagName !== 'TD' && el.tagName !== 'TH') {\n                el.style.padding = p.replace(\/\\b(2[4-9]|[3-9]\\d)px\\b\/g, '10px')\n                                    .replace(\/\\b(1[6-9])px\\b\/g, '8px');\n            }\n            \/\/ Reduce large margins\n            const mb = el.style.marginBottom;\n            if (mb && parseInt(mb) > 14) el.style.marginBottom = '10px';\n        });\n\n        \/\/ 4. For many items, allow a second page break before totals section\n        \/\/ (items > 7: totals block gets page-break-before)\n        if (itemCount > 7) {\n            const totalsDiv = clone.querySelector('div[style*=\"justify-content:flex-end\"]');\n            if (totalsDiv) totalsDiv.style.pageBreakBefore = 'always';\n        }\n\n        \/\/ 5. Pin footer to bottom of A4 page \u2014 make doc a flex column, push footer down\n        clone.style.display = 'flex';\n        clone.style.flexDirection = 'column';\n        clone.style.minHeight = 'calc(297mm - 16mm)';\n        const footerEl = clone.querySelector('.quote-footer-pro');\n        if (footerEl) footerEl.style.marginTop = 'auto';\n\n        const pw = window.open('', '_blank', 'width=860,height=700');\n        if (!pw) { showAlert(i18n.printPopupBlocked, 'error'); return; }\n        pw.document.write(`<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"UTF-8\">\n<title>Devis ${state.quotationNumber}<\/title>\n<style>\n  * { box-sizing: border-box; }\n  body {\n    font-family: Arial, sans-serif;\n    font-size: 11px;\n    color: #1a1a1a;\n    line-height: 1.35;\n    margin: 0;\n    padding: 8mm 10mm;\n  }\n  table { border-collapse: collapse; width: 100%; }\n  thead { display: table-header-group; }\n  tr { page-break-inside: avoid; }\n  @page { size: A4; margin: 0; }\n<\/style>\n<\/head>\n<body>\n${clone.outerHTML}\n<script>\n  window.onload = function() {\n    setTimeout(function() {\n      \/* window.print() blocks in Chrome but NOT on Safari\/iOS, where closing\n         immediately can cancel the job and print a blank page. Close on\n         afterprint instead, with a long fallback so the window never lingers. *\/\n      var done = false;\n      var bye = function () { if (done) { return; } done = true; window.close(); };\n      if ('onafterprint' in window) { window.onafterprint = bye; }\n      window.print();\n      setTimeout(bye, 60000);\n    }, 500);\n  };\n<\\\/script>\n<\/body>\n<\/html>`);\n        pw.document.close();\n    };\n    \n    function handleAddToCart(trigger) {\n        if (state.files.length === 0) {\n            showAlert(i18n.noFiles, 'error');\n            return;\n        }\n\n        \/\/ \ud83d\udd34 NO PRICEABLE FILE, NO ORDER. Every uploaded file is one we could not\n        \/\/ measure, so the total is 0.000 and there is nothing honest to sell.\n        \/\/ Owner, 2026-08-25: \"let's change the system to push client sending\n        \/\/ these files manually to our team\" \u2014 which is the CTA already on each\n        \/\/ card, so this message points at it rather than adding a second route.\n        \/\/ \u26a0\ufe0f A MIXED basket is allowed through on purpose: the readable files get\n        \/\/ priced and ordered, and the unreadable one keeps its own note and its\n        \/\/ own send-to-team button.\n        const priceable = state.files.some(function (f, i) {\n            const d = state.fileData.get('file_' + i);\n            return d && !d.error && !d.noQuote;\n        });\n        if (!priceable) {\n            showAlert(i18n.noPriceableFiles, 'error');\n            return;\n        }\n\n        if (!tdpRequireAccount(function () { handleAddToCart(trigger); })) {\n            return;\n        }\n\n        \/\/ Add to cart is wired straight to this handler, so it can be the FIRST\n        \/\/ thing that reaches the server \u2014 the quotation preview is optional.\n        ensureQuotationNumber(function () { doAddToCart(trigger); });\n    }\n\n    \/**\n     * @param {HTMLElement} [trigger] Which button was pressed \u2014 the sidebar CTA or the\n     *                                one at the foot of the quotation.\n     *\/\n    function doAddToCart(trigger) {\n        \/*\n         * \u26a0\ufe0f BOTH buttons are disabled for the duration, not just the one clicked.\n         * There are now two ways to submit the same order, and this request creates a\n         * WooCommerce product and an order \u2014 letting someone press the other button\n         * while the first is in flight would bill them twice. Each button's own label is\n         * restored afterwards, so a disabled sidebar CTA stays disabled.\n         *\/\n        const active  = (trigger && trigger.nodeType === 1) ? trigger : elements.cartBtn;\n        const buttons = [ elements.cartBtn, elements.quoteCartBtn ].filter(Boolean);\n        const restore = buttons.map(function (b) {\n            return { el: b, html: b.innerHTML, disabled: b.disabled };\n        });\n\n        buttons.forEach(function (b) {\n            b.disabled = true;\n            b.classList.add('loading');\n        });\n        active.innerHTML = i18n.cartProcessing;\n        \n        const productData = {\n            action: 'tdp_create_order',\n            nonce: window.pro_ajax_nonce,\n            quotation_number: state.quotationNumber,\n            total_price: parseFloat(elements.totalPrice.textContent),\n            user_email: guestInfo.email,\n            user_name: guestInfo.name,\n            user_phone: guestInfo.phone,\n            gdpr_consent: guestInfo.gdpr_consent,\n            files: state.files.map((file, index) => {\n                const card = document.querySelectorAll('.stl-file-card-pro')[index];\n                const quantity = parseInt(card.querySelector('.quantity-pro').value) || 1;\n                const unitPrice = parseFloat(card.querySelector('.unit-price-ht-pro').value) || 0;\n                const totalHT = unitPrice * quantity;\n                \n                return {\n                    file_name: file.name,\n                    technology: card.querySelector('.tech-pro').value,\n                    material: card.querySelector('.material-pro').value,\n                    color: card.querySelector('.color-pro').value,\n                    quality: card.querySelector('.quality-pro').value,\n                    quantity: quantity,\n                    volume: card.querySelector('.volume-pro').value,\n                    surface: card.querySelector('.surface-pro').value,\n                    unit_price: unitPrice.toFixed(3),\n                    price_ht: totalHT.toFixed(3),\n                    price_ttc: (totalHT * 1.19).toFixed(3)\n                };\n            })\n        };\n        \n        jQuery.ajax({\n            type: 'POST',\n            url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\n            data: productData,\n            success: function(response) {\n                if (response.success) {\n                    \/*\n                     * GA4 quote_add_to_cart (T7) \u2014 ON SUCCESS, NEVER ON THE CLICK.\n                     * This request creates a WooCommerce product and an order, and\n                     * it is refused for a price mismatch, an expired nonce or a\n                     * basket with nothing priceable in it. Counting the click would\n                     * count every one of those refusals as a conversion.\n                     *\/\n                    if (typeof window.tn3dpTrack === 'function') {\n                        window.tn3dpTrack('quote_add_to_cart', {\n                            value: Number((state.totalTTC || 0).toFixed(3)),\n                            currency: 'TND',\n                            files: state.files.length,\n                            quotation_number: state.quotationNumber || ''\n                        });\n                    }\n                    saveFilesToServer('cart_added');\n                    showAlert(i18n.orderCreated, 'success');\n                    setTimeout(() => {\n                        window.location.href = response.data.redirect_url || '\/cart\/';\n                    }, 1500);\n                } else {\n                    showAlert(i18n.orderError, 'error');\n                }\n            },\n            error: function(xhr, status, error) {\n                console.error('AJAX error:', error);\n                showAlert(i18n.connectionError, 'error');\n            },\n            complete: function() {\n                restore.forEach(function (r) {\n                    r.el.disabled  = r.disabled;\n                    r.el.classList.remove('loading');\n                    r.el.innerHTML = r.html;\n                });\n            }\n        });\n    }\n    \n    function updateFileCounter() {\n        elements.fileCounter.innerHTML = state.files.length > 0\n            ? `${i18n.fileCountPrefix} ${state.files.length} ${i18n.filesSelectedSuffix}`\n            : i18n.noFileSelected;\n    }\n    \n    function showAlert(message, type) {\n        const alert = document.createElement('div');\n        alert.className = `alert-message-pro alert-${type}-pro`;\n        alert.innerHTML = message;\n        elements.alertContainer.appendChild(alert);\n        setTimeout(() => alert.remove(), 5000);\n    }\n\n    \/\/ ==================== FILE SAVING ====================\n\n    \/**\n     * The verdict this customer was actually shown, in a shape small enough to\n     * store on the order (PR6, 2026-08-25).\n     *\n     * \ud83d\udd34 THIS IS A RECORD OF WHAT WE TOLD SOMEBODY, NOT AN INPUT TO ANYTHING.\n     * It is browser-supplied, so it must never be trusted to decide money, a\n     * price or a workflow state - that is the v2.8.0 lesson, where the ORDER\n     * TOTAL came from $_POST and any visitor could buy a print for a dinar.\n     * Here the browser's answer is precisely the thing being asked about\n     * (\"what did the page say?\"), so taking it from the browser is correct;\n     * treating it as a measurement of the part would not be.\n     *\n     * Why it is stored at all: until now the score, the five factor scores and\n     * the note the customer read were computed and thrown away. Nobody could\n     * audit why a customer saw a note, a moderator opening an order could not\n     * see what they had been promised, and there was no data to tune a\n     * threshold against - which is the input every remaining item in\n     * docs\/printability-analyzer-plan.md needs.\n     *\n     * \u26a0\ufe0f Notes are recorded as IDS, not sentences. An id survives a reword, is\n     * the same in all three languages, and traces to exactly one rule on the\n     * Tunisia3DPrint \u2192 Printability screen, which prints its EN\/FR\/AR text.\n     * `lang` records which of those the customer actually read.\n     *\/\n    function tdpVerdictForFile(index) {\n        const d = state.fileData.get('file_' + index);\n        if (!d || d.error) return null;\n\n        const f  = d.printabilityFactors || {};\n        const ov = d.overhangData  || {};\n        const th = d.thicknessData || {};\n        const me = d.meshData      || {};\n        const dm = d.dimensions    || {};\n\n        const n1 = v => Math.round((Number(v) || 0) * 10) \/ 10;\n        const n2 = v => Math.round((Number(v) || 0) * 100) \/ 100;\n        const i0 = v => Math.round(Number(v) || 0);\n        const flag = o => o.isCritical ? 'critical' : o.isWarning ? 'warning' : 'clean';\n\n        return {\n            score:     i0(d.printabilityScore),\n            verdict:   d.printability || '',\n            lang:      window.tdpActiveLang || 'en',\n            estimated: !!d.estimated,\n            triangles: i0(d.triangleCount),\n            dims:      [n1(dm.width), n1(dm.height), n1(dm.depth)],\n            volumeCm3: n2(d.volume),\n            factors: {\n                aspect:     i0(f.aspectScore),\n                efficiency: i0(f.efficiencyScore),\n                overhang:   i0(f.overhangScore),\n                thickness:  i0(f.thicknessScore),\n                mesh:       i0(f.meshScore)\n            },\n            overhang: {\n                best:    ov.bestOrientation || '',\n                pct:     n2(ov.bestAreaPercent),\n                regions: i0(ov.bestRegions),\n                span:    n1(ov.bestSpanMm),\n                plate:   n2(ov.plateAreaPercent),\n                flag:    flag(ov)\n            },\n            thickness: { p5: n2(th.p5ThicknessMm), method: th.method || '', flag: flag(th) },\n            mesh: {\n                open:        i0(me.openEdges),\n                nonManifold: i0(me.nonManifoldEdges),\n                consistency: n2(me.volumeConsistency),\n                flag:        flag(me)\n            },\n            \/\/ Which rules spoke, in the order the customer read them.\n            notes: (typeof tdpEvaluateNotes === 'function') ? tdpEvaluateNotes(d).all : []\n        };\n    }\n\n    \/* ==================================================================\n     * SIMULATION RECORD \u2014 every price the calculator shows, kept for tuning.\n     *\n     * Two separate requests, and the split is the whole design:\n     *\n     *   1. tdp_sim_log        \u2014 a few hundred bytes of numbers, sent on every\n     *                           settled price change. Cheap enough to fire often.\n     *   2. tdp_sim_store_file \u2014 the file bytes, sent AT MOST ONCE per distinct\n     *                           file, and only when the server says it does not\n     *                           already hold that sha256.\n     *\n     * Sending the bytes on every recalculation (which is what saveFilesToServer\n     * does, deliberately, at its two moments) would push tens of megabytes while\n     * somebody drags a quantity spinner.\n     *\n     * \u26a0\ufe0f THE PRICE LOGGED IS THE COMPUTED ONE EVEN WHILE THE GATE IS LOCKED, and\n     * `gated` records that the visitor only saw a range. That is the point: the\n     * question worth answering later is \"what would we have charged, and did they\n     * walk away?\" \u2014 which is unanswerable if a locked session logs a zero.\n     * \u26a0\ufe0f Nothing here writes an exact figure into the DOM. The gate's rule is about\n     * what the VISITOR can see, and it is untouched.\n     * ================================================================== *\/\n\n    const tdpSim = {\n        session: (function () {\n            \/\/ Groups one visit, so repeated fiddling collapses into one row per\n            \/\/ settings combination rather than one row per keystroke. sessionStorage\n            \/\/ (not localStorage) because a new visit should be a new session.\n            try {\n                let s = sessionStorage.getItem('tdpSimSession');\n                if (!s || !\/^[a-f0-9]{32}$\/.test(s)) {\n                    s = '';\n                    const b = new Uint8Array(16);\n                    (window.crypto || {}).getRandomValues && crypto.getRandomValues(b);\n                    for (let i = 0; i < 16; i++) { s += b[i].toString(16).padStart(2, '0'); }\n                    sessionStorage.setItem('tdpSimSession', s);\n                }\n                return s;\n            } catch (e) {\n                return ''; \/\/ Private mode. The server falls back to an IP+day key.\n            }\n        })(),\n        offered: {},   \/\/ sha256 -> true, so bytes are offered once per page load\n        timer:   null\n    };\n\n    \/**\n     * sha256 of a file, cached ON THE File OBJECT.\n     *\n     * \u26a0\ufe0f Cached on the object, not under a fileId, because removing a file\n     * reindexes every id after it \u2014 a fileId-keyed cache would silently hand one\n     * file's hash to another, and the store is keyed on that hash.\n     *\n     * crypto.subtle exists only on a secure context. On plain HTTP it is absent, so\n     * this resolves to '' and the row is logged without a file reference rather than\n     * throwing. The live site is HTTPS; this is for a local or proxied dev setup.\n     *\/\n    function tdpSimHash(file) {\n        if (file._tdpHash !== undefined) return Promise.resolve(file._tdpHash);\n        if (!window.crypto || !crypto.subtle || !file.arrayBuffer) {\n            file._tdpHash = '';\n            return Promise.resolve('');\n        }\n        return file.arrayBuffer()\n            .then(function (buf) { return crypto.subtle.digest('SHA-256', buf); })\n            .then(function (digest) {\n                let hex = '';\n                new Uint8Array(digest).forEach(function (b) { hex += b.toString(16).padStart(2, '0'); });\n                file._tdpHash = hex;\n                return hex;\n            })\n            .catch(function () { file._tdpHash = ''; return ''; });\n    }\n\n    \/**\n     * Debounced entry point, called at the end of every updatePricesPro().\n     *\n     * 1500 ms because that function re-runs on every keystroke in the quantity box.\n     * The row is an upsert keyed on (session, file, material, quality, colour, qty),\n     * so a settled value is what gets recorded and a passing one is not.\n     *\/\n    function tdpQueueSimLog(locked, rangeMin, rangeMax) {\n        if (!window.tdpSimCfg || !window.tdpSimCfg.enabled) return;\n        clearTimeout(tdpSim.timer);\n        tdpSim.timer = setTimeout(function () {\n            try { tdpSendSimLog(locked, rangeMin, rangeMax); } catch (e) {}\n        }, 1500);\n    }\n\n    function tdpSendSimLog(locked, rangeMin, rangeMax) {\n        const cards = document.querySelectorAll('.stl-file-card-pro');\n        const jobs  = [];\n        const rows  = [];\n        const byHash = {};\n\n        cards.forEach(function (card, index) {\n            if (index >= state.files.length) return;\n\n            const file     = state.files[index];\n            const fileData = state.fileData.get('file_' + index);\n            if (!file || !fileData || fileData.error) return;\n\n            jobs.push(tdpSimHash(file).then(function (hash) {\n                const tech     = (card.querySelector('.tech-pro')     || {}).value || '';\n                const material = (card.querySelector('.material-pro') || {}).value || '';\n                const quality  = (card.querySelector('.quality-pro')  || {}).value || '';\n                const color    = (card.querySelector('.color-pro')    || {}).value || '';\n                const vol      = parseFloat((card.querySelector('.volume-pro')  || {}).value) || 0;\n                const surf     = parseFloat((card.querySelector('.surface-pro') || {}).value) || 0;\n                const qty      = parseInt((card.querySelector('.quantity-pro')  || {}).value, 10) || 1;\n\n                let unit = 0, total = 0;\n                if (!fileData.noQuote && vol > 0) {\n                    const p = pricingPro.calculateDetailedPrice(vol, surf, tech, material, quality, qty, color);\n                    total = p.priceTTC;\n                    unit  = qty > 0 ? p.priceTTC \/ qty : p.priceTTC;\n                }\n\n                const v    = (typeof tdpVerdictForFile === 'function') ? tdpVerdictForFile(index) : null;\n                const dims = (v && v.dims) ? v.dims : [0, 0, 0];\n                const dot  = file.name.lastIndexOf('.');\n\n                if (hash) { byHash[hash] = file; }\n\n                rows.push({\n                    file_hash:  hash,\n                    file_name:  file.name,\n                    file_ext:   dot > -1 ? file.name.slice(dot + 1).toLowerCase() : '',\n                    file_size:  file.size,\n                    volume_cm3: vol,\n                    surface_cm2: surf,\n                    bbox_x: dims[0], bbox_y: dims[1], bbox_z: dims[2],\n                    triangles:  (v && v.triangles) ? v.triangles : 0,\n                    technology: tech,\n                    material:   material,\n                    quality:    quality,\n                    color:      color,\n                    quantity:   qty,\n                    unit_price:  Number(unit.toFixed(3)),\n                    total_price: Number(total.toFixed(3)),\n                    gated:       locked ? 1 : 0,\n                    teaser_min:  locked ? rangeMin : 0,\n                    teaser_max:  locked ? rangeMax : 0,\n                    printability_score:   (v && typeof v.score === 'number') ? v.score : -1,\n                    printability_verdict: (v && v.verdict) ? v.verdict : '',\n                    printability_notes:   (v && v.notes) ? [].concat(v.notes).join(',') : '',\n                    quotation_number: state.quotationNumber || '',\n                    lang: window.tdpActiveLang || 'en',\n                    user_name:  guestInfo.name  || '',\n                    user_email: guestInfo.email || ''\n                });\n            }));\n        });\n\n        if (!jobs.length) return;\n\n        Promise.all(jobs).then(function () {\n            if (!rows.length) return;\n\n            jQuery.ajax({\n                type: 'POST',\n                url: 'https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php',\n                data: {\n                    action: 'tdp_sim_log',\n                    nonce: window.t3d_tracking_nonce,\n                    session_key: tdpSim.session,\n                    rows: rows\n                },\n                success: function (res) {\n                    if (!window.tdpSimCfg.storeFiles) return;\n                    if (!res || !res.success || !res.data || !res.data.missing) return;\n                    res.data.missing.forEach(function (hash) {\n                        if (byHash[hash]) { tdpSimUploadFile(byHash[hash], hash); }\n                    });\n                }\n            });\n        });\n    }\n\n    \/**\n     * Send one file's bytes, once. Silent on every failure \u2014 this is bookkeeping.\n     *\n     * \ud83d\udd34 A REAL MULTIPART UPLOAD, NOT BASE64, AND THAT IS THE BUG FIX.\n     *\n     * The first version read the file and base64'd it into a form field. Two things\n     * went wrong, and the owner met both with a single 10 MB cat.stl:\n     *\n     *   1. base64 inflates by 4\/3, so a 10 MB model became a ~13.5 MB request body \u2014\n     *      which this host refuses. PHP then discards $_POST entirely, so the nonce\n     *      check fails and the file is SILENTLY never stored. Measured on staging:\n     *      6.68 MB stored, 10.15 MB did not; as multipart, 10 MB is accepted.\n     *   2. Building that string with String.fromCharCode over millions of bytes runs\n     *      on the main thread and visibly freezes the tab.\n     *\n     * Handing the File object straight to FormData fixes both: the browser streams it,\n     * nothing is copied in JS, and the body is the file's real size.\n     *\n     * \u26a0\ufe0f NO Content-Type HEADER IS SET, deliberately. fetch() must generate the\n     * multipart boundary itself; setting it by hand produces a body PHP cannot parse\n     * and $_FILES arrives empty.\n     *\/\n    function tdpSimUploadFile(file, hash) {\n        if (tdpSim.offered[hash]) return;\n        tdpSim.offered[hash] = true;\n\n        if (file.size > window.tdpSimCfg.maxBytes) return;\n\n        const dot = file.name.lastIndexOf('.');\n        const ext = dot > -1 ? file.name.slice(dot + 1).toLowerCase() : '';\n        if (ext !== 'stl' && ext !== 'obj') return;\n\n        const fd = new FormData();\n        fd.append('action',    'tdp_sim_store_file');\n        fd.append('nonce',     window.t3d_tracking_nonce);\n        fd.append('file_hash', hash);\n        fd.append('file_ext',  ext);\n        fd.append('file',      file, file.name);\n\n        fetch('https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php', {\n            method: 'POST',\n            credentials: 'same-origin',\n            body: fd\n        }).catch(function () { \/* bookkeeping: never disturb the quote *\/ });\n    }\n\n    \/*\n     * \ud83d\udd34 SENDS THE FILES AS A REAL MULTIPART UPLOAD, NOT BASE64 (v2.29.0).\n     *\n     * This is the save that keeps a PAYING CUSTOMER'S model \u2014 it runs on the devis and\n     * again on add-to-cart \u2014 and it had the defect measured on the simulation store the\n     * day before: base64 inflates by 4\/3, so a 10 MB part became a ~13.5 MB body and the\n     * request took **106.6 seconds** against 60.8 s as multipart on the same host. Worse,\n     * building that string with String.fromCharCode over millions of bytes runs on the\n     * main thread and freezes the tab \u2014 at the exact moment the customer is trying to\n     * order. Any navigation during those two minutes lost the file silently.\n     *\n     * \u26a0\ufe0f The File objects are handed to FormData directly: nothing is read or copied in\n     * JS at all, so the FileReader pass is gone entirely.\n     *\n     * \u26a0\ufe0f NO Content-Type HEADER \u2014 fetch() must generate the multipart boundary itself;\n     * setting it by hand yields a body PHP cannot parse and $_FILES arrives empty.\n     *\n     * \u26a0\ufe0f The field names must stay `files[i][name]`, `files[i][verdict]`, `files[i][blob]`.\n     * PHP splits that last one into $_FILES['files']['tmp_name'][i]['blob'], and the\n     * server pairs the two arrays BY INDEX \u2014 rename either side and the verdict is\n     * attached to the wrong file.\n     *\/\n    function saveFilesToServer(eventType) {\n        if (state.files.length === 0) return;\n\n        const fd = new FormData();\n        fd.append('action',           't3d_save_uploaded_files');\n        fd.append('nonce',            window.t3d_tracking_nonce);\n        fd.append('quotation_number', state.quotationNumber || '');\n        fd.append('event_type',       eventType);\n        fd.append('total_price',      elements.totalPrice.textContent);\n        fd.append('user_email',       guestInfo.email || '');\n        fd.append('user_name',        guestInfo.name || '');\n        fd.append('user_phone',       guestInfo.phone || '');\n        fd.append('gdpr_consent',     guestInfo.gdpr_consent || '');\n        fd.append('guest_identifier', guestInfo.guest_identifier || '');\n\n        state.files.forEach((file, index) => {\n            \/\/ Captured HERE, by index, exactly as the old code did: it is the only\n            \/\/ reliable link back to this file's analysis.\n            const verdict = tdpVerdictForFile(index);\n            fd.append('files[' + index + '][name]',    file.name);\n            fd.append('files[' + index + '][verdict]', verdict ? JSON.stringify(verdict) : '');\n            fd.append('files[' + index + '][blob]',    file, file.name);\n        });\n\n        fetch('https:\/\/tunisia3dprint.com\/wp-admin\/admin-ajax.php', {\n            method: 'POST',\n            credentials: 'same-origin',\n            body: fd\n        }).catch(function () { \/* the quote must never break on a bookkeeping save *\/ });\n    }\n    \n    init();\n});\n\n\/* === TDP Design Tips === *\/\n\nfunction tdpSelectTip(result) {\n    if (!window.tdpTips || !tdpTips.tips_enabled) return null;\n\n    \/\/ WHICH note fires is decided in printability-core.js, by id, so the admin\n    \/\/ test panel and the record written onto the order agree with what the\n    \/\/ customer read. THIS function owns only the wording.\n    \/\/\n    \/\/ \u26a0\ufe0f Since 2026-08-25 it is always EXACTLY ONE note, one sentence, short\n    \/\/ enough for two lines \u2014 and it never reads as a reason not to order. See\n    \/\/ the header of tdpEvaluateNotes() for the four rules and why.\n    const ev = tdpEvaluateNotes(result);\n\n    const headlines = {\n        excellent: tdpTips.headline_excellent,\n        good:      tdpTips.headline_good,\n        fair:      tdpTips.headline_improve,\n        poor:      tdpTips.headline_poor\n    };\n\n    \/\/ Note id -> key in the translated bundle. Written out rather than derived\n    \/\/ from the id, so a renamed id fails loudly here instead of putting a silent\n    \/\/ `undefined` in the customer's paragraph.\n    const KEY = {\n        ready:           'tip_ready',\n        minor_support:   'tip_minor_support',\n        minor_thin:      'tip_minor_thin',\n        minor_several:   'tip_minor_several',\n        major_support:   'tip_major_support',\n        major_thin:      'tip_major_thin',\n        major_mesh:      'tip_major_mesh',\n        major_several:   'tip_major_several',\n        quote_unreadable: 'tip_quote_unreadable',\n        quote_no_volume:  'tip_quote_no_volume'\n    };\n\n    const body = tdpTips[KEY[ev.id]] || '';\n\n    return {\n        headline:    headlines[ev.level],\n        body:        body,\n        level:       ev.level,\n        \/\/ A serious finding still never blocks: the CTA stays, and the note\n        \/\/ itself says to place the order. This only drives the tip's styling.\n        hasCritical: ev.severity === 'major',\n        \/\/ Recorded with the order (PR6) so a moderator can see WHICH rule spoke,\n        \/\/ not just the sentence it produced - and in any language.\n        noteIds:     ev.all\n    };\n}\n\nfunction tdpEscapeHtml(s) {\n    return String(s == null ? '' : s)\n        .replace(\/&\/g, '&amp;')\n        .replace(\/<\/g, '&lt;')\n        .replace(\/>\/g, '&gt;')\n        .replace(\/\"\/g, '&quot;')\n        .replace(\/'\/g, '&#39;');\n}\n\nfunction tdpRenderDesignTip(result, filename, fileId) {\n    \/\/ Remove any previous tip block for this card.\n    const existing = document.getElementById('tdp-design-tip-block-' + fileId);\n    if (existing) existing.remove();\n\n    const tip = tdpSelectTip(result);\n    if (!tip) return;\n\n    const icons   = { excellent: '\u2705', good: '\ud83d\udc4d', fair: '\ud83d\udca1', poor: '\u26a0\ufe0f' };\n\n    \/\/ \u26a0\ufe0f BOUND TO THE NOTE'S SEVERITY, NOT TO THE SCORE BAND. It used to read\n    \/\/ `level === 'fair' || 'poor'`, and the 2026-08-25 re-weighting moved almost\n    \/\/ every real part up to Excellent or Good \u2014 which would have silently\n    \/\/ removed the customer's one-click route to the team, exactly for the parts\n    \/\/ that need it. Severity says the same thing the note says: this one is\n    \/\/ worth a conversation.\n    const showCTA = tip.hasCritical;\n\n    \/\/ Pre-encode all dynamic strings for safe inline HTML.\n    const safeFilename   = tdpEscapeHtml(filename);\n    const safeHeadline   = tdpEscapeHtml(tip.headline);\n    const safeBody       = tdpEscapeHtml(tip.body);\n    const safeFileId     = tdpEscapeHtml(fileId);\n\n    \/\/ Store the email payload in a JS map keyed by fileId. The button only ever\n    \/\/ carries the safe fileId \u2014 translated text (which can contain apostrophes,\n    \/\/ e.g. French \"l'impression\") never touches the HTML attribute layer, so it\n    \/\/ can no longer break the inline handler.\n    window.tdpTipData = window.tdpTipData || {};\n    window.tdpTipData[fileId] = {\n        filename:  filename,\n        level:     tip.level,\n        levelText: result.printabilityText || tip.level,\n        tipText:   tip.body,\n    };\n\n    let ctaHTML = '';\n    if (showCTA) {\n        if (tdpTips.isLoggedIn) {\n            \/\/ Logged-in: one-click send, no phone field needed.\n            ctaHTML = `\n        <div class=\"tdp-tip-cta\">\n            <p class=\"tdp-tip-cta-title\">${tdpEscapeHtml(tdpTips.cta_title)}<\/p>\n            <button type=\"button\" class=\"tdp-tip-send-btn\"\n                    onclick=\"tdpSendToTeam('${safeFileId}')\">\n                \u2709 ${tdpEscapeHtml(tdpTips.cta_button)}\n            <\/button>\n            <div class=\"tdp-tip-send-msg\" id=\"tdp-tip-send-msg-${safeFileId}\"><\/div>\n        <\/div>`;\n        } else {\n            \/\/ Guest: phone required before button activates.\n            ctaHTML = `\n        <div class=\"tdp-tip-cta\">\n            <p class=\"tdp-tip-cta-title\">${tdpEscapeHtml(tdpTips.cta_title)}<\/p>\n            <p class=\"tdp-tip-guest-note\">${tdpEscapeHtml(tdpTips.cta_guest_note)}<\/p>\n            <label class=\"tdp-tip-phone-label\" for=\"tdp-tip-phone-${safeFileId}\">${tdpEscapeHtml(tdpTips.cta_phone_label)}<\/label>\n            <input type=\"tel\" id=\"tdp-tip-phone-${safeFileId}\" class=\"tdp-tip-phone-input\"\n                   placeholder=\"${tdpEscapeHtml(tdpTips.cta_phone_placeholder)}\"\n                   oninput=\"tdpUpdateSendBtn('${safeFileId}')\" \/>\n            <button type=\"button\" class=\"tdp-tip-send-btn\" disabled\n                    onclick=\"tdpSendToTeam('${safeFileId}')\">\n                \u2709 ${tdpEscapeHtml(tdpTips.cta_button)}\n            <\/button>\n            <div class=\"tdp-tip-send-msg\" id=\"tdp-tip-send-msg-${safeFileId}\"><\/div>\n        <\/div>`;\n        }\n    }\n\n    const html = `\n        <div class=\"tdp-design-tip tdp-tip-${tip.level}\" id=\"tdp-design-tip-block-${safeFileId}\" data-filename=\"${safeFilename}\">\n            <div class=\"tdp-tip-header\">\n                <span class=\"tdp-tip-icon\">${icons[tip.level] || '\ud83d\udca1'}<\/span>\n                <span class=\"tdp-tip-headline\">${safeHeadline}<\/span>\n            <\/div>\n            <p class=\"tdp-tip-body\">${safeBody}<\/p>\n            ${ctaHTML}\n        <\/div>`;\n\n    \/\/ Insert after the preview section so the tip sits between the printability\n    \/\/ bar and the cost-breakdown row.\n    const label = document.getElementById('printabilityLabel_' + fileId);\n    const previewSection = label && label.closest('.preview-section-pro');\n    if (previewSection) {\n        previewSection.insertAdjacentHTML('afterend', html);\n    }\n}\n\n\/**\n * Enable \/ disable the send button for a guest card based on phone input length.\n *\/\nfunction tdpUpdateSendBtn(fileId) {\n    const phoneInput = document.getElementById('tdp-tip-phone-' + fileId);\n    const btn = document.querySelector('#tdp-design-tip-block-' + fileId + ' .tdp-tip-send-btn');\n    if (btn && phoneInput) {\n        btn.disabled = phoneInput.value.trim().length < 6;\n    }\n}\n\n\/**\n * Show a prominent banner message inside the tip card.\n * type: 'success' | 'error' | '' (clear)\n *\/\nfunction tdpShowSendMsg(msgEl, text, type) {\n    if (!msgEl) return;\n    msgEl.textContent = text;\n    msgEl.className   = 'tdp-tip-send-msg' + (type ? ' tdp-send-' + type : '');\n    msgEl.style.display = text ? 'block' : 'none';\n}\n\n\/**\n * Send printability info + contact details to the team. The server side posts\n * this through the site-notifications email hub (migrated 2026-08-17), not\n * wp_mail() directly \u2014 see TDP_Tracking::handle_send_team_email_ajax().\n * The file itself is NOT uploaded here (avoids PHP post_max_size limits).\n * The admin receives all contact info and can request the file directly.\n *\/\n\/\/ Read a File as raw base64 (no \"data:...;base64,\" prefix) for JSON-safe transport.\nfunction tdpFileToBase64(file) {\n    return new Promise(function (resolve, reject) {\n        const r = new FileReader();\n        r.onload  = function () {\n            const s = String(r.result || '');\n            const i = s.indexOf(',');\n            resolve(i >= 0 ? s.slice(i + 1) : s);\n        };\n        r.onerror = function () { reject(r.error); };\n        r.readAsDataURL(file);\n    });\n}\n\nasync function tdpSendToTeam(fileId) {\n    const btn     = document.querySelector('#tdp-design-tip-block-' + fileId + ' .tdp-tip-send-btn');\n    const msgEl   = document.getElementById('tdp-tip-send-msg-' + fileId);\n    const phoneEl = document.getElementById('tdp-tip-phone-' + fileId);\n    const phone   = phoneEl ? phoneEl.value.trim() : '';\n\n    \/\/ Payload was stored at render time, keyed by the safe fileId.\n    const data = (window.tdpTipData && window.tdpTipData[fileId]) || {};\n\n    \/\/ Safety net: if the DOM elements are somehow missing, abort visibly.\n    if (!btn || !msgEl) {\n        console.error('[TDP send-to-team] DOM elements not found for fileId:', fileId);\n        return;\n    }\n\n    \/\/ Guest validation.\n    if (!tdpTips.isLoggedIn && !phone) {\n        tdpShowSendMsg(msgEl, tdpTips.cta_phone_required, 'error');\n        return;\n    }\n\n    \/\/ Show loading state.\n    btn.disabled = true;\n    btn.textContent = tdpTips.cta_sending;\n    tdpShowSendMsg(msgEl, '', '');\n\n    \/\/ Multipart payload so we can also embed the uploaded STL\/OBJ (base64) and\n    \/\/ give the team email a direct download link to the customer's file.\n    const params = new FormData();\n    params.append('action',     'tdp_send_team_email');\n    params.append('nonce',      tdpTips.sendNonce);\n    params.append('filename',   data.filename  || '');\n    params.append('level',      data.level     || '');\n    params.append('level_text', data.levelText || '');\n    params.append('tip_text',   data.tipText   || '');\n    params.append('phone',      phone);\n\n    \/\/ Attach the actual file bytes if we still hold them in the browser. Skip\n    \/\/ very large files to stay within PHP upload limits \u2014 the email then falls\n    \/\/ back to \"contact the customer for the file\".\n    const srcFile = (window.tdpFileStore && window.tdpFileStore[fileId]) || null;\n    const TDP_MAX_ATTACH = 40 * 1024 * 1024; \/\/ 40 MB raw\n    if (srcFile && srcFile.size <= TDP_MAX_ATTACH) {\n        try {\n            params.append('file_data', await tdpFileToBase64(srcFile));\n            params.append('file_name', srcFile.name);\n        } catch (e) {\n            console.warn('[TDP send-to-team] could not read file for upload:', e);\n        }\n    }\n\n    try {\n        const response = await fetch(tdpTips.ajaxUrl, {\n            method:      'POST',\n            credentials: 'same-origin',\n            body:        params,\n        });\n\n        \/\/ Read as text first so we can log unexpected non-JSON responses.\n        const rawText = await response.text();\n        let data;\n        try {\n            data = JSON.parse(rawText);\n        } catch (parseErr) {\n            console.error('[TDP send-to-team] Non-JSON server response:', rawText);\n            throw new Error('Server returned an unexpected response.');\n        }\n\n        if (data && data.success) {\n            if (btn) { btn.textContent = '\u2713 ' + tdpTips.cta_sent; btn.disabled = true; }\n            const msg = (data.data && data.data.message) ? data.data.message : tdpTips.cta_sent_detail;\n            tdpShowSendMsg(msgEl, msg, 'success');\n        } else {\n            if (btn) { btn.disabled = false; btn.innerHTML = '\u2709 ' + tdpEscapeHtml(tdpTips.cta_button); }\n            const errMsg = (data && typeof data.data === 'string' && data.data)\n                ? data.data : tdpTips.cta_error;\n            console.error('[TDP send-to-team] wp_mail error:', errMsg);\n            tdpShowSendMsg(msgEl, errMsg, 'error');\n        }\n    } catch (err) {\n        console.error('[TDP send-to-team] Fetch error:', err);\n        if (btn) { btn.disabled = false; btn.innerHTML = '\u2709 ' + tdpEscapeHtml(tdpTips.cta_button); }\n        tdpShowSendMsg(msgEl, tdpTips.cta_error, 'error');\n    }\n}\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\/ar\/wp-json\/wp\/v2\/pages\/11068","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tunisia3dprint.com\/ar\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/tunisia3dprint.com\/ar\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/tunisia3dprint.com\/ar\/wp-json\/wp\/v2\/users\/4"}],"replies":[{"embeddable":true,"href":"https:\/\/tunisia3dprint.com\/ar\/wp-json\/wp\/v2\/comments?post=11068"}],"version-history":[{"count":76,"href":"https:\/\/tunisia3dprint.com\/ar\/wp-json\/wp\/v2\/pages\/11068\/revisions"}],"predecessor-version":[{"id":13720,"href":"https:\/\/tunisia3dprint.com\/ar\/wp-json\/wp\/v2\/pages\/11068\/revisions\/13720"}],"wp:attachment":[{"href":"https:\/\/tunisia3dprint.com\/ar\/wp-json\/wp\/v2\/media?parent=11068"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}