/* global React, ReactDOM, PWLC_AJAX, PWLC_DATA */ // Pixel Calculator – LED Calculator v1.24.1 (admin-managed DB + RFQ history + cookies) const FALLBACK_CABS = [ { id: "500x500", label: "500 × 500 mm", width_mm: 500, height_mm: 500 }, { id: "500x1000", label: "500 × 1000 mm", width_mm: 500, height_mm: 1000 }, { id: "1000x500", label: "1000 × 500 mm", width_mm: 1000, height_mm: 500 }, { id: "600x337.5", label: "600 × 337.5 mm (16:9)", width_mm: 600, height_mm: 337.5 }, { id: "640x480", label: "640 × 480 mm (4:3)", width_mm: 640, height_mm: 480 }, { id: "960x540", label: "960 × 540 mm (16:9)", width_mm: 960, height_mm: 540 }, { id: "768x432", label: "768 × 432 mm (16:9)", width_mm: 768, height_mm: 432 }, { id: "960x960", label: "960 × 960 mm", width_mm: 960, height_mm: 960 }, { id: "640x640", label: "640 × 640 mm", width_mm: 640, height_mm: 640 }, { id: "640x360", label: "640 × 360 mm (16:9)", width_mm: 640, height_mm: 360 }, { id: "960x480", label: "960 × 480 mm (2:1)", width_mm: 960, height_mm: 480 }, ]; const SERVER_CABS = (typeof PWLC_DATA !== "undefined" && Array.isArray(PWLC_DATA.cabinets)) ? PWLC_DATA.cabinets : FALLBACK_CABS; const DEFAULT_COUNTER = 5188; const INITIAL_COUNTER = (() => { if (typeof PWLC_DATA !== "undefined" && PWLC_DATA && typeof PWLC_DATA.design_counter !== "undefined") { const parsed = Number(PWLC_DATA.design_counter); if (Number.isFinite(parsed) && parsed >= 0) { return parsed; } } return DEFAULT_COUNTER; })(); const SPEC_INDEX = {}; const CABINETS = SERVER_CABS.map(c => { const base = { id: c.id, label: c.label, w: c.width_mm, h: c.height_mm }; SPEC_INDEX[c.id] = { id: c.id, label: c.label, width_mm: c.width_mm, height_mm: c.height_mm, common_pixel_pitches_mm: Array.isArray(c.common_pixel_pitches_mm) ? c.common_pixel_pitches_mm : [], resolutions_by_pitch: c.resolutions_by_pitch || {} }; return base; }); const DEFAULT_CABINET_IDS = { A: CABINETS.find((cab) => cab.id === "600x337.5")?.id || CABINETS[0]?.id, B: CABINETS.find((cab) => cab.id === "640x480")?.id || CABINETS[0]?.id, C: CABINETS.find((cab) => cab.id === "500x500")?.id || CABINETS[0]?.id, }; const PITCHES_DEFAULT = [0.468, 0.625, 0.78125, 0.9375, 1.0, 1.25, 1.56, 1.8, 2.0, 2.5, 2.6, 2.9, 3.9, 4.8, 6.6, 7.8]; const CABINET_LOGO_MAX_SIZE_MM = 100; const CABINET_LOGO_RELATIVE_PATH = "../public/images/pixel-wall-logo-color.png"; const SAMPLE_SCREEN_CONTENT_IMAGES = [ { id: "sample-1-woman", label: "Women", path: "../public/images/sample-1-woman.jpeg" }, { id: "sample-2-cat", label: "Cat", path: "../public/images/sample-2-cat.jpeg" }, { id: "sample-3-nature", label: "Nature", path: "../public/images/sample-3-nature.jpg" }, { id: "sample-4-chip", label: "Chip", path: "../public/images/sample-4-chip.jpg" }, { id: "sample-5-car", label: "Car", path: "../public/images/sample-5-car.jpg" }, ]; const RESOLUTION_STANDARDS = (() => { if (typeof PWLC_DATA === "undefined" || !PWLC_DATA || !Array.isArray(PWLC_DATA.resolution_standards)) { return []; } return PWLC_DATA.resolution_standards .map((entry) => { if (!entry) { return null; } const name = typeof entry.name === "string" ? entry.name.trim() : ""; const width = Math.round(Number(entry.width)); const height = Math.round(Number(entry.height)); let total = Number(entry.total_pixels); if (!Number.isFinite(total) || total <= 0) { total = width * height; } if (!name || !Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0 || !Number.isFinite(total) || total <= 0) { return null; } const id = (typeof entry.id === "string" && entry.id) ? entry.id : `${name}-${width}x${height}`; return { id, name, width, height, total, }; }) .filter((item) => item) .sort((a, b) => { if (a.total === b.total) { return a.name.localeCompare(b.name); } return a.total - b.total; }); })(); function normalizeProcessorPort(port){ if(!port || typeof port !== "object"){ return null; } const count = Number(port.count); const capacity = Number(port.max_pixels_per_port); if(!Number.isFinite(count) || count <= 0 || !Number.isFinite(capacity) || capacity <= 0){ return null; } return { count: Math.max(0, Math.floor(count)), maxPixelsPerPort: capacity, }; } function normalizeProcessorStringList(value){ if(Array.isArray(value)){ return value .map((item)=> (typeof item === "string" ? item.trim() : "")) .filter((item)=>item); } if(typeof value === "string"){ return value .split(/[\n,]+/) .map((item)=>item.trim()) .filter((item)=>item); } return []; } function normalizeProcessorEntry(entry){ if(!entry || typeof entry !== "object"){ return null; } const brand = typeof entry.brand === "string" ? entry.brand.trim() : (typeof entry.manufacturer === "string" ? entry.manufacturer.trim() : ""); const model = typeof entry.model === "string" ? entry.model.trim() : ""; const maxPixelsMP = Number(entry.max_pixels); const maxWidth = Number(entry.max_width); const maxHeight = Number(entry.max_height); if(!brand || !model){ return null; } if(!Number.isFinite(maxPixelsMP) || maxPixelsMP <= 0){ return null; } const ethernet = normalizeProcessorPort(entry.ethernet_ports); const fiber = normalizeProcessorPort(entry.fiber_ports); return { brand, manufacturer: brand, model, maxPixelsMP, maxWidth: Number.isFinite(maxWidth) && maxWidth > 0 ? Math.round(maxWidth) : null, maxHeight: Number.isFinite(maxHeight) && maxHeight > 0 ? Math.round(maxHeight) : null, ethernet, fiber, inputPorts: normalizeProcessorStringList(entry.input_ports), features: normalizeProcessorStringList(entry.features), }; } const PROCESSOR_CATALOG = (() => { if (typeof PWLC_DATA === "undefined" || !PWLC_DATA || !Array.isArray(PWLC_DATA.processors)) { return []; } return PWLC_DATA.processors .map((entry) => normalizeProcessorEntry(entry)) .filter((item) => item) .sort((a, b) => { const brandCompare = a.brand.localeCompare(b.brand); if (brandCompare !== 0) { return brandCompare; } return a.model.localeCompare(b.model); }); })(); function computeProcessorRecommendation(processors, totalPixels, resX, resY){ if(!processors || processors.length === 0){ return null; } const totalPixelsMP = Number(totalPixels) / 1_000_000; if(!Number.isFinite(totalPixelsMP) || totalPixelsMP <= 0){ return null; } const matches = []; let maxEthernet = null; let maxFiber = null; processors.forEach((proc)=>{ if(!proc){ return; } if(Number.isFinite(proc.maxPixelsMP) && proc.maxPixelsMP > 0 && proc.maxPixelsMP + 1e-6 < totalPixelsMP){ return; } if(Number.isFinite(proc.maxWidth) && proc.maxWidth && Number.isFinite(resX) && resX > proc.maxWidth){ return; } if(Number.isFinite(proc.maxHeight) && proc.maxHeight && Number.isFinite(resY) && resY > proc.maxHeight){ return; } if(!proc.ethernet){ return; } const perPort = proc.ethernet.maxPixelsPerPort; const count = proc.ethernet.count; if(!Number.isFinite(perPort) || perPort <= 0 || !Number.isFinite(count) || count <= 0){ return; } const ethernetNeeded = Math.ceil(totalPixelsMP / perPort); if(!Number.isFinite(ethernetNeeded) || ethernetNeeded > count){ return; } const match = { brand: proc.brand, manufacturer: proc.manufacturer, model: proc.model, ethernetPortsNeeded: ethernetNeeded, }; if(proc.fiber && Number.isFinite(proc.fiber.maxPixelsPerPort) && proc.fiber.maxPixelsPerPort > 0 && Number.isFinite(proc.fiber.count) && proc.fiber.count > 0){ const fiberNeeded = Math.ceil(totalPixelsMP / proc.fiber.maxPixelsPerPort); match.fiberPortsNeeded = fiberNeeded; if(fiberNeeded <= proc.fiber.count){ if(maxFiber === null || fiberNeeded > maxFiber){ maxFiber = fiberNeeded; } } } matches.push(match); if(maxEthernet === null || ethernetNeeded > maxEthernet){ maxEthernet = ethernetNeeded; } }); const brandGroups = new Map(); matches.forEach((item)=>{ const brand = item.brand || item.manufacturer || ""; const brandKey = brand || "Unknown Brand"; const model = item.model || ""; if(!brandGroups.has(brandKey)){ brandGroups.set(brandKey, new Set()); } if(model){ brandGroups.get(brandKey).add(model); } }); const recommendedNames = matches.map((item)=>`${item.brand || item.manufacturer || ""} ${item.model}`.trim()); const groupedNames = Array.from(brandGroups.entries()) .sort((a, b)=>a[0].localeCompare(b[0])) .map(([brand, modelsSet])=>{ const models = Array.from(modelsSet); models.sort((a, b)=>a.localeCompare(b)); return `${brand} (${models.join(', ')})`; }); const portSegments = []; if(maxEthernet !== null){ portSegments.push(`${maxEthernet} Ethernet port${maxEthernet === 1 ? '' : 's'} needed`); } if(maxFiber !== null){ portSegments.push(`${maxFiber} Fiber port${maxFiber === 1 ? '' : 's'} needed`); } const requirementText = portSegments.length > 0 ? portSegments.join(' | ') : `${fmt(totalPixelsMP, 2)} MP load`; const recommendedText = groupedNames.length > 0 ? `Recommended: ${groupedNames.join(', ')}` : 'Recommended: None available'; return { totalPixelsMP, matches, requirementText, recommendedText, summary: `${requirementText} | ${recommendedText}`, recommendedNames, recommendedGroups: groupedNames, maxEthernetPorts: maxEthernet, maxFiberPorts: maxFiber, }; } function getSpecForCab(cabId){ return SPEC_INDEX[cabId]; } function getSupportedPitches(cabId){ const spec = getSpecForCab(cabId); return spec && spec.common_pixel_pitches_mm && spec.common_pixel_pitches_mm.length ? spec.common_pixel_pitches_mm.slice() : PITCHES_DEFAULT; } function getCabinetTileResolution(cabId, pitch){ const spec = getSpecForCab(cabId); if (!spec) return null; const key = String(pitch); const val = (spec.resolutions_by_pitch || {})[key]; if (!val) return null; const [xs, ys] = val.split("x"); const x = parseInt(xs, 10); const y = parseInt(ys, 10); if (!Number.isFinite(x) || !Number.isFinite(y)) return null; return { x, y }; } const FIT_MODES = [ { id: "nearest", label: "Nearest (minimize area error)" }, { id: "fit-within", label: "Fit Within (never exceed target)" }, { id: "meet-or-exceed", label: "Meet or Exceed (never under target)" }, ]; const SCREEN_CONTENT_FIT_OPTIONS = [ { value: "stretch", label: "Stretch" }, { value: "fill", label: "Fill" }, { value: "crop", label: "Crop" }, ]; const INSTALL_ENVIRONMENT_OPTIONS = [ { value: "", label: "Select installation environment" }, { value: "indoor", label: "Indoor" }, { value: "outdoor", label: "Outdoor" }, { value: "mixed", label: "Indoor & Outdoor" }, ]; const SERVICE_SCOPE_OPTIONS = [ { value: "", label: "Select service scope" }, { value: "equipment-only", label: "Equipment only (no installation services)" }, { value: "full-service", label: "Full service installation" }, ]; const COUNTRY_OPTIONS = [ { value: "", label: "Select country" }, { value: "AF", label: "Afghanistan" }, { value: "AL", label: "Albania" }, { value: "DZ", label: "Algeria" }, { value: "AS", label: "American Samoa" }, { value: "AD", label: "Andorra" }, { value: "AO", label: "Angola" }, { value: "AI", label: "Anguilla" }, { value: "AQ", label: "Antarctica" }, { value: "AG", label: "Antigua and Barbuda" }, { value: "AR", label: "Argentina" }, { value: "AM", label: "Armenia" }, { value: "AW", label: "Aruba" }, { value: "AU", label: "Australia" }, { value: "AT", label: "Austria" }, { value: "AZ", label: "Azerbaijan" }, { value: "BS", label: "Bahamas" }, { value: "BH", label: "Bahrain" }, { value: "BD", label: "Bangladesh" }, { value: "BB", label: "Barbados" }, { value: "BY", label: "Belarus" }, { value: "BE", label: "Belgium" }, { value: "BZ", label: "Belize" }, { value: "BJ", label: "Benin" }, { value: "BM", label: "Bermuda" }, { value: "BT", label: "Bhutan" }, { value: "BO", label: "Bolivia" }, { value: "BQ", label: "Bonaire, Sint Eustatius and Saba" }, { value: "BA", label: "Bosnia and Herzegovina" }, { value: "BW", label: "Botswana" }, { value: "BR", label: "Brazil" }, { value: "IO", label: "British Indian Ocean Territory" }, { value: "BN", label: "Brunei" }, { value: "BG", label: "Bulgaria" }, { value: "BF", label: "Burkina Faso" }, { value: "BI", label: "Burundi" }, { value: "KH", label: "Cambodia" }, { value: "CM", label: "Cameroon" }, { value: "CA", label: "Canada" }, { value: "CV", label: "Cape Verde" }, { value: "KY", label: "Cayman Islands" }, { value: "CF", label: "Central African Republic" }, { value: "TD", label: "Chad" }, { value: "CL", label: "Chile" }, { value: "CN", label: "China" }, { value: "CX", label: "Christmas Island" }, { value: "CC", label: "Cocos (Keeling) Islands" }, { value: "CO", label: "Colombia" }, { value: "KM", label: "Comoros" }, { value: "CG", label: "Congo" }, { value: "CD", label: "Congo (Democratic Republic)" }, { value: "CK", label: "Cook Islands" }, { value: "CR", label: "Costa Rica" }, { value: "CI", label: "Côte d’Ivoire" }, { value: "HR", label: "Croatia" }, { value: "CU", label: "Cuba" }, { value: "CW", label: "Curaçao" }, { value: "CY", label: "Cyprus" }, { value: "CZ", label: "Czechia" }, { value: "DK", label: "Denmark" }, { value: "DJ", label: "Djibouti" }, { value: "DM", label: "Dominica" }, { value: "DO", label: "Dominican Republic" }, { value: "EC", label: "Ecuador" }, { value: "EG", label: "Egypt" }, { value: "SV", label: "El Salvador" }, { value: "GQ", label: "Equatorial Guinea" }, { value: "ER", label: "Eritrea" }, { value: "EE", label: "Estonia" }, { value: "SZ", label: "Eswatini" }, { value: "ET", label: "Ethiopia" }, { value: "FK", label: "Falkland Islands" }, { value: "FO", label: "Faroe Islands" }, { value: "FJ", label: "Fiji" }, { value: "FI", label: "Finland" }, { value: "FR", label: "France" }, { value: "GF", label: "French Guiana" }, { value: "PF", label: "French Polynesia" }, { value: "GA", label: "Gabon" }, { value: "GM", label: "Gambia" }, { value: "GE", label: "Georgia" }, { value: "DE", label: "Germany" }, { value: "GH", label: "Ghana" }, { value: "GI", label: "Gibraltar" }, { value: "GR", label: "Greece" }, { value: "GL", label: "Greenland" }, { value: "GD", label: "Grenada" }, { value: "GP", label: "Guadeloupe" }, { value: "GU", label: "Guam" }, { value: "GT", label: "Guatemala" }, { value: "GG", label: "Guernsey" }, { value: "GN", label: "Guinea" }, { value: "GW", label: "Guinea-Bissau" }, { value: "GY", label: "Guyana" }, { value: "HT", label: "Haiti" }, { value: "VA", label: "Holy See" }, { value: "HN", label: "Honduras" }, { value: "HK", label: "Hong Kong" }, { value: "HU", label: "Hungary" }, { value: "IS", label: "Iceland" }, { value: "IN", label: "India" }, { value: "ID", label: "Indonesia" }, { value: "IR", label: "Iran" }, { value: "IQ", label: "Iraq" }, { value: "IE", label: "Ireland" }, { value: "IM", label: "Isle of Man" }, { value: "IL", label: "Israel" }, { value: "IT", label: "Italy" }, { value: "JM", label: "Jamaica" }, { value: "JP", label: "Japan" }, { value: "JE", label: "Jersey" }, { value: "JO", label: "Jordan" }, { value: "KZ", label: "Kazakhstan" }, { value: "KE", label: "Kenya" }, { value: "KI", label: "Kiribati" }, { value: "KP", label: "Korea (North)" }, { value: "KR", label: "Korea (South)" }, { value: "KW", label: "Kuwait" }, { value: "KG", label: "Kyrgyzstan" }, { value: "LA", label: "Laos" }, { value: "LV", label: "Latvia" }, { value: "LB", label: "Lebanon" }, { value: "LS", label: "Lesotho" }, { value: "LR", label: "Liberia" }, { value: "LY", label: "Libya" }, { value: "LI", label: "Liechtenstein" }, { value: "LT", label: "Lithuania" }, { value: "LU", label: "Luxembourg" }, { value: "MO", label: "Macao" }, { value: "MG", label: "Madagascar" }, { value: "MW", label: "Malawi" }, { value: "MY", label: "Malaysia" }, { value: "MV", label: "Maldives" }, { value: "ML", label: "Mali" }, { value: "MT", label: "Malta" }, { value: "MH", label: "Marshall Islands" }, { value: "MQ", label: "Martinique" }, { value: "MR", label: "Mauritania" }, { value: "MU", label: "Mauritius" }, { value: "YT", label: "Mayotte" }, { value: "MX", label: "Mexico" }, { value: "FM", label: "Micronesia" }, { value: "MD", label: "Moldova" }, { value: "MC", label: "Monaco" }, { value: "MN", label: "Mongolia" }, { value: "ME", label: "Montenegro" }, { value: "MS", label: "Montserrat" }, { value: "MA", label: "Morocco" }, { value: "MZ", label: "Mozambique" }, { value: "MM", label: "Myanmar" }, { value: "NA", label: "Namibia" }, { value: "NR", label: "Nauru" }, { value: "NP", label: "Nepal" }, { value: "NL", label: "Netherlands" }, { value: "NC", label: "New Caledonia" }, { value: "NZ", label: "New Zealand" }, { value: "NI", label: "Nicaragua" }, { value: "NE", label: "Niger" }, { value: "NG", label: "Nigeria" }, { value: "NU", label: "Niue" }, { value: "NF", label: "Norfolk Island" }, { value: "MK", label: "North Macedonia" }, { value: "MP", label: "Northern Mariana Islands" }, { value: "NO", label: "Norway" }, { value: "OM", label: "Oman" }, { value: "PK", label: "Pakistan" }, { value: "PW", label: "Palau" }, { value: "PS", label: "Palestine State" }, { value: "PA", label: "Panama" }, { value: "PG", label: "Papua New Guinea" }, { value: "PY", label: "Paraguay" }, { value: "PE", label: "Peru" }, { value: "PH", label: "Philippines" }, { value: "PN", label: "Pitcairn" }, { value: "PL", label: "Poland" }, { value: "PT", label: "Portugal" }, { value: "PR", label: "Puerto Rico" }, { value: "QA", label: "Qatar" }, { value: "RE", label: "Réunion" }, { value: "RO", label: "Romania" }, { value: "RU", label: "Russia" }, { value: "RW", label: "Rwanda" }, { value: "BL", label: "Saint Barthélemy" }, { value: "SH", label: "Saint Helena" }, { value: "KN", label: "Saint Kitts and Nevis" }, { value: "LC", label: "Saint Lucia" }, { value: "MF", label: "Saint Martin" }, { value: "PM", label: "Saint Pierre and Miquelon" }, { value: "VC", label: "Saint Vincent and the Grenadines" }, { value: "WS", label: "Samoa" }, { value: "SM", label: "San Marino" }, { value: "ST", label: "Sao Tome and Principe" }, { value: "SA", label: "Saudi Arabia" }, { value: "SN", label: "Senegal" }, { value: "RS", label: "Serbia" }, { value: "SC", label: "Seychelles" }, { value: "SL", label: "Sierra Leone" }, { value: "SG", label: "Singapore" }, { value: "SX", label: "Sint Maarten" }, { value: "SK", label: "Slovakia" }, { value: "SI", label: "Slovenia" }, { value: "SB", label: "Solomon Islands" }, { value: "SO", label: "Somalia" }, { value: "ZA", label: "South Africa" }, { value: "GS", label: "South Georgia and the South Sandwich Islands" }, { value: "SS", label: "South Sudan" }, { value: "ES", label: "Spain" }, { value: "LK", label: "Sri Lanka" }, { value: "SD", label: "Sudan" }, { value: "SR", label: "Suriname" }, { value: "SJ", label: "Svalbard and Jan Mayen" }, { value: "SE", label: "Sweden" }, { value: "CH", label: "Switzerland" }, { value: "SY", label: "Syria" }, { value: "TW", label: "Taiwan" }, { value: "TJ", label: "Tajikistan" }, { value: "TZ", label: "Tanzania" }, { value: "TH", label: "Thailand" }, { value: "TL", label: "Timor-Leste" }, { value: "TG", label: "Togo" }, { value: "TK", label: "Tokelau" }, { value: "TO", label: "Tonga" }, { value: "TT", label: "Trinidad and Tobago" }, { value: "TN", label: "Tunisia" }, { value: "TR", label: "Turkey" }, { value: "TM", label: "Turkmenistan" }, { value: "TC", label: "Turks and Caicos Islands" }, { value: "TV", label: "Tuvalu" }, { value: "UG", label: "Uganda" }, { value: "UA", label: "Ukraine" }, { value: "AE", label: "United Arab Emirates" }, { value: "GB", label: "United Kingdom" }, { value: "US", label: "United States" }, { value: "UM", label: "United States Minor Outlying Islands" }, { value: "UY", label: "Uruguay" }, { value: "UZ", label: "Uzbekistan" }, { value: "VU", label: "Vanuatu" }, { value: "VE", label: "Venezuela" }, { value: "VN", label: "Vietnam" }, { value: "VG", label: "Virgin Islands (British)" }, { value: "VI", label: "Virgin Islands (U.S.)" }, { value: "WF", label: "Wallis and Futuna" }, { value: "EH", label: "Western Sahara" }, { value: "YE", label: "Yemen" }, { value: "ZM", label: "Zambia" }, { value: "ZW", label: "Zimbabwe" }, ]; const RES_CHOICES = [ { id: "any", label: "Any", target: null }, { id: "720p", label: "720P HD: 1280 × 720", target: { x: 1280, y: 720 } }, { id: "1080p", label: "1080P Full HD: 1920 × 1080", target: { x: 1920, y: 1080 } }, { id: "2k", label: "2K: 2048 × 1080", target: { x: 2048, y: 1080 } }, { id: "1440p", label: "1440P QHD: 2560 × 1440", target: { x: 2560, y: 1440 } }, { id: "2160p", label: "2160P UHD: 3840 × 2160", target: { x: 3840, y: 2160 } }, { id: "4k", label: "4K: 4096 × 2160", target: { x: 4096, y: 2160 } }, { id: "5k", label: "5K: 5120 × 2880", target: { x: 5120, y: 2880 } }, { id: "8k", label: "8K: 7680 × 4320", target: { x: 7680, y: 4320 } }, ]; const MM_PER_M = 1000, MM_PER_IN = 25.4, MM_PER_FT = 304.8; const FT_PER_M = MM_PER_M / MM_PER_FT; const LENGTH_UNIT_OPTIONS = [ { value: "mm", label: "Millimeters (mm)" }, { value: "m", label: "Meters (m)" }, { value: "in", label: "Inches (in)" }, { value: "ft", label: "Feet (ft)" }, ]; const TOOL_VERSION = "1.24.1"; function toMM(value, unit){ if (!Number.isFinite(value)) return 0; switch(unit){ case "m": return value*MM_PER_M; case "ft": return value*MM_PER_FT; case "in": return value*MM_PER_IN; default: return value; } } function fromMM(mm, unit){ switch(unit){ case "m": return mm/MM_PER_M; case "ft": return mm/MM_PER_FT; case "in": return mm/MM_PER_IN; default: return mm; } } function formatUnitInput(mmValue, unit){ const converted = fromMM(mmValue, unit); if(!Number.isFinite(converted)){ return "0"; } let decimals = 0; if(unit === "m"){ decimals = 3; } else if(unit === "in" || unit === "ft"){ decimals = 3; } const factor = Math.pow(10, decimals); const rounded = decimals > 0 ? Math.round(converted * factor) / factor : Math.round(converted); return String(rounded); } function diagonalInches(w,h){ return Math.sqrt(w*w+h*h)/MM_PER_IN; } function formatAspect(resX,resY){ if(!resX||!resY) return "—"; const r=resX/resY; const COMMON=[ {x:16,y:9,label:"16:9"},{x:4,y:3,label:"4:3"},{x:21,y:9,label:"21:9"},{x:32,y:9,label:"32:9"}, {x:2,y:1,label:"2:1"},{x:3,y:1,label:"3:1"},{x:3,y:4,label:"3:4"},{x:9,y:16,label:"9:16"},{x:1,y:1,label:"1:1"}, ]; let best=null; for(const cr of COMMON){ const err=Math.abs(r-cr.x/cr.y); if(!best||err=1_000_000) return `${(n/1_000_000).toFixed(2)} MPix`; if(n>=1_000) return `${(n/1_000).toFixed(2)} KPix`; return `${n.toLocaleString()} px`; } function findResolutionStandard(totalPixels){ if(!Number.isFinite(totalPixels) || totalPixels <= 0 || RESOLUTION_STANDARDS.length === 0){ return null; } let match = null; for(const standard of RESOLUTION_STANDARDS){ if(totalPixels >= standard.total){ if(!match || standard.total > match.total){ match = standard; } } } return match; } function formatPixelsWithStandard(totalPixels){ const base = humanPixels(totalPixels); if(!Number.isFinite(totalPixels) || totalPixels <= 0){ return base; } const standard = findResolutionStandard(totalPixels); if(standard){ return `${base} (${standard.name})`; } return base; } function resScore(resX,resY,target){ if(!target) return Infinity; const dx=Math.abs(resX-target.x)/target.x; const dy=Math.abs(resY-target.y)/target.y; return dx+dy; } function ensurePitchSupported(current,allowed){ if(allowed.length===0) return current; if(allowed.includes(current)) return current; let best=allowed[0]; let bestErr=Math.abs(current-best); for(const p of allowed){ const e=Math.abs(current-p); if(e pivotValue){ pivotRow = row; pivotValue = candidate; } } if(pivotValue <= 1e-12){ return null; } if(pivotRow !== col){ const temp = matrix[col]; matrix[col] = matrix[pivotRow]; matrix[pivotRow] = temp; } const pivot = matrix[col][col]; for(let c = col; c < 9; c += 1){ matrix[col][c] /= pivot; } for(let row = 0; row < dimension; row += 1){ if(row === col){ continue; } const factor = matrix[row][col]; if(Math.abs(factor) <= 1e-12){ continue; } for(let c = col; c < 9; c += 1){ matrix[row][c] -= factor * matrix[col][c]; } } } const homography = new Array(9); for(let i = 0; i < dimension; i += 1){ homography[i] = matrix[i][8]; } homography[8] = 1; return homography; } function invertHomographyMatrix(matrix){ if(!Array.isArray(matrix) || matrix.length !== 9){ return null; } const a = matrix[0]; const b = matrix[1]; const c = matrix[2]; const d = matrix[3]; const e = matrix[4]; const f = matrix[5]; const g = matrix[6]; const h = matrix[7]; const i = matrix[8]; const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); if(Math.abs(det) <= 1e-18){ return null; } const invDet = 1 / det; return [ (e * i - f * h) * invDet, (c * h - b * i) * invDet, (b * f - c * e) * invDet, (f * g - d * i) * invDet, (a * i - c * g) * invDet, (c * d - a * f) * invDet, (d * h - e * g) * invDet, (b * g - a * h) * invDet, (a * e - b * d) * invDet, ]; } function isPointInConvexPolygon(point, polygon){ if(!polygon || polygon.length < 3){ return false; } let hasPositive = false; let hasNegative = false; const tolerance = 1e-6; for(let i = 0; i < polygon.length; i += 1){ const current = polygon[i]; const next = polygon[(i + 1) % polygon.length]; const edgeX = next.x - current.x; const edgeY = next.y - current.y; const pointX = point.x - current.x; const pointY = point.y - current.y; const cross = edgeX * pointY - edgeY * pointX; if(Math.abs(cross) <= tolerance){ continue; } if(cross > 0){ hasPositive = true; } else { hasNegative = true; } if(hasPositive && hasNegative){ return false; } } return true; } function bilinearSample(imageData, width, height, x, y){ const clampedX = Math.max(0, Math.min(width - 1, x)); const clampedY = Math.max(0, Math.min(height - 1, y)); const x0 = Math.floor(clampedX); const y0 = Math.floor(clampedY); const x1 = Math.min(width - 1, x0 + 1); const y1 = Math.min(height - 1, y0 + 1); const tx = clampedX - x0; const ty = clampedY - y0; const idx00 = (y0 * width + x0) * 4; const idx10 = (y0 * width + x1) * 4; const idx01 = (y1 * width + x0) * 4; const idx11 = (y1 * width + x1) * 4; const w00 = (1 - tx) * (1 - ty); const w10 = tx * (1 - ty); const w01 = (1 - tx) * ty; const w11 = tx * ty; return [ imageData[idx00] * w00 + imageData[idx10] * w10 + imageData[idx01] * w01 + imageData[idx11] * w11, imageData[idx00 + 1] * w00 + imageData[idx10 + 1] * w10 + imageData[idx01 + 1] * w01 + imageData[idx11 + 1] * w11, imageData[idx00 + 2] * w00 + imageData[idx10 + 2] * w10 + imageData[idx01 + 2] * w01 + imageData[idx11 + 2] * w11, imageData[idx00 + 3] * w00 + imageData[idx10 + 3] * w10 + imageData[idx01 + 3] * w01 + imageData[idx11 + 3] * w11, ]; } const APP_ASSET_BASE_URL = (()=>{ if(typeof document === "undefined"){ return ""; } const scripts = document.getElementsByTagName("script"); for(let i=scripts.length - 1;i>=0;i-=1){ const srcAttr = scripts[i] && scripts[i].getAttribute ? scripts[i].getAttribute("src") : null; if(srcAttr && srcAttr.indexOf("/assets/app.jsx") !== -1){ const withoutQuery = srcAttr.split("?")[0] || ""; return withoutQuery.replace(/app\.jsx$/, ""); } } return ""; })(); function resolveAssetUrl(relativePath){ if(!relativePath){ return ""; } if(/^https?:/i.test(relativePath)){ return relativePath; } if(APP_ASSET_BASE_URL){ return APP_ASSET_BASE_URL + relativePath.replace(/^\//, ""); } return relativePath; } const HTML2CANVAS_CDN = "https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"; const JSPDF_CDN = "https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"; // Cache script loading promises so we only download each library once per session. const externalScriptPromises = {}; function loadExternalScriptOnce(src){ if(externalScriptPromises[src]){ return externalScriptPromises[src]; } externalScriptPromises[src] = new Promise((resolve, reject)=>{ if(typeof document === "undefined"){ reject(new Error("Document is unavailable")); return; } const existing = Array.prototype.slice.call(document.getElementsByTagName("script")) .find((node)=>node && node.getAttribute && node.getAttribute("src") === src); if(existing){ if(existing.getAttribute("data-loaded") === "true"){ resolve(); return; } existing.addEventListener("load",()=>resolve()); existing.addEventListener("error",(event)=>{ delete externalScriptPromises[src]; reject(event); }); return; } const script = document.createElement("script"); script.src = src; script.async = true; script.crossOrigin = "anonymous"; script.referrerPolicy = "no-referrer"; script.onload = ()=>{ script.setAttribute("data-loaded", "true"); resolve(); }; script.onerror = (event)=>{ delete externalScriptPromises[src]; reject(new Error(`Failed to load script: ${src}`)); }; document.head.appendChild(script); }); return externalScriptPromises[src]; } async function ensureHtml2Canvas(){ if(typeof window !== "undefined" && typeof window.html2canvas === "function"){ return window.html2canvas; } await loadExternalScriptOnce(HTML2CANVAS_CDN); if(typeof window === "undefined" || typeof window.html2canvas !== "function"){ throw new Error("html2canvas is unavailable after loading script"); } return window.html2canvas; } async function ensureJsPDF(){ if(typeof window !== "undefined" && window.jspdf && typeof window.jspdf.jsPDF === "function"){ return window.jspdf.jsPDF; } await loadExternalScriptOnce(JSPDF_CDN); if(typeof window === "undefined" || !window.jspdf || typeof window.jspdf.jsPDF !== "function"){ throw new Error("jsPDF is unavailable after loading script"); } return window.jspdf.jsPDF; } function isElementVisible(el){ if(!el){ return false; } if(typeof el.getClientRects === "function" && el.getClientRects().length === 0){ return false; } const style = window.getComputedStyle ? window.getComputedStyle(el) : null; if(style && (style.visibility === "hidden" || style.display === "none")){ return false; } if(typeof el.offsetParent === "undefined"){ return true; } return el.offsetParent !== null || style?.position === "fixed"; } function mat4Multiply(a,b){ const out = new Array(16); for(let column=0;column<4;column+=1){ const colOffset = column * 4; const b0 = b[colOffset + 0]; const b1 = b[colOffset + 1]; const b2 = b[colOffset + 2]; const b3 = b[colOffset + 3]; out[colOffset + 0] = a[0] * b0 + a[4] * b1 + a[8] * b2 + a[12] * b3; out[colOffset + 1] = a[1] * b0 + a[5] * b1 + a[9] * b2 + a[13] * b3; out[colOffset + 2] = a[2] * b0 + a[6] * b1 + a[10] * b2 + a[14] * b3; out[colOffset + 3] = a[3] * b0 + a[7] * b1 + a[11] * b2 + a[15] * b3; } return out; } function mat4FromTRS(translation = [0,0,0], rotation = [0,0,0,1], scale = [1,1,1]){ const [tx,ty,tz] = translation; const [rx,ry,rz,rw] = rotation; const [sx,sy,sz] = scale; const x2 = rx + rx; const y2 = ry + ry; const z2 = rz + rz; const xx = rx * x2; const xy = rx * y2; const xz = rx * z2; const yy = ry * y2; const yz = ry * z2; const zz = rz * z2; const wx = rw * x2; const wy = rw * y2; const wz = rw * z2; return [ (1 - (yy + zz)) * sx, (xy + wz) * sx, (xz - wy) * sx, 0, (xy - wz) * sy, (1 - (xx + zz)) * sy, (yz + wx) * sy, 0, (xz + wy) * sz, (yz - wx) * sz, (1 - (xx + yy)) * sz, 0, tx, ty, tz, 1, ]; } function mat4TransformPoint(matrix, point){ const x = point[0]; const y = point[1]; const z = point[2]; const nx = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]; const ny = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]; const nz = matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]; const nw = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15]; if(nw && Math.abs(nw) > 1e-6 && Math.abs(nw - 1) > 1e-6){ return [nx / nw, ny / nw, nz / nw]; } return [nx, ny, nz]; } function parseBinaryGLTF(arrayBuffer){ if(!(arrayBuffer instanceof ArrayBuffer)){ throw new Error("Expected ArrayBuffer for GLB parsing"); } const view = new DataView(arrayBuffer); const magic = view.getUint32(0, true); if(magic !== 0x46546c67){ // 'glTF' throw new Error("Invalid GLB header"); } const version = view.getUint32(4, true); if(version !== 2){ throw new Error(`Unsupported GLB version: ${version}`); } const length = view.getUint32(8, true); let offset = 12; let json = null; let binary = null; const decoder = typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null; while(offset < length){ const chunkLength = view.getUint32(offset, true); offset += 4; const chunkType = view.getUint32(offset, true); offset += 4; const chunkStart = offset; const typeStr = String.fromCharCode( chunkType & 0xFF, (chunkType >> 8) & 0xFF, (chunkType >> 16) & 0xFF, (chunkType >> 24) & 0xFF ); if(typeStr === "JSON"){ if(!decoder){ throw new Error("TextDecoder is not available"); } const chunkView = new Uint8Array(arrayBuffer, chunkStart, chunkLength); const jsonText = decoder.decode(chunkView); json = JSON.parse(jsonText); } else if(typeStr === "BIN\0"){ binary = new Uint8Array(arrayBuffer, chunkStart, chunkLength); } offset += chunkLength; } return { json, binary }; } function getComponentSize(componentType){ switch(componentType){ case 5120: // BYTE case 5121: // UNSIGNED_BYTE return 1; case 5122: // SHORT case 5123: // UNSIGNED_SHORT return 2; case 5125: // UNSIGNED_INT case 5126: // FLOAT return 4; default: return 0; } } function readVec3Accessor(gltf, dataView, accessorIndex){ if(!gltf || !gltf.accessors || !Array.isArray(gltf.accessors)){ return null; } const accessor = gltf.accessors[accessorIndex]; if(!accessor || accessor.type !== "VEC3" || accessor.componentType !== 5126){ return null; } const bufferViewIndex = accessor.bufferView; if(!gltf.bufferViews || !Array.isArray(gltf.bufferViews)){ return null; } const bufferView = gltf.bufferViews[bufferViewIndex]; if(!bufferView){ return null; } const stride = bufferView.byteStride || 12; const count = accessor.count || 0; const baseOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0); const result = new Array(count); let offset = baseOffset; for(let i=0;inull); nodes.forEach((node, index)=>{ const children = Array.isArray(node && node.children) ? node.children : []; children.forEach((childIndex)=>{ if(typeof childIndex === "number" && childIndex >= 0 && childIndex < parentMap.length){ parentMap[childIndex] = index; } }); }); const matrixCache = new Array(nodes.length); const worldMatrixForNode = (index)=>{ if(matrixCache[index]){ return matrixCache[index]; } const node = nodes[index] || {}; let localMatrix = null; if(Array.isArray(node.matrix) && node.matrix.length === 16){ localMatrix = node.matrix.slice(); } else { localMatrix = mat4FromTRS(node.translation, node.rotation, node.scale); } const parentIndex = parentMap[index]; if(typeof parentIndex === "number" && parentIndex >= 0){ const parentMatrix = worldMatrixForNode(parentIndex); localMatrix = mat4Multiply(parentMatrix, localMatrix); } matrixCache[index] = localMatrix; return localMatrix; }; const dataView = new DataView(binary.buffer, binary.byteOffset, binary.byteLength); const axisMap = options.axisMap || { x: 0, y: 1, z: 2 }; const axisFlip = options.axisFlip || { x: 1, y: 1, z: 1 }; const triangles = []; let minX = Infinity; let minY = Infinity; let minZ = Infinity; let maxX = -Infinity; let maxY = -Infinity; let maxZ = -Infinity; const mapAxes = (vector)=>{ const arr = [vector[0], vector[1], vector[2]]; return { x: arr[axisMap.x] * (axisFlip.x || 1), y: arr[axisMap.y] * (axisFlip.y || 1), z: arr[axisMap.z] * (axisFlip.z || 1), }; }; const updateBounds = (point)=>{ if(point.x < minX) minX = point.x; if(point.y < minY) minY = point.y; if(point.z < minZ) minZ = point.z; if(point.x > maxX) maxX = point.x; if(point.y > maxY) maxY = point.y; if(point.z > maxZ) maxZ = point.z; }; nodes.forEach((node, nodeIndex)=>{ if(typeof node.mesh !== "number"){ return; } const mesh = meshes[node.mesh]; if(!mesh || !Array.isArray(mesh.primitives)){ return; } const worldMatrix = worldMatrixForNode(nodeIndex); mesh.primitives.forEach((primitive)=>{ if(!primitive || (primitive.mode != null && primitive.mode !== 4)){ return; } if(!primitive.attributes || typeof primitive.attributes.POSITION !== "number"){ return; } const positions = readVec3Accessor(gltf, dataView, primitive.attributes.POSITION); if(!positions || positions.length === 0){ return; } const indices = readIndicesAccessor(gltf, dataView, primitive.indices, positions.length); if(!indices || indices.length < 3){ return; } for(let i=0;i 0 ? options.unitsToMm : 1000; if(typeof options.targetHeightMm === "number" && options.targetHeightMm > 0){ scale = options.targetHeightMm / heightUnits; } const translation = options.translation || { x: 0, y: 0, z: 0 }; triangles.forEach((triangle)=>{ triangle.vertices = triangle.vertices.map((vertex)=>{ const adjusted = { x: (vertex.x - centerX), y: (vertex.y - centerY), z: vertex.z - baseZ, }; return { x: adjusted.x * scale + translation.x, y: adjusted.y * scale + translation.y, z: adjusted.z * scale + translation.z, }; }); const edgeAB = vec3Sub(triangle.vertices[1], triangle.vertices[0]); const edgeAC = vec3Sub(triangle.vertices[2], triangle.vertices[0]); triangle.normal = vec3Normalize(vec3Cross(edgeAB, edgeAC)); }); const lightDirectionBase = options.lightDirection || { x: -0.25, y: -0.6, z: 1 }; let lightDirection = vec3Normalize(lightDirectionBase); if(!(lightDirection.x || lightDirection.y || lightDirection.z)){ lightDirection = { x: 0, y: 0, z: 1 }; } return { triangles, lightDirection, heightMm: heightUnits * scale, }; } async function loadGLBModel(url, options){ const response = await fetch(url, { credentials: "same-origin" }); if(!response.ok){ throw new Error(`Failed to load GLB model (${response.status})`); } const buffer = await response.arrayBuffer(); return extractModelFromGLB(buffer, options); } const MIN_POLAR_ANGLE = 0.12; const MAX_POLAR_ANGLE = Math.PI - 0.12; const WALL_CLEARANCE_MM = 2000; const SCREEN_THICKNESS_MM = 40; const SCREEN_BACK_OFFSET_MM = 1; const RAD_TO_DEG = 180 / Math.PI; const ROOM_GRID_SPACING_MM = 1000; const ROOM_GRID_MAX_LINES = 200; const ROOM_LIMIT_MM = 100000; const MIN_ROOM_DIMENSION_MM = 1; const DEFAULT_WALL_WIDTH_MM = 10000; const DEFAULT_WALL_HEIGHT_MM = 4000; const CAMERA_MIN_RADIUS_MM = 500; const CAMERA_BACK_LIMIT_MM = ROOM_LIMIT_MM; const CAMERA_PAN_BUFFER_MM = 1000; const BODY_MODEL_LOAD_OPTIONS = { axisMap: { x: 0, y: 2, z: 1 }, centerAxes: { x: true, y: true }, alignBase: true, targetHeightMm: 1820, translation: { x: -1000, y: 2000, z: 0 }, lightDirection: { x: -0.3, y: -0.55, z: 1.1 }, }; const BODY_MODEL_FILL_LIGHT = { r: 182, g: 208, b: 255 }; const BODY_MODEL_FILL_DARK = { r: 46, g: 72, b: 116 }; const BODY_MODEL_STROKE_LIGHT = { r: 222, g: 236, b: 255 }; const BODY_MODEL_STROKE_DARK = { r: 28, g: 48, b: 86 }; function clamp(value, min, max){ if(!Number.isFinite(value)){ return min; } return Math.min(Math.max(value, min), max); } function randomInRange(min, max){ if(!Number.isFinite(min)){ return Number.isFinite(max) ? max : 0; } if(!Number.isFinite(max)){ return min; } if(min === max){ return min; } const lower = Math.min(min, max); const upper = Math.max(min, max); return lower + (upper - lower) * Math.random(); } function randomSign(){ return Math.random() < 0.5 ? -1 : 1; } function mixRgba(colorA, colorB, t, alpha){ const ratio = clamp(t, 0, 1); const r = Math.round(colorA.r + (colorB.r - colorA.r) * ratio); const g = Math.round(colorA.g + (colorB.g - colorA.g) * ratio); const b = Math.round(colorA.b + (colorB.b - colorA.b) * ratio); return `rgba(${r},${g},${b},${alpha})`; } function wrapAngle(angle){ const twoPi = Math.PI * 2; let result = angle % twoPi; if(result < -Math.PI){ result += twoPi; } else if(result > Math.PI){ result -= twoPi; } return result; } function shortestAngleDifference(target, current){ let diff = target - current; const twoPi = Math.PI * 2; while(diff > Math.PI){ diff -= twoPi; } while(diff < -Math.PI){ diff += twoPi; } return diff; } function easeInOutCubic(t){ if(t <= 0){ return 0; } if(t >= 1){ return 1; } return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } function interpolateNumber(from, to, progress){ const start = Number.isFinite(from) ? from : 0; const end = Number.isFinite(to) ? to : 0; return start + (end - start) * progress; } function interpolateTarget(from, to, progress){ const safeFrom = from || { x: 0, y: 0, z: 0 }; const safeTo = to || { x: 0, y: 0, z: 0 }; return { x: interpolateNumber(safeFrom.x, safeTo.x, progress), y: interpolateNumber(safeFrom.y, safeTo.y, progress), z: interpolateNumber(safeFrom.z, safeTo.z, progress), }; } function interpolateAngle(from, to, progress){ if(!Number.isFinite(from) || !Number.isFinite(to)){ return 0; } const diff = shortestAngleDifference(to, from); return wrapAngle(from + diff * progress); } function clampRoomDimension(value){ if(!Number.isFinite(value)){ return MIN_ROOM_DIMENSION_MM; } return clamp(value, MIN_ROOM_DIMENSION_MM, ROOM_LIMIT_MM); } function clampOrbitTarget(scene, target){ const safeTarget = target || { x: 0, y: 0, z: 0 }; if(!scene){ return { ...safeTarget }; } const halfWallWidth = scene.wallWidth / 2; const maxDepth = Math.min(scene.floorDepth, CAMERA_BACK_LIMIT_MM); const maxDepthWithBuffer = Math.max(0, maxDepth - CAMERA_PAN_BUFFER_MM); return { x: clamp(safeTarget.x, -halfWallWidth, halfWallWidth), y: clamp(safeTarget.y, 0, maxDepthWithBuffer), z: clamp(safeTarget.z, 0, scene.wallHeight), }; } function computeOrbitLimits(scene, target){ if(!scene){ return { minRadius: CAMERA_MIN_RADIUS_MM, maxRadius: CAMERA_BACK_LIMIT_MM }; } const minRadius = Math.max(scene.minOrbitRadius ?? CAMERA_MIN_RADIUS_MM, CAMERA_MIN_RADIUS_MM); const maxDepth = Math.min(scene.floorDepth, CAMERA_BACK_LIMIT_MM); const depthAllowance = Math.max(minRadius, maxDepth - target.y); const maxRadius = Math.max(minRadius, Math.min(scene.maxOrbitRadius ?? CAMERA_BACK_LIMIT_MM, depthAllowance)); return { minRadius, maxRadius }; } function clampOrbitState(scene, state){ if(!state){ return state; } const target = clampOrbitTarget(scene, state.target); const { minRadius, maxRadius } = computeOrbitLimits(scene, target); const azimuth = wrapAngle(typeof state.azimuth === 'number' ? state.azimuth : 0); const polar = clamp(typeof state.polar === 'number' ? state.polar : Math.PI / 2, MIN_POLAR_ANGLE, MAX_POLAR_ANGLE); const radius = clamp(typeof state.radius === 'number' ? state.radius : minRadius, minRadius, maxRadius); const defaultRadius = clamp(typeof state.defaultRadius === 'number' ? state.defaultRadius : radius, minRadius, maxRadius); return { ...state, target, azimuth, polar, radius, minRadius, maxRadius, defaultRadius, }; } function buildOrbitStateFromScene(scene, overrides = {}){ if(!scene){ const fallbackTarget = { x: 0, y: 0, z: 1800 }; const fallbackRadius = 10000; const fallbackPolar = clamp(Math.PI / 2, MIN_POLAR_ANGLE, MAX_POLAR_ANGLE); return { target: fallbackTarget, radius: fallbackRadius, azimuth: 0, polar: fallbackPolar, minRadius: CAMERA_MIN_RADIUS_MM, maxRadius: 40000, defaultRadius: fallbackRadius, ...overrides, }; } const target = clampOrbitTarget(scene, scene.defaultTarget); const cameraDistance = clamp(scene.cameraDistance, scene.minOrbitRadius ?? CAMERA_MIN_RADIUS_MM, scene.maxOrbitRadius ?? CAMERA_BACK_LIMIT_MM); const position = { x: target.x, y: target.y + cameraDistance, z: scene.cameraHeight, }; const dx = position.x - target.x; const dy = position.y - target.y; const dz = position.z - target.z; const radius = Math.max(1, Math.sqrt(dx * dx + dy * dy + dz * dz)); const azimuth = Math.atan2(dx, dy); const polar = clamp(Math.acos(clamp(dz / radius, -0.9999, 0.9999)), MIN_POLAR_ANGLE, MAX_POLAR_ANGLE); const baseState = { target, radius, azimuth, polar, defaultRadius: radius, minRadius: scene.minOrbitRadius, maxRadius: scene.maxOrbitRadius, ...overrides, }; return clampOrbitState(scene, baseState); } function buildAutoFitOrbitState(scene, viewport, overrides = {}){ if(!scene){ return { ...buildOrbitStateFromScene(null), ...overrides }; } const target = clampOrbitTarget(scene, scene.defaultTarget); const { minRadius, maxRadius } = computeOrbitLimits(scene, target); const desiredCameraHeight = 1800; const maxHeight = Math.max(desiredCameraHeight, scene.wallHeight ?? desiredCameraHeight); const cameraHeight = clamp(desiredCameraHeight, 0, maxHeight); const diffZ = cameraHeight - target.z; const viewportWidth = Number.isFinite(viewport?.width) ? viewport.width : 0; const viewportHeight = Number.isFinite(viewport?.height) ? viewport.height : 0; const baseRadius = Math.max(minRadius, Math.abs(diffZ) + 1); const safeBaseRadius = clamp(baseRadius, minRadius, maxRadius); const basePolarRaw = Math.acos(clamp(diffZ / Math.max(safeBaseRadius, 1), -0.9999, 0.9999)); const fallbackState = clampOrbitState(scene, { target, radius: safeBaseRadius, azimuth: 0, polar: clamp(basePolarRaw, MIN_POLAR_ANGLE, MAX_POLAR_ANGLE), defaultRadius: safeBaseRadius, minRadius, maxRadius, ...overrides, }); if(!(viewportWidth > 0) || !(viewportHeight > 0)){ return fallbackState; } const screenHalfWidth = (scene.screenWidth ?? 0) / 2; const screenBottom = scene.clearanceMm ?? 0; const screenTop = screenBottom + (scene.screenHeight ?? 0); const screenBackY = SCREEN_BACK_OFFSET_MM; const screenFrontY = SCREEN_BACK_OFFSET_MM + SCREEN_THICKNESS_MM; const pointsToProject = [ { x: -screenHalfWidth, y: screenFrontY, z: screenBottom }, { x: screenHalfWidth, y: screenFrontY, z: screenBottom }, { x: screenHalfWidth, y: screenFrontY, z: screenTop }, { x: -screenHalfWidth, y: screenFrontY, z: screenTop }, { x: -screenHalfWidth, y: screenBackY, z: screenBottom }, { x: screenHalfWidth, y: screenBackY, z: screenBottom }, { x: screenHalfWidth, y: screenBackY, z: screenTop }, { x: -screenHalfWidth, y: screenBackY, z: screenTop }, ]; const marginRatio = 0.06; const fovRad = Math.PI / 3; const evaluateRadius = (radius)=>{ if(!Number.isFinite(radius)){ return null; } const clampedRadius = clamp(radius, minRadius, maxRadius); if(!(clampedRadius > Math.abs(diffZ))){ return null; } const horizontalSq = clampedRadius * clampedRadius - diffZ * diffZ; if(!(horizontalSq > 1e-3)){ return null; } const horizontalRadius = Math.sqrt(horizontalSq); const cosPolarRaw = clamp(diffZ / clampedRadius, -0.9999, 0.9999); const polar = Math.acos(cosPolarRaw); if(polar < MIN_POLAR_ANGLE || polar > MAX_POLAR_ANGLE){ return null; } const cameraPosition = { x: target.x, y: target.y + horizontalRadius, z: cameraHeight, }; const camera = createCameraBasis(cameraPosition, target, { x: 0, y: 0, z: 1 }); const viewportInfo = { width: viewportWidth, height: viewportHeight }; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; let visible = true; for(const point of pointsToProject){ const projected = project3DPoint(point, camera, viewportInfo, fovRad); if(!projected){ visible = false; break; } minX = Math.min(minX, projected.x); maxX = Math.max(maxX, projected.x); minY = Math.min(minY, projected.y); maxY = Math.max(maxY, projected.y); } if(!visible){ const state = { target, radius: clampedRadius, azimuth: 0, polar, defaultRadius: clampedRadius, minRadius, maxRadius, ...overrides, }; return { fits: false, state }; } const marginX = viewportWidth * marginRatio; const marginY = viewportHeight * marginRatio; const fits = minX >= marginX && maxX <= (viewportWidth - marginX) && minY >= marginY && maxY <= (viewportHeight - marginY); const state = { target, radius: clampedRadius, azimuth: 0, polar, defaultRadius: clampedRadius, minRadius, maxRadius, ...overrides, }; return { fits, state }; }; const baseEval = evaluateRadius(safeBaseRadius); if(!baseEval){ return fallbackState; } if(baseEval.fits){ return clampOrbitState(scene, baseEval.state); } const maxEval = evaluateRadius(maxRadius); if(!maxEval){ return clampOrbitState(scene, baseEval.state); } if(!maxEval.fits){ return clampOrbitState(scene, maxEval.state); } let low = safeBaseRadius; let high = maxRadius; let bestState = maxEval.state; for(let i = 0; i < 40 && (high - low) > 0.5; i += 1){ const mid = (low + high) / 2; const result = evaluateRadius(mid); if(result && result.fits){ bestState = result.state; high = mid; } else { low = mid; } } return clampOrbitState(scene, bestState); } function orbitStateToCameraPosition(orbit){ if(!orbit){ return { x: 0, y: 0, z: 0 }; } const sinPolar = Math.sin(orbit.polar); const cosPolar = Math.cos(orbit.polar); const sinAzimuth = Math.sin(orbit.azimuth); const cosAzimuth = Math.cos(orbit.azimuth); const horizontalRadius = orbit.radius * sinPolar; return { x: orbit.target.x + horizontalRadius * sinAzimuth, y: orbit.target.y + horizontalRadius * cosAzimuth, z: orbit.target.z + orbit.radius * cosPolar, }; } function PixelCalculator(){ const { useState, useEffect, useMemo } = React; const [activeTab, setActiveTab] = useState("visual"); const [desiredW,setDesiredW]=useState(4.8); const [desiredH,setDesiredH]=useState(2.7); const [unit,setUnit]=useState("m"); const [fitMode,setFitMode]=useState("nearest"); const [desiredRes,setDesiredRes]=useState("any"); const [visualScreenDesignCollapsed, setVisualScreenDesignCollapsed] = useState(false); const [visualContentCollapsed, setVisualContentCollapsed] = useState(true); const [screenContentEnabled, setScreenContentEnabled] = useState(false); const [exportingDesign, setExportingDesign] = useState(false); const [exportStatus, setExportStatus] = useState(null); const [screenContentImage, setScreenContentImage] = useState(SAMPLE_SCREEN_CONTENT_IMAGES[0]); const [screenContentFit, setScreenContentFit] = useState("stretch"); const [screenContentSampleId, setScreenContentSampleId] = useState(""); const [hideCabinetGridLines, setHideCabinetGridLines] = useState(false); useEffect(()=>{ // Clear the export status when switching between calculator modes. setExportStatus(null); },[activeTab]); const [cabA,setCabA]=useState(DEFAULT_CABINET_IDS.A); const [cabB,setCabB]=useState(DEFAULT_CABINET_IDS.B); const [cabC,setCabC]=useState(DEFAULT_CABINET_IDS.C); const [pitchA,setPitchA]=useState(1.5); const [pitchB,setPitchB]=useState(1.5); const [pitchC,setPitchC]=useState(1.5); const manualPitchRef = React.useRef({ A: false, B: false, C: false }); const [designCounter, setDesignCounter] = useState(()=>INITIAL_COUNTER); const incrementCounter = React.useCallback((delta)=>{ if(!delta || delta <= 0) return; setDesignCounter(prev=>prev + delta); if (typeof PWLC_AJAX === "undefined" || !PWLC_AJAX.ajax_url || !PWLC_AJAX.nonce) { return; } const params = new URLSearchParams(); params.append("action", "pwlc_increment_counter"); params.append("nonce", PWLC_AJAX.nonce); params.append("delta", String(delta)); fetch(PWLC_AJAX.ajax_url, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, body: params.toString(), }) .then((res)=>{ if(!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((json)=>{ if(json && json.success && json.data && typeof json.data.count === "number"){ setDesignCounter(json.data.count); } }) .catch((err)=>{ if (typeof console !== "undefined" && console.warn) { console.warn("Failed to persist design counter", err); } }); },[]); const lastDesignRef = React.useRef({ A: null, B: null, C: null }); const designInitRef = React.useRef(false); const lastMaxRowsRef = React.useRef(Infinity); const [manualCab, setManualCab] = useState(DEFAULT_CABINET_IDS.A); const cabinetsById = useMemo(()=>Object.fromEntries(CABINETS.map((c)=>[c.id, c])),[]); const [manualPitch, setManualPitch] = useState(1.5); const [manualColsInput, setManualColsInput] = useState("3"); const [manualRowsInput, setManualRowsInput] = useState("3"); const [visualColsInput, setVisualColsInput] = useState("3"); const [visualRowsInput, setVisualRowsInput] = useState("3"); const manualDesignLastRef = React.useRef(null); const manualDesignInitRef = React.useRef(false); const visualDesignLastRef = React.useRef(null); const visualDesignInitRef = React.useRef(false); const [hoveredDesign, setHoveredDesign] = useState(null); const hoverTimeoutRef = React.useRef(null); const [designActionStatus, setDesignActionStatus] = useState(null); const designActionTimeoutRef = React.useRef(null); const visualContainerRef = React.useRef(null); const [visualCanvasEl, setVisualCanvasEl] = useState(null); const visualSummaryRef = React.useRef(null); const screenDesignRef = React.useRef(null); const manualDesignRef = React.useRef(null); const [visualOrbitState, setVisualOrbitState] = useState(()=>buildOrbitStateFromScene(null)); const visualOrbitStateRef = React.useRef(visualOrbitState); const visualOrbitTargetRef = React.useRef(visualOrbitState); const visualIntroAnimationRef = React.useRef({ started: false, completed: false, active: false, startTime: null, durationMs: 2000, startState: null, finalState: null, }); const lastSceneDefaultTargetRef = React.useRef({ x: 0, y: 0, z: 0 }); const visualOrbitDragStartRef = React.useRef(null); const visualPointerModeRef = React.useRef(null); const visualPointerStartRef = React.useRef({ x: 0, y: 0 }); const visualSceneRef = React.useRef(null); const [visualViewport, setVisualViewport] = useState({ width: 0, height: 0 }); const [visualContainerEl, setVisualContainerEl] = useState(null); const [visualFloorClearanceInput, setVisualFloorClearanceInput] = useState("1"); const [visualFloorClearanceUnit, setVisualFloorClearanceUnit] = useState("m"); const [visualWallHeightInput, setVisualWallHeightInput] = useState("4"); const [visualWallHeightUnit, setVisualWallHeightUnit] = useState("m"); const [visualWallWidthInput, setVisualWallWidthInput] = useState("10"); const [visualWallWidthUnit, setVisualWallWidthUnit] = useState("m"); const [visualPersonEnabled, setVisualPersonEnabled] = useState(true); const [visualWallHeightCommittedInput, setVisualWallHeightCommittedInput] = useState("4"); const [visualWallWidthCommittedInput, setVisualWallWidthCommittedInput] = useState("10"); const [visualRowsError, setVisualRowsError] = useState(null); const [wallWidthWarning, setWallWidthWarning] = useState(null); const [bodyModel, setBodyModel] = useState(null); const [cabinetLogoAsset, setCabinetLogoAsset] = useState(null); const screenContentFileInputRef = React.useRef(null); const screenContentLoadTokenRef = React.useRef(0); const screenContentObjectUrlRef = React.useRef(null); const screenTextureBufferRef = React.useRef({ canvas: null, ctx: null, lastKey: null, width: 0, height: 0 }); const attachVisualContainer = React.useCallback((node)=>{ visualContainerRef.current = node; setVisualContainerEl(node); },[]); const attachVisualCanvas = React.useCallback((node)=>{ setVisualCanvasEl(node); },[]); const handleExportDesign = React.useCallback(async ()=>{ // Avoid starting another export while one is in progress. if(exportingDesign){ return; } if(typeof window === "undefined"){ return; } // Collect the currently visible sections that should be exported. const captureTargets = []; if(activeTab === "visual" && visualContainerEl && isElementVisible(visualContainerEl)){ captureTargets.push({ element: visualContainerEl }); } let designElement = null; if(activeTab === "visual"){ designElement = visualSummaryRef.current; } else if(activeTab === "screen"){ designElement = screenDesignRef.current; } else if(activeTab === "manual"){ designElement = manualDesignRef.current; } if(designElement && isElementVisible(designElement)){ captureTargets.push({ element: designElement }); } if(captureTargets.length === 0){ // Let the user know we need visible content before exporting. setExportStatus({ type: "error", message: "No exportable design content is visible yet." }); return; } try { // Update UI state before running the async export steps. setExportingDesign(true); setExportStatus({ type: "info", message: "Generating PDF…" }); // Load html2canvas and jsPDF on demand from the CDN. const html2canvas = await ensureHtml2Canvas(); const JsPDFConstructor = await ensureJsPDF(); const scale = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); const capturedImages = []; for(const target of captureTargets){ try { // Render each DOM node into an off-screen canvas. const canvas = await html2canvas(target.element, { backgroundColor: "#ffffff", useCORS: true, scale, }); capturedImages.push({ title: target.title, dataUrl: canvas.toDataURL("image/png") }); } catch(captureError){ if(typeof console !== "undefined" && console.error){ console.error("Failed to capture export section", captureError); } } } if(capturedImages.length === 0){ throw new Error("No sections captured"); } const pdf = new JsPDFConstructor({ orientation: "portrait", unit: "pt", format: "letter" }); const marginX = 48; const marginTop = 60; const marginBottom = 60; let currentY = marginTop; const pageWidth = pdf.internal.pageSize.getWidth(); const pageHeight = pdf.internal.pageSize.getHeight(); const availableWidth = pageWidth - marginX * 2; const exportDate = new Date(); const modeLabel = activeTab === "visual" ? "Visual Mode" : activeTab === "screen" ? "Screen-Based Mode" : "Manual Mode"; pdf.setFont("helvetica", "bold"); pdf.setFontSize(20); pdf.text("Pixel Calculator Design Export", pageWidth / 2, currentY, { align: "center" }); currentY += 24; pdf.setFont("helvetica", "normal"); pdf.setFontSize(12); pdf.setTextColor(55); let generatedLabel = ""; try { generatedLabel = exportDate.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); } catch(_) { generatedLabel = `${exportDate.toLocaleDateString()} ${exportDate.toLocaleTimeString()}`; } const subtitleLine = `${modeLabel} • Generated ${generatedLabel}`; pdf.text(subtitleLine, pageWidth / 2, currentY, { align: "center" }); currentY += 22; pdf.setTextColor(0); // Precompute section dimensions so we can scale them to fit a single letter page. const sectionSpacing = 18; const sections = capturedImages.map((capture)=>{ const imageProps = pdf.getImageProperties(capture.dataUrl); const naturalWidth = imageProps?.width || 1; const naturalHeight = imageProps?.height || 1; const imageHeight = naturalWidth ? (naturalHeight * availableWidth) / naturalWidth : availableWidth; return { ...capture, imageHeight }; }); const totalHeadingHeight = sections.reduce((sum, section)=>sum + (section.title ? 18 : 0), 0); const totalImageHeight = sections.reduce((sum, section)=>sum + section.imageHeight, 0); const totalSpacingUnits = sections.length > 1 ? sections.length - 1 : 0; const availableContentHeight = pageHeight - marginBottom - currentY; let imageScale = 1; if(totalImageHeight > 0){ // Constrain the image stack to the remaining vertical space. const availableForImages = Math.max(availableContentHeight - totalHeadingHeight, 0); if(availableForImages < totalImageHeight){ imageScale = Math.max(availableForImages / totalImageHeight, 0); } } const usedImageHeight = totalImageHeight * imageScale; const remainingHeightForSpacing = Math.max(availableContentHeight - totalHeadingHeight - usedImageHeight, 0); // Allow the inter-section spacing to shrink if the captures are tall. const spacingScale = totalSpacingUnits > 0 ? Math.min(1, remainingHeightForSpacing / (sectionSpacing * totalSpacingUnits)) : 1; const adjustedSpacing = sectionSpacing * spacingScale; sections.forEach((section, index)=>{ if(section.title){ pdf.setFont("helvetica", "bold"); pdf.setFontSize(14); pdf.text(section.title, marginX, currentY); pdf.setFont("helvetica", "normal"); pdf.setFontSize(12); currentY += 18; } const imageWidth = availableWidth * imageScale; const imageHeight = section.imageHeight * imageScale; const imageX = marginX + (availableWidth - imageWidth) / 2; pdf.addImage(section.dataUrl, "PNG", imageX, currentY, imageWidth, imageHeight, undefined, "FAST"); currentY += imageHeight; if(index < sections.length - 1){ currentY += adjustedSpacing; } }); const footerText = `Pixel Calculator for LED Video Wall V${TOOL_VERSION} — PixelCal.com — © ${exportDate.getFullYear()} PixelCal.com. All rights reserved.`; pdf.setFont("helvetica", "normal"); pdf.setFontSize(10); pdf.setTextColor(100); pdf.text(footerText, pageWidth / 2, pageHeight - 30, { align: "center" }); const timestamp = `${exportDate.getFullYear()}${String(exportDate.getMonth() + 1).padStart(2, "0")}${String(exportDate.getDate()).padStart(2, "0")}_${String(exportDate.getHours()).padStart(2, "0")}${String(exportDate.getMinutes()).padStart(2, "0")}${String(exportDate.getSeconds()).padStart(2, "0")}`; const filename = `PixelCal_Design_${timestamp}.pdf`; pdf.save(filename); setExportStatus({ type: "success", message: "Export started – check your downloads." }); } catch(exportError){ if(typeof console !== "undefined" && console.error){ console.error("Failed to export design", exportError); } setExportStatus({ type: "error", message: "Unable to export design right now. Please try again." }); } finally { setExportingDesign(false); } },[activeTab, exportingDesign, visualContainerEl]); const handleVisualReset = React.useCallback(()=>{ const scene = visualSceneRef.current; let nextState = null; if(scene){ nextState = buildAutoFitOrbitState(scene, visualViewport); } else { nextState = buildOrbitStateFromScene(null); } if(!nextState){ return; } visualOrbitDragStartRef.current = null; visualOrbitTargetRef.current = nextState; if(nextState.target){ lastSceneDefaultTargetRef.current = nextState.target; } },[visualViewport]); React.useEffect(()=>{ ensureMaterialSymbolFont(); },[]); React.useEffect(()=>{ let cancelled = false; const url = resolveAssetUrl(CABINET_LOGO_RELATIVE_PATH); if(!url){ setCabinetLogoAsset(null); return ()=>{ cancelled = true; }; } const img = new Image(); img.decoding = "async"; img.onload = ()=>{ if(cancelled){ return; } const naturalWidth = img.naturalWidth || img.width || 0; const naturalHeight = img.naturalHeight || img.height || 0; if(naturalWidth > 0 && naturalHeight > 0){ setCabinetLogoAsset({ image: img, width: naturalWidth, height: naturalHeight, aspectRatio: naturalWidth / naturalHeight }); } else { setCabinetLogoAsset({ image: img, width: naturalWidth, height: naturalHeight, aspectRatio: 1 }); } }; img.onerror = (error)=>{ if(cancelled){ return; } if(typeof console !== "undefined" && console.warn){ console.warn("Failed to load cabinet logo image", error); } setCabinetLogoAsset(null); }; img.src = url; return ()=>{ cancelled = true; img.onload = null; img.onerror = null; }; },[]); React.useEffect(()=>{ return ()=>{ if(hoverTimeoutRef.current){ clearTimeout(hoverTimeoutRef.current); } if(designActionTimeoutRef.current){ clearTimeout(designActionTimeoutRef.current); } }; },[]); React.useEffect(()=>{ visualOrbitStateRef.current = visualOrbitState; },[visualOrbitState]); React.useEffect(()=>{ const handle = setTimeout(()=>{ setVisualWallHeightCommittedInput(visualWallHeightInput); }, 1000); return ()=>clearTimeout(handle); },[visualWallHeightInput]); React.useEffect(()=>{ const handle = setTimeout(()=>{ setVisualWallWidthCommittedInput(visualWallWidthInput); }, 1000); return ()=>clearTimeout(handle); },[visualWallWidthInput]); React.useEffect(()=>{ let frame = null; let lastTime = null; const step = (time)=>{ frame = requestAnimationFrame(step); const scene = visualSceneRef.current; if(!scene){ lastTime = time; return; } if(lastTime === null){ lastTime = time; return; } const deltaMs = time - lastTime; lastTime = time; const dt = Math.min(Math.max(deltaMs / 1000, 0), 0.05); const introStatus = visualIntroAnimationRef.current; if(introStatus && introStatus.active){ if(introStatus.startTime === null){ introStatus.startTime = time; } const duration = introStatus.durationMs || 1; const elapsed = time - introStatus.startTime; const rawProgress = clamp(elapsed / duration, 0, 1); const eased = easeInOutCubic(rawProgress); const startState = introStatus.startState; const finalState = introStatus.finalState; if(startState && finalState){ const desiredTarget = interpolateTarget(startState.target, finalState.target, eased); const desiredRadius = interpolateNumber(startState.radius, finalState.radius, eased); const desiredPolar = interpolateNumber(startState.polar, finalState.polar, eased); const desiredAzimuth = interpolateAngle(startState.azimuth, finalState.azimuth, eased); const desiredDefaultRadius = interpolateNumber( startState.defaultRadius ?? startState.radius, finalState.defaultRadius ?? finalState.radius, eased ); const desiredState = clampOrbitState(scene, { ...finalState, target: desiredTarget, radius: desiredRadius, polar: desiredPolar, azimuth: desiredAzimuth, defaultRadius: desiredDefaultRadius, }); visualOrbitTargetRef.current = desiredState; visualOrbitStateRef.current = desiredState; setVisualOrbitState(desiredState); if(rawProgress >= 1){ introStatus.active = false; introStatus.completed = true; introStatus.startTime = null; introStatus.startState = null; introStatus.finalState = finalState; visualOrbitTargetRef.current = finalState; visualOrbitStateRef.current = finalState; setVisualOrbitState(finalState); } } else { introStatus.active = false; introStatus.startTime = null; introStatus.startState = null; introStatus.started = false; introStatus.completed = false; } return; } const currentRaw = visualOrbitStateRef.current; const targetRaw = visualOrbitTargetRef.current; if(!currentRaw || !targetRaw){ return; } const current = clampOrbitState(scene, currentRaw); const target = clampOrbitState(scene, targetRaw); visualOrbitTargetRef.current = target; const blend = 1 - Math.exp(-8 * dt); const currentTarget = current.target || { x: 0, y: 0, z: 0 }; const targetTarget = target.target || { x: 0, y: 0, z: 0 }; const interpolatedTarget = { x: currentTarget.x + (targetTarget.x - currentTarget.x) * blend, y: currentTarget.y + (targetTarget.y - currentTarget.y) * blend, z: currentTarget.z + (targetTarget.z - currentTarget.z) * blend, }; const nextTarget = clampOrbitTarget(scene, interpolatedTarget); const nextAzimuth = wrapAngle(current.azimuth + shortestAngleDifference(target.azimuth, current.azimuth) * blend); const desiredPolar = clamp(current.polar + shortestAngleDifference(target.polar, current.polar) * blend, MIN_POLAR_ANGLE, MAX_POLAR_ANGLE); const limits = computeOrbitLimits(scene, nextTarget); const targetRadius = clamp(target.radius, limits.minRadius, limits.maxRadius); const nextRadius = clamp(current.radius + (targetRadius - current.radius) * blend, limits.minRadius, limits.maxRadius); const nextDefaultRadius = clamp(target.defaultRadius ?? current.defaultRadius ?? nextRadius, limits.minRadius, limits.maxRadius); const epsilon = 1e-2; const changed = Math.abs(nextTarget.x - currentTarget.x) > epsilon || Math.abs(nextTarget.y - currentTarget.y) > epsilon || Math.abs(nextTarget.z - currentTarget.z) > epsilon || Math.abs(nextAzimuth - current.azimuth) > 1e-4 || Math.abs(desiredPolar - current.polar) > 1e-4 || Math.abs(nextRadius - current.radius) > epsilon || Math.abs((limits.minRadius ?? 0) - (current.minRadius ?? 0)) > epsilon || Math.abs((limits.maxRadius ?? 0) - (current.maxRadius ?? 0)) > epsilon; if(changed){ const nextState = clampOrbitState(scene, { target: nextTarget, azimuth: nextAzimuth, polar: desiredPolar, radius: nextRadius, minRadius: limits.minRadius, maxRadius: limits.maxRadius, defaultRadius: nextDefaultRadius, }); visualOrbitStateRef.current = nextState; setVisualOrbitState(nextState); } }; frame = requestAnimationFrame(step); return ()=>{ if(frame !== null){ cancelAnimationFrame(frame); } }; },[]); React.useEffect(()=>{ if(!visualContainerEl){ return; } const el = visualContainerEl; const updateSize=()=>{ setVisualViewport({ width: el.clientWidth, height: el.clientHeight }); }; updateSize(); if(typeof ResizeObserver !== "undefined"){ const observer=new ResizeObserver((entries)=>{ const entry=entries && entries[0]; if(entry){ const { width, height } = entry.contentRect; setVisualViewport({ width, height }); } }); observer.observe(el); return ()=>observer.disconnect(); } if(typeof window !== "undefined"){ window.addEventListener('resize', updateSize); return ()=>window.removeEventListener('resize', updateSize); } return undefined; },[visualContainerEl]); const manualCols = React.useMemo(()=>{ const parsed = parseInt(manualColsInput, 10); if(!Number.isFinite(parsed) || parsed < 1){ return 1; } return parsed; },[manualColsInput]); const manualRows = React.useMemo(()=>{ const parsed = parseInt(manualRowsInput, 10); if(!Number.isFinite(parsed) || parsed < 1){ return 1; } return parsed; },[manualRowsInput]); const visualCols = React.useMemo(()=>{ const parsed = parseInt(visualColsInput, 10); if(!Number.isFinite(parsed) || parsed < 1){ return 1; } return parsed; },[visualColsInput]); const visualRows = React.useMemo(()=>{ const parsed = parseInt(visualRowsInput, 10); if(!Number.isFinite(parsed) || parsed < 1){ return 1; } return parsed; },[visualRowsInput]); const visualFloorClearanceMm = React.useMemo(()=>{ const parsed = parseFloat(visualFloorClearanceInput); if(!Number.isFinite(parsed) || parsed < 0){ return 0; } const mmValue = Math.max(0, toMM(parsed, visualFloorClearanceUnit)); return clamp(mmValue, 0, ROOM_LIMIT_MM); },[visualFloorClearanceInput, visualFloorClearanceUnit]); const visualWallHeightMm = React.useMemo(()=>{ const parsed = parseFloat(visualWallHeightCommittedInput); const mmValue = Number.isFinite(parsed) && parsed > 0 ? toMM(parsed, visualWallHeightUnit) : DEFAULT_WALL_HEIGHT_MM; return clampRoomDimension(mmValue); },[visualWallHeightCommittedInput, visualWallHeightUnit]); const visualWallWidthMm = React.useMemo(()=>{ const parsed = parseFloat(visualWallWidthCommittedInput); const mmValue = Number.isFinite(parsed) && parsed > 0 ? toMM(parsed, visualWallWidthUnit) : DEFAULT_WALL_WIDTH_MM; return clampRoomDimension(mmValue); },[visualWallWidthCommittedInput, visualWallWidthUnit]); const manualData = React.useMemo(()=>{ const cab = cabinetsById[manualCab]; if(!cab){ return null; } const cols = manualCols; const rows = manualRows; if(cols<=0 || rows<=0){ return null; } const widthMm = cols * cab.w; const heightMm = rows * cab.h; const diagIn = diagonalInches(widthMm, heightMm); const tile = getCabinetTileResolution(manualCab, manualPitch); let resX; let resY; if(tile){ resX = tile.x * cols; resY = tile.y * rows; } else { resX = Math.round(widthMm / manualPitch); resY = Math.round(heightMm / manualPitch); } const totalPixels = resX * resY; const areaM2 = (widthMm * heightMm) / 1_000_000; const pixelDensityMpixPerSqm = areaM2 > 0 ? (totalPixels / areaM2) / 1_000_000 : null; const totalPixelsMP = totalPixels / 1_000_000; const processor = computeProcessorRecommendation(PROCESSOR_CATALOG, totalPixels, resX, resY); return { cab, cols, rows, widthMm, heightMm, diagIn, resX, resY, totalPixels, areaM2, pitch: manualPitch, pixelDensityMpixPerSqm, totalPixelsMP, processor, }; },[cabinetsById, manualCab, manualCols, manualRows, manualPitch]); const visualData = React.useMemo(()=>{ const cab = cabinetsById[manualCab]; if(!cab){ return null; } const cols = visualCols; const rows = visualRows; if(cols<=0 || rows<=0){ return null; } const widthMm = cols * cab.w; const heightMm = rows * cab.h; if(!(widthMm > 0) || !(heightMm > 0)){ return null; } if(widthMm > visualWallWidthMm + 1e-6){ return null; } const clearanceMm = Math.max(0, visualFloorClearanceMm); const availableHeight = visualWallHeightMm - clearanceMm; if(!(availableHeight > 0)){ return null; } if(heightMm > availableHeight + 1e-6){ return null; } const diagIn = diagonalInches(widthMm, heightMm); const tile = getCabinetTileResolution(manualCab, manualPitch); let resX; let resY; if(tile){ resX = tile.x * cols; resY = tile.y * rows; } else { resX = Math.round(widthMm / manualPitch); resY = Math.round(heightMm / manualPitch); } const totalPixels = resX * resY; const areaM2 = (widthMm * heightMm) / 1_000_000; const pixelDensityMpixPerSqm = areaM2 > 0 ? (totalPixels / areaM2) / 1_000_000 : null; const totalPixelsMP = totalPixels / 1_000_000; const processor = computeProcessorRecommendation(PROCESSOR_CATALOG, totalPixels, resX, resY); return { cab, cols, rows, widthMm, heightMm, diagIn, resX, resY, totalPixels, areaM2, pitch: manualPitch, pixelDensityMpixPerSqm, totalPixelsMP, processor, }; },[cabinetsById, manualCab, visualCols, visualRows, manualPitch, visualFloorClearanceMm, visualWallHeightMm, visualWallWidthMm]); const formatDesignSummary = React.useCallback((data)=>{ if(!data){ return null; } const { widthMm, heightMm, diagIn, resX, resY, totalPixels, areaM2, cols, rows, pitch, pixelDensityMpixPerSqm } = data; const widthM = widthMm / 1000; const heightM = heightMm / 1000; const widthFt = fromMM(widthMm, "ft"); const heightFt = fromMM(heightMm, "ft"); const areaFt2 = widthFt * heightFt; const minViewingDistanceM = ceilToDecimals(pitch,1); const minViewingDistanceFt = ceilToDecimals(pitch * FT_PER_M,0); const minViewingDistanceMLabel = Number.isFinite(minViewingDistanceM) ? fmtFixed(minViewingDistanceM,1) : null; const minViewingDistanceFtLabel = Number.isFinite(minViewingDistanceFt) ? minViewingDistanceFt.toLocaleString() : null; let processorLabel = "—"; if(data.processor && data.processor.summary){ processorLabel = data.processor.summary; } return { layoutLabel: `${cols} × ${rows}`, dimensionLabel: `${fmt(widthMm,0)} mm × ${fmt(heightMm,0)} mm (${fmt(widthM,3)} m × ${fmt(heightM,3)} m)` , dimensionFeetLabel: `${fmt(widthFt,3)} ft × ${fmt(heightFt,3)} ft`, diagLabel: `${fmt(diagIn,2)} in`, resolutionLabel: `${resX.toLocaleString()} × ${resY.toLocaleString()}`, aspectLabel: formatAspect(resX, resY), areaLabel: `${fmt(areaM2,3)} m² (${fmt(areaFt2,3)} ft²)`, totalPixelsLabel: formatPixelsWithStandard(totalPixels), totalCabinetsLabel: `${(cols*rows).toLocaleString()} cabinets`, pitchLabel: fmtPitch(pitch), pixelDensityLabel: Number.isFinite(pixelDensityMpixPerSqm) ? `${fmt(pixelDensityMpixPerSqm,3)} Mpix/sqm` : "—", minViewingDistanceLabel: (minViewingDistanceMLabel && minViewingDistanceFtLabel) ? `> ${minViewingDistanceMLabel} m (> ${minViewingDistanceFtLabel} ft)` : "—", processorRecommendation: processorLabel, }; },[]); const manualFormatted = React.useMemo(()=>formatDesignSummary(manualData),[formatDesignSummary, manualData]); const visualFormatted = React.useMemo(()=>formatDesignSummary(visualData),[formatDesignSummary, visualData]); const manualDesignSignature = React.useMemo(()=>{ if(!manualData){ return null; } const cabId = manualData.cab && manualData.cab.id ? manualData.cab.id : (manualCab || ""); const pitchVal = Number.isFinite(manualData.pitch) ? manualData.pitch : 0; const colsVal = Number.isFinite(manualData.cols) ? manualData.cols : 0; const rowsVal = Number.isFinite(manualData.rows) ? manualData.rows : 0; const widthKey = Number.isFinite(manualData.widthMm) ? Math.round(manualData.widthMm) : 0; const heightKey = Number.isFinite(manualData.heightMm) ? Math.round(manualData.heightMm) : 0; const resXVal = Number.isFinite(manualData.resX) ? manualData.resX : 0; const resYVal = Number.isFinite(manualData.resY) ? manualData.resY : 0; return [cabId, pitchVal, colsVal, rowsVal, widthKey, heightKey, resXVal, resYVal].join("|"); },[manualData, manualCab]); React.useEffect(()=>{ if(!manualDesignSignature){ manualDesignLastRef.current = null; return; } if(!manualDesignInitRef.current){ manualDesignLastRef.current = manualDesignSignature; manualDesignInitRef.current = true; return; } if(manualDesignLastRef.current !== manualDesignSignature){ manualDesignLastRef.current = manualDesignSignature; incrementCounter(1); } },[manualDesignSignature, incrementCounter]); const visualDesignSignature = React.useMemo(()=>{ if(!visualData){ return null; } const cabId = visualData.cab && visualData.cab.id ? visualData.cab.id : (manualCab || ""); const pitchVal = Number.isFinite(visualData.pitch) ? visualData.pitch : 0; const colsVal = Number.isFinite(visualData.cols) ? visualData.cols : 0; const rowsVal = Number.isFinite(visualData.rows) ? visualData.rows : 0; const widthKey = Number.isFinite(visualData.widthMm) ? Math.round(visualData.widthMm) : 0; const heightKey = Number.isFinite(visualData.heightMm) ? Math.round(visualData.heightMm) : 0; const resXVal = Number.isFinite(visualData.resX) ? visualData.resX : 0; const resYVal = Number.isFinite(visualData.resY) ? visualData.resY : 0; return [cabId, pitchVal, colsVal, rowsVal, widthKey, heightKey, resXVal, resYVal].join("|"); },[visualData, manualCab]); React.useEffect(()=>{ if(!visualDesignSignature){ visualDesignLastRef.current = null; return; } if(!visualDesignInitRef.current){ visualDesignLastRef.current = visualDesignSignature; visualDesignInitRef.current = true; return; } if(visualDesignLastRef.current !== visualDesignSignature){ visualDesignLastRef.current = visualDesignSignature; incrementCounter(1); } },[visualDesignSignature, incrementCounter]); const visualScene = React.useMemo(()=>{ if(!visualData){ return null; } const screenWidth = visualData.widthMm; const screenHeight = visualData.heightMm; const clearanceMm = Math.max(0, visualFloorClearanceMm); const totalHeight = clearanceMm + screenHeight; const minWallHeight = clearanceMm + screenHeight; const wallHeight = clampRoomDimension(Math.max(visualWallHeightMm, minWallHeight)); const wallWidth = clampRoomDimension(Math.max(visualWallWidthMm, screenWidth)); const floorWidth = wallWidth; const desiredDepth = Math.max( wallWidth, screenWidth * 2.2, totalHeight * 1.6, SCREEN_THICKNESS_MM * 80, 4000 ); const floorDepth = clampRoomDimension(desiredDepth); const baseSize = Math.max(wallWidth, wallHeight, floorDepth); const defaultTarget = { x: 0, y: 0, z: clearanceMm + screenHeight / 2 }; const targetZ = clamp(defaultTarget.z, 0, wallHeight); const minOrbitRadius = CAMERA_MIN_RADIUS_MM; const maxOrbitRadius = Math.max(minOrbitRadius, Math.min(floorDepth, CAMERA_BACK_LIMIT_MM)); const baseRadius = clamp(Math.max(wallWidth, wallHeight, screenWidth) * 1.1, minOrbitRadius, maxOrbitRadius); const cameraDistance = clamp(baseRadius, minOrbitRadius, Math.min(maxOrbitRadius, CAMERA_BACK_LIMIT_MM - defaultTarget.y)); const cameraHeight = targetZ; return { screenWidth, screenHeight, clearanceMm, totalHeight, baseSize, floorWidth, floorDepth, wallHeight, wallWidth, cameraDistance, cameraHeight, defaultTarget: { x: 0, y: 0, z: targetZ }, minOrbitRadius, maxOrbitRadius, }; },[visualData, visualFloorClearanceMm, visualWallHeightMm, visualWallWidthMm]); React.useEffect(()=>{ visualSceneRef.current = visualScene; },[visualScene]); React.useEffect(()=>{ if(!visualScene){ const fallback = buildOrbitStateFromScene(null); lastSceneDefaultTargetRef.current = fallback.target; visualOrbitTargetRef.current = fallback; visualOrbitStateRef.current = fallback; setVisualOrbitState(fallback); return; } const defaults = buildOrbitStateFromScene(visualScene); const previous = visualOrbitStateRef.current; if(!previous || !previous.target){ const constrainedDefaults = clampOrbitState(visualScene, defaults); lastSceneDefaultTargetRef.current = constrainedDefaults.target; visualOrbitTargetRef.current = constrainedDefaults; visualOrbitStateRef.current = constrainedDefaults; setVisualOrbitState(constrainedDefaults); return; } const previousCameraPosition = orbitStateToCameraPosition(previous); const previousDefaultTarget = lastSceneDefaultTargetRef.current || defaults.target; const target = clampOrbitTarget(visualScene, { x: defaults.target.x + (previous.target.x - previousDefaultTarget.x), y: defaults.target.y + (previous.target.y - previousDefaultTarget.y), z: defaults.target.z + (previous.target.z - previousDefaultTarget.z), }); const dx = previousCameraPosition.x - target.x; const dy = previousCameraPosition.y - target.y; const dz = previousCameraPosition.z - target.z; const radius = Math.max(1, Math.sqrt(dx * dx + dy * dy + dz * dz)); const azimuth = Math.atan2(dx, dy); const polar = clamp(Math.acos(clamp(dz / radius, -0.9999, 0.9999)), MIN_POLAR_ANGLE, MAX_POLAR_ANGLE); const baseState = { ...defaults, target, radius, azimuth, polar, defaultRadius: previous.defaultRadius ?? defaults.defaultRadius ?? radius, }; const nextState = clampOrbitState(visualScene, baseState); visualOrbitTargetRef.current = nextState; visualOrbitStateRef.current = nextState; setVisualOrbitState(nextState); lastSceneDefaultTargetRef.current = defaults.target; },[visualScene]); React.useEffect(()=>{ const scene = visualScene; const status = visualIntroAnimationRef.current; if(!scene || status.started || status.completed){ return undefined; } status.started = true; status.completed = false; status.active = false; status.startTime = null; status.startState = null; const finalState = buildOrbitStateFromScene(scene); const { minRadius, maxRadius } = computeOrbitLimits(scene, finalState.target); const sweepRadius = clamp( finalState.radius * randomInRange(1.45, 1.75), minRadius, maxRadius ); const spanCandidates = [ finalState.radius, sweepRadius, scene.baseSize, scene.floorDepth, scene.wallWidth, ].filter((value)=>Number.isFinite(value) && value > 0); const baseSpan = spanCandidates.length ? Math.max(...spanCandidates) : finalState.radius; const lateralOffset = baseSpan * 0.35; const wallHeightForLift = (Number.isFinite(scene.wallHeight) && scene.wallHeight > 0) ? scene.wallHeight : finalState.target.z + baseSpan; const verticalLift = wallHeightForLift * 0.2 * randomInRange(0.75, 1.3); const lateralSign = randomSign(); const azimuthOffset = randomInRange(Math.PI / 4, Math.PI * 0.55) * lateralSign; const startTarget = clampOrbitTarget(scene, { x: finalState.target.x + lateralOffset * randomInRange(0.45, 0.85) * lateralSign, y: finalState.target.y - lateralOffset * randomInRange(0.8, 1.2), z: finalState.target.z + verticalLift, }); const startState = clampOrbitState(scene, { ...finalState, target: startTarget, radius: sweepRadius, azimuth: wrapAngle(finalState.azimuth + azimuthOffset), polar: clamp(finalState.polar * randomInRange(0.5, 0.75), MIN_POLAR_ANGLE, MAX_POLAR_ANGLE), defaultRadius: clamp(finalState.defaultRadius, minRadius, maxRadius), }); visualOrbitTargetRef.current = startState; visualOrbitStateRef.current = startState; setVisualOrbitState(startState); status.finalState = finalState; status.startState = startState; status.durationMs = 2000; status.active = true; status.startTime = null; return ()=>{ const introStatus = visualIntroAnimationRef.current; if(introStatus){ const hadStarted = introStatus.startTime !== null; if(introStatus.active && introStatus.finalState){ visualOrbitTargetRef.current = introStatus.finalState; visualOrbitStateRef.current = introStatus.finalState; setVisualOrbitState(introStatus.finalState); } introStatus.active = false; introStatus.startTime = null; introStatus.startState = null; if(!introStatus.completed){ if(!hadStarted){ introStatus.started = false; } else { introStatus.completed = true; } } } }; },[visualScene]); const manualPitchOptions = React.useMemo(()=>{ const raw = getSupportedPitches(manualCab) || []; return raw.slice().sort((a,b)=>a-b); },[manualCab]); const getMaxRowsAllowed = React.useCallback((cab)=>{ if(!cab || !(cab.h > 0)){ return Infinity; } const clearance = Math.max(0, visualFloorClearanceMm); const available = visualWallHeightMm - clearance; if(!(available > 0)){ return 0; } const maxRows = Math.floor((available + 1e-6) / cab.h); return Math.max(0, maxRows); },[visualFloorClearanceMm, visualWallHeightMm]); const getMaxColsAllowed = React.useCallback((cab)=>{ if(!cab || !(cab.w > 0)){ return Infinity; } const available = visualWallWidthMm; if(!(available > 0)){ return 0; } const maxCols = Math.floor((available + 1e-6) / cab.w); return Math.max(0, maxCols); },[visualWallWidthMm]); const wallHeightClearanceError = React.useMemo(()=>{ return visualWallHeightMm > 0 && visualWallHeightMm <= visualFloorClearanceMm; },[visualWallHeightMm, visualFloorClearanceMm]); React.useEffect(()=>{ setManualPitch(prev=>ensurePitchSupported(prev, manualPitchOptions)); },[manualPitchOptions]); React.useEffect(()=>{ setWallWidthWarning(null); },[visualWallWidthMm]); React.useEffect(()=>{ const cab = cabinetsById[manualCab]; if(!cab){ lastMaxRowsRef.current = Infinity; if(visualRowsError){ setVisualRowsError(null); } return; } const maxRows = getMaxRowsAllowed(cab); const prevMaxRows = lastMaxRowsRef.current; lastMaxRowsRef.current = maxRows; if(!Number.isFinite(maxRows)){ if(visualRowsError){ setVisualRowsError(null); } return; } let adjusted = false; setVisualRowsInput(prev=>{ let parsed = parseInt(prev, 10); if(!Number.isFinite(parsed) || parsed < 1){ parsed = 1; } let result = parsed; if(maxRows <= 0){ if(parsed !== 1){ adjusted = true; } result = 1; } else if(parsed > maxRows){ adjusted = true; result = Math.max(1, maxRows); } return String(result); }); if(maxRows <= 0){ setVisualRowsError("Wall height is too low for the selected clearance and cabinet height."); } else if(adjusted){ setVisualRowsError("Row count adjusted to fit within the wall height."); } else if(prevMaxRows !== maxRows && visualRowsError){ setVisualRowsError(null); } },[manualCab, cabinetsById, getMaxRowsAllowed, visualRowsError]); React.useEffect(()=>{ const cab = cabinetsById[manualCab]; if(!cab){ setWallWidthWarning(null); return; } const maxCols = getMaxColsAllowed(cab); if(!Number.isFinite(maxCols)){ setWallWidthWarning(null); return; } let adjusted = false; setVisualColsInput(prev=>{ let parsed = parseInt(prev, 10); if(!Number.isFinite(parsed) || parsed < 1){ parsed = 1; } let result = parsed; if(maxCols <= 0){ if(parsed !== 1){ adjusted = true; } result = 1; } else if(parsed > maxCols){ adjusted = true; result = Math.max(1, maxCols); } return String(result); }); if(maxCols <= 0){ setWallWidthWarning("Wall width is too narrow for the selected cabinet width."); } else if(adjusted){ setWallWidthWarning("Column count adjusted to fit within the wall width."); } else { setWallWidthWarning(null); } },[manualCab, cabinetsById, getMaxColsAllowed]); const handleManualColsBlur = React.useCallback(()=>{ setManualColsInput((prev)=>{ const parsed = parseInt(prev, 10); const safeValue = Number.isFinite(parsed) && parsed > 0 ? parsed : 1; return String(safeValue); }); },[]); const handleManualRowsBlur = React.useCallback(()=>{ setManualRowsInput((prev)=>{ const parsed = parseInt(prev, 10); const safeValue = Number.isFinite(parsed) && parsed > 0 ? parsed : 1; return String(safeValue); }); },[]); const handleVisualColsBlur = React.useCallback(()=>{ const cab = cabinetsById[manualCab]; const maxCols = getMaxColsAllowed(cab); let message = null; setVisualColsInput(prev=>{ let parsed = parseInt(prev, 10); if(!Number.isFinite(parsed) || parsed < 1){ parsed = 1; } if(Number.isFinite(maxCols)){ if(maxCols <= 0){ message = "Wall width is too narrow for the selected cabinet width."; return "1"; } if(parsed > maxCols){ message = "Column count adjusted to fit within the wall width."; return String(Math.max(1, maxCols)); } } return String(parsed); }); setWallWidthWarning(message); },[cabinetsById, manualCab, getMaxColsAllowed]); const handleVisualRowsBlur = React.useCallback(()=>{ const cab = cabinetsById[manualCab]; const maxRows = getMaxRowsAllowed(cab); let message = null; let nextValue = "1"; setVisualRowsInput(prev=>{ let parsed = parseInt(prev, 10); if(!Number.isFinite(parsed) || parsed < 1){ parsed = 1; } if(Number.isFinite(maxRows)){ if(maxRows <= 0){ message = "Wall height is too low for the selected clearance and cabinet height."; nextValue = "1"; return nextValue; } if(parsed > maxRows){ message = "Row count adjusted to fit within the wall height."; nextValue = String(Math.max(1, maxRows)); return nextValue; } } nextValue = String(parsed); return nextValue; }); setVisualRowsError(message); },[cabinetsById, manualCab, getMaxRowsAllowed]); const handleVisualFloorClearanceBlur = React.useCallback(()=>{ setVisualFloorClearanceInput(prev=>{ const parsed = parseFloat(prev); if(!Number.isFinite(parsed) || parsed < 0){ return formatUnitInput(0, visualFloorClearanceUnit); } const mmValue = clamp(Math.max(0, toMM(parsed, visualFloorClearanceUnit)), 0, ROOM_LIMIT_MM); return formatUnitInput(mmValue, visualFloorClearanceUnit); }); },[visualFloorClearanceUnit]); const handleVisualWallHeightBlur = React.useCallback(()=>{ setVisualWallHeightInput(prev=>{ const parsed = parseFloat(prev); const mmValue = Number.isFinite(parsed) && parsed > 0 ? toMM(parsed, visualWallHeightUnit) : DEFAULT_WALL_HEIGHT_MM; const next = formatUnitInput(clampRoomDimension(mmValue), visualWallHeightUnit); setVisualWallHeightCommittedInput(next); return next; }); },[visualWallHeightUnit]); const handleVisualWallWidthBlur = React.useCallback(()=>{ setVisualWallWidthInput(prev=>{ const parsed = parseFloat(prev); const mmValue = Number.isFinite(parsed) && parsed > 0 ? toMM(parsed, visualWallWidthUnit) : DEFAULT_WALL_WIDTH_MM; const next = formatUnitInput(clampRoomDimension(mmValue), visualWallWidthUnit); setVisualWallWidthCommittedInput(next); return next; }); },[visualWallWidthUnit]); const adjustManualCols = React.useCallback((delta)=>{ setManualColsInput((prev)=>{ const parsed = parseInt(prev, 10); const base = Number.isFinite(parsed) && parsed > 0 ? parsed : 1; return String(Math.max(1, base + delta)); }); },[]); const adjustVisualCols = React.useCallback((delta)=>{ const cab = cabinetsById[manualCab]; const maxCols = getMaxColsAllowed(cab); let message = null; setVisualColsInput(prev=>{ const parsed = parseInt(prev, 10); const base = Number.isFinite(parsed) && parsed > 0 ? parsed : 1; let next = Math.max(1, base + delta); if(Number.isFinite(maxCols)){ if(maxCols <= 0){ message = "Wall width is too narrow for the selected cabinet width."; next = 1; } else if(next > maxCols){ if(delta > 0){ message = "Cannot add another column without exceeding the wall width."; } else if(base > maxCols){ message = "Column count adjusted to fit within the wall width."; } next = Math.max(1, maxCols); } } return String(next); }); setWallWidthWarning(message); },[cabinetsById, manualCab, getMaxColsAllowed]); const handleVisualFloorClearanceUnitChange = React.useCallback((nextUnit)=>{ setVisualFloorClearanceUnit(nextUnit); const clearanceMm = clamp(Math.max(0, visualFloorClearanceMm), 0, ROOM_LIMIT_MM); const clearanceFormatted = formatUnitInput(clearanceMm, nextUnit); setVisualFloorClearanceInput(clearanceFormatted); setVisualWallHeightUnit(nextUnit); const heightMm = clampRoomDimension(visualWallHeightMm); const heightFormatted = formatUnitInput(heightMm, nextUnit); setVisualWallHeightInput(heightFormatted); setVisualWallHeightCommittedInput(heightFormatted); setVisualWallWidthUnit(nextUnit); const widthMm = clampRoomDimension(visualWallWidthMm); const widthFormatted = formatUnitInput(widthMm, nextUnit); setVisualWallWidthInput(widthFormatted); setVisualWallWidthCommittedInput(widthFormatted); },[visualFloorClearanceMm, visualWallHeightMm, visualWallWidthMm]); const handleVisualWallHeightUnitChange = React.useCallback((nextUnit)=>{ setVisualWallHeightUnit(nextUnit); const heightMm = clampRoomDimension(visualWallHeightMm); const heightFormatted = formatUnitInput(heightMm, nextUnit); setVisualWallHeightInput(heightFormatted); setVisualWallHeightCommittedInput(heightFormatted); setVisualFloorClearanceUnit(nextUnit); const clearanceMm = clamp(Math.max(0, visualFloorClearanceMm), 0, ROOM_LIMIT_MM); setVisualFloorClearanceInput(formatUnitInput(clearanceMm, nextUnit)); setVisualWallWidthUnit(nextUnit); const widthMm = clampRoomDimension(visualWallWidthMm); const widthFormatted = formatUnitInput(widthMm, nextUnit); setVisualWallWidthInput(widthFormatted); setVisualWallWidthCommittedInput(widthFormatted); },[visualWallHeightMm, visualFloorClearanceMm, visualWallWidthMm]); const handleVisualWallWidthUnitChange = React.useCallback((nextUnit)=>{ setVisualWallWidthUnit(nextUnit); const widthMm = clampRoomDimension(visualWallWidthMm); const widthFormatted = formatUnitInput(widthMm, nextUnit); setVisualWallWidthInput(widthFormatted); setVisualWallWidthCommittedInput(widthFormatted); setVisualFloorClearanceUnit(nextUnit); const clearanceMm = clamp(Math.max(0, visualFloorClearanceMm), 0, ROOM_LIMIT_MM); setVisualFloorClearanceInput(formatUnitInput(clearanceMm, nextUnit)); setVisualWallHeightUnit(nextUnit); const heightMm = clampRoomDimension(visualWallHeightMm); const heightFormatted = formatUnitInput(heightMm, nextUnit); setVisualWallHeightInput(heightFormatted); setVisualWallHeightCommittedInput(heightFormatted); },[visualWallWidthMm, visualFloorClearanceMm, visualWallHeightMm]); const handleVisualPersonToggle = React.useCallback(()=>{ setVisualPersonEnabled((prev)=>!prev); },[]); const handleVisualScreenDesignToggle = React.useCallback(()=>{ setVisualScreenDesignCollapsed((prev)=>!prev); },[]); const handleVisualContentCollapseToggle = React.useCallback(()=>{ setVisualContentCollapsed((prev)=>!prev); },[]); const handleScreenContentSampleChange = React.useCallback((event)=>{ const selectedId = event.target ? event.target.value : ""; setScreenContentSampleId(selectedId); screenContentLoadTokenRef.current += 1; const currentToken = screenContentLoadTokenRef.current; if(!selectedId){ return; } const sample = SAMPLE_SCREEN_CONTENT_IMAGES.find((item)=>item.id === selectedId); if(!sample){ setScreenContentSampleId(""); return; } const url = resolveAssetUrl(sample.path); if(!url){ setScreenContentSampleId(""); return; } if(screenContentObjectUrlRef.current){ URL.revokeObjectURL(screenContentObjectUrlRef.current); screenContentObjectUrlRef.current = null; } const img = new Image(); img.decoding = "async"; img.onload = ()=>{ if(screenContentLoadTokenRef.current !== currentToken){ return; } const naturalWidth = img.naturalWidth || img.width || 0; const naturalHeight = img.naturalHeight || img.height || 0; setScreenContentImage({ image: img, width: naturalWidth, height: naturalHeight, name: sample.label, id: `${sample.id}-${Date.now()}`, }); setScreenContentEnabled(true); if(screenTextureBufferRef.current){ screenTextureBufferRef.current.lastKey = null; screenTextureBufferRef.current.imageData = null; } if(screenContentFileInputRef.current){ screenContentFileInputRef.current.value = ""; } }; img.onerror = ()=>{ if(screenContentLoadTokenRef.current === currentToken){ setScreenContentSampleId(""); } }; img.src = url; },[]); const handleScreenContentToggle = React.useCallback(()=>{ setScreenContentEnabled((prev)=>!prev); },[]); const handleHideCabinetGridLinesToggle = React.useCallback(()=>{ setHideCabinetGridLines((prev)=>!prev); },[]); const handleScreenContentClear = React.useCallback(()=>{ screenContentLoadTokenRef.current += 1; if(screenContentObjectUrlRef.current){ URL.revokeObjectURL(screenContentObjectUrlRef.current); screenContentObjectUrlRef.current = null; } setScreenContentImage(null); if(screenTextureBufferRef.current){ screenTextureBufferRef.current.lastKey = null; screenTextureBufferRef.current.imageData = null; } if(screenContentFileInputRef.current){ screenContentFileInputRef.current.value = ""; } setScreenContentSampleId(""); },[]); const handleScreenContentFitChange = React.useCallback((event)=>{ setScreenContentFit(event.target.value); },[]); const handleScreenContentFileChange = React.useCallback((event)=>{ const file = event.target && event.target.files ? event.target.files[0] : null; screenContentLoadTokenRef.current += 1; const currentToken = screenContentLoadTokenRef.current; if(!file){ if(screenContentObjectUrlRef.current){ URL.revokeObjectURL(screenContentObjectUrlRef.current); screenContentObjectUrlRef.current = null; } return; } const type = (file.type || "").toLowerCase(); const name = (file.name || "").toLowerCase(); const isSupported = type.startsWith("image/") ? /^(image\/(png|jpe?g|gif))$/.test(type) : /(\.png|\.jpe?g|\.jpg|\.gif)$/.test(name); if(!isSupported){ if(screenContentFileInputRef.current){ screenContentFileInputRef.current.value = ""; } if(screenContentObjectUrlRef.current){ URL.revokeObjectURL(screenContentObjectUrlRef.current); screenContentObjectUrlRef.current = null; } return; } if(screenContentObjectUrlRef.current){ URL.revokeObjectURL(screenContentObjectUrlRef.current); screenContentObjectUrlRef.current = null; } const objectUrl = URL.createObjectURL(file); screenContentObjectUrlRef.current = objectUrl; const img = new Image(); img.onload = ()=>{ if(screenContentLoadTokenRef.current !== currentToken){ URL.revokeObjectURL(objectUrl); return; } screenContentObjectUrlRef.current = null; URL.revokeObjectURL(objectUrl); const naturalWidth = img.naturalWidth || img.width || 0; const naturalHeight = img.naturalHeight || img.height || 0; setScreenContentImage({ image: img, width: naturalWidth, height: naturalHeight, name: file.name, id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, }); setScreenContentEnabled(true); setScreenContentSampleId(""); if(screenTextureBufferRef.current){ screenTextureBufferRef.current.lastKey = null; screenTextureBufferRef.current.imageData = null; } }; img.onerror = ()=>{ URL.revokeObjectURL(objectUrl); if(screenContentLoadTokenRef.current === currentToken){ screenContentObjectUrlRef.current = null; if(screenContentFileInputRef.current){ screenContentFileInputRef.current.value = ""; } } }; img.src = objectUrl; },[]); React.useEffect(()=>{ return ()=>{ if(screenContentObjectUrlRef.current){ URL.revokeObjectURL(screenContentObjectUrlRef.current); screenContentObjectUrlRef.current = null; } }; },[]); React.useEffect(()=>{ if(screenTextureBufferRef.current){ screenTextureBufferRef.current.lastKey = null; screenTextureBufferRef.current.imageData = null; } },[screenContentImage, screenContentFit]); useEffect(()=>{ let cancelled = false; const url = resolveAssetUrl("body.glb"); if(!url){ setBodyModel(null); return ()=>{ cancelled = true; }; } loadGLBModel(url, BODY_MODEL_LOAD_OPTIONS) .then((model)=>{ if(cancelled){ return; } setBodyModel(model); }) .catch((error)=>{ if(typeof console !== "undefined" && console.error){ console.error("Failed to load body visualization model", error); } if(!cancelled){ setBodyModel(null); } }); return ()=>{ cancelled = true; }; },[]); const adjustManualRows = React.useCallback((delta)=>{ setManualRowsInput((prev)=>{ const parsed = parseInt(prev, 10); const base = Number.isFinite(parsed) && parsed > 0 ? parsed : 1; return String(Math.max(1, base + delta)); }); },[]); const adjustVisualRows = React.useCallback((delta)=>{ const cab = cabinetsById[manualCab]; const maxRows = getMaxRowsAllowed(cab); const base = visualRows; let next = Math.max(1, base + delta); let message = null; if(Number.isFinite(maxRows)){ if(maxRows <= 0){ message = "Wall height is too low for the selected clearance and cabinet height."; next = 1; } else if(next > maxRows){ message = "Cannot add another row without exceeding the wall height."; next = Math.max(1, maxRows); } } setVisualRowsInput(String(next)); setVisualRowsError(message); },[visualRows, cabinetsById, manualCab, getMaxRowsAllowed]); const desiredWmm=useMemo(()=>toMM(Number(desiredW)||0,unit),[desiredW,unit]); const desiredHmm=useMemo(()=>toMM(Number(desiredH)||0,unit),[desiredH,unit]); const desiredAreaMM2=desiredWmm*desiredHmm; const desiredAreaM2=desiredAreaMM2/1_000_000; React.useEffect(()=>{ const canvas = visualCanvasEl; const viewWidth = visualViewport.width; const viewHeight = visualViewport.height; if(!canvas || !(viewWidth > 0) || !(viewHeight > 0)){ return; } const dpr = (typeof window !== "undefined" && Number.isFinite(window.devicePixelRatio)) ? window.devicePixelRatio : 1; const desiredWidth = Math.max(1, Math.round(viewWidth * dpr)); const desiredHeight = Math.max(1, Math.round(viewHeight * dpr)); if(canvas.width !== desiredWidth || canvas.height !== desiredHeight){ canvas.width = desiredWidth; canvas.height = desiredHeight; } if(canvas.style.width !== `${viewWidth}px`){ canvas.style.width = `${viewWidth}px`; } if(canvas.style.height !== `${viewHeight}px`){ canvas.style.height = `${viewHeight}px`; } const ctx = canvas.getContext("2d"); if(!ctx){ return; } ctx.setTransform(1,0,0,1,0,0); ctx.clearRect(0,0,canvas.width,canvas.height); ctx.scale(dpr, dpr); const backgroundGradient = ctx.createLinearGradient(0, 0, 0, viewHeight); backgroundGradient.addColorStop(0, "#1e293b"); backgroundGradient.addColorStop(0.55, "#0f172a"); backgroundGradient.addColorStop(1, "#020617"); ctx.fillStyle = backgroundGradient; ctx.fillRect(0,0,viewWidth,viewHeight); if(!visualData || !visualScene){ return; } const scene = visualScene; const orbit = visualOrbitState && visualOrbitState.target ? visualOrbitState : buildOrbitStateFromScene(scene); const cameraTarget = orbit.target || scene.defaultTarget; const cameraPosition = orbitStateToCameraPosition({ ...orbit, target: cameraTarget }); const camera = createCameraBasis(cameraPosition, cameraTarget, { x: 0, y: 0, z: 1 }); const viewport = { width: viewWidth, height: viewHeight }; const fovRad = Math.PI / 3; const projectPointToScreen = (point)=>{ const projected = project3DPoint(point, camera, viewport, fovRad); if(!projected){ return null; } return projected; }; const projectPolygon = (points)=>{ const mapped = points.map(projectPointToScreen); if(mapped.some((pt)=>!pt)){ return null; } return mapped; }; const drawPolygon = (points, fillStyle, strokeStyle, lineWidth = 1)=>{ if(!points || points.length === 0){ return; } ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for(let i=1;i{ if(!a || !b){ return; } ctx.save(); ctx.lineWidth = lineWidth; ctx.strokeStyle = strokeStyle; ctx.lineCap = "round"; ctx.lineJoin = "round"; if(dash && dash.length){ ctx.setLineDash(dash); } ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); ctx.restore(); }; const zoomFactor = orbit.defaultRadius ? orbit.defaultRadius / Math.max(orbit.radius, 1e-3) : 1; const scaledLineWidth = (base, minFactor = 0.25, maxFactor = 1.15)=>{ return base * clamp(zoomFactor, minFactor, maxFactor); }; const cabinetLineWidth = (base)=>{ return Math.max(0.6, base * clamp(zoomFactor, 0.35, 1.2)); }; const { screenWidth, screenHeight, clearanceMm, baseSize, floorWidth, floorDepth, wallHeight, wallWidth } = scene; const halfFloorWidth = floorWidth / 2; const halfWallWidth = wallWidth / 2; const cabinetCols = visualData.cols || 0; const cabinetRows = visualData.rows || 0; const cabinetWidth = visualData.cab ? visualData.cab.w : 0; const cabinetHeight = visualData.cab ? visualData.cab.h : 0; const frontFaceGridLinesVisible = !hideCabinetGridLines; const hasCabinetLogo = !!(cabinetLogoAsset && cabinetLogoAsset.image && cabinetLogoAsset.width > 0 && cabinetLogoAsset.height > 0); const cabinetLogoImage = hasCabinetLogo ? cabinetLogoAsset.image : null; const cabinetLogoNaturalWidth = hasCabinetLogo ? cabinetLogoAsset.width : 0; const cabinetLogoNaturalHeight = hasCabinetLogo ? cabinetLogoAsset.height : 0; const cabinetLogoAspect = hasCabinetLogo && Number.isFinite(cabinetLogoAsset.aspectRatio) && cabinetLogoAsset.aspectRatio > 0 ? cabinetLogoAsset.aspectRatio : (hasCabinetLogo && cabinetLogoNaturalHeight > 0 ? cabinetLogoNaturalWidth / cabinetLogoNaturalHeight : 1); const screenTextureEnabled = !!(screenContentEnabled && screenContentImage && screenContentImage.image && screenWidth > 0 && screenHeight > 0); let screenTextureSource = null; if(screenTextureEnabled && typeof document !== "undefined"){ const aspect = screenHeight > 0 ? screenWidth / screenHeight : 0; if(aspect > 0){ let bufferState = screenTextureBufferRef.current; if(!bufferState || !bufferState.canvas || !bufferState.ctx){ const canvasEl = document.createElement("canvas"); const bufferCtx = canvasEl.getContext("2d"); if(bufferCtx){ bufferCtx.imageSmoothingEnabled = true; bufferCtx.imageSmoothingQuality = "high"; bufferState = { canvas: canvasEl, ctx: bufferCtx, lastKey: null, width: 0, height: 0, imageData: null }; } else { bufferState = null; } screenTextureBufferRef.current = bufferState; } const buffer = screenTextureBufferRef.current; if(buffer && buffer.ctx){ const baseSize = 1024; let targetWidth = baseSize; let targetHeight = Math.max(1, Math.round(targetWidth / aspect)); if(targetHeight > baseSize){ targetHeight = baseSize; targetWidth = Math.max(1, Math.round(targetHeight * aspect)); } const fitKey = `${screenContentImage.id}|${screenContentFit}|${targetWidth}|${targetHeight}`; if(buffer.lastKey !== fitKey || !buffer.imageData){ buffer.canvas.width = targetWidth; buffer.canvas.height = targetHeight; const bctx = buffer.ctx; bctx.setTransform(1,0,0,1,0,0); bctx.clearRect(0,0,targetWidth,targetHeight); bctx.fillStyle = "#0f172a"; bctx.fillRect(0,0,targetWidth,targetHeight); const naturalW = screenContentImage.width; const naturalH = screenContentImage.height; if(naturalW > 0 && naturalH > 0){ if(screenContentFit === "stretch"){ bctx.drawImage(screenContentImage.image, 0, 0, targetWidth, targetHeight); } else if(screenContentFit === "fill"){ const scale = Math.min(targetWidth / naturalW, targetHeight / naturalH); const drawW = naturalW * scale; const drawH = naturalH * scale; const dx = (targetWidth - drawW) / 2; const dy = (targetHeight - drawH) / 2; bctx.drawImage(screenContentImage.image, dx, dy, drawW, drawH); } else { const targetAspect = targetWidth / targetHeight; const sourceAspect = naturalW / naturalH; if(sourceAspect > targetAspect){ const desiredWidth = naturalH * targetAspect; const sx = (naturalW - desiredWidth) / 2; bctx.drawImage(screenContentImage.image, sx, 0, desiredWidth, naturalH, 0, 0, targetWidth, targetHeight); } else if(sourceAspect < targetAspect){ const desiredHeight = naturalW / targetAspect; const sy = (naturalH - desiredHeight) / 2; bctx.drawImage(screenContentImage.image, 0, sy, naturalW, desiredHeight, 0, 0, targetWidth, targetHeight); } else { bctx.drawImage(screenContentImage.image, 0, 0, naturalW, naturalH, 0, 0, targetWidth, targetHeight); } } } try { buffer.imageData = bctx.getImageData(0, 0, targetWidth, targetHeight); } catch(_error){ buffer.imageData = null; } buffer.lastKey = fitKey; buffer.width = targetWidth; buffer.height = targetHeight; } screenTextureSource = buffer.canvas && buffer.imageData ? { image: buffer.canvas, width: buffer.width, height: buffer.height, imageData: buffer.imageData } : null; } } } else if(screenTextureBufferRef.current){ screenTextureBufferRef.current.lastKey = null; screenTextureBufferRef.current.imageData = null; } const floorPlane = [ { x: -halfFloorWidth, y: 0, z: 0 }, { x: halfFloorWidth, y: 0, z: 0 }, { x: halfFloorWidth, y: floorDepth, z: 0 }, { x: -halfFloorWidth, y: floorDepth, z: 0 }, ]; const ceilingPlane = [ { x: -halfFloorWidth, y: 0, z: wallHeight }, { x: halfFloorWidth, y: 0, z: wallHeight }, { x: halfFloorWidth, y: floorDepth, z: wallHeight }, { x: -halfFloorWidth, y: floorDepth, z: wallHeight }, ]; const wallPlane = [ { x: -halfWallWidth, y: 0, z: 0 }, { x: halfWallWidth, y: 0, z: 0 }, { x: halfWallWidth, y: 0, z: wallHeight }, { x: -halfWallWidth, y: 0, z: wallHeight }, ]; const leftWallPlane = [ { x: -halfWallWidth, y: 0, z: 0 }, { x: -halfWallWidth, y: floorDepth, z: 0 }, { x: -halfWallWidth, y: floorDepth, z: wallHeight }, { x: -halfWallWidth, y: 0, z: wallHeight }, ]; const rightWallPlane = [ { x: halfWallWidth, y: 0, z: 0 }, { x: halfWallWidth, y: floorDepth, z: 0 }, { x: halfWallWidth, y: floorDepth, z: wallHeight }, { x: halfWallWidth, y: 0, z: wallHeight }, ]; const wallPolygon = projectPolygon(wallPlane); if(wallPolygon){ const wallGradient = ctx.createLinearGradient( wallPolygon[3].x, wallPolygon[3].y, wallPolygon[0].x, wallPolygon[0].y ); wallGradient.addColorStop(0, "rgba(51,65,85,0.9)"); wallGradient.addColorStop(0.65, "rgba(30,41,59,0.88)"); wallGradient.addColorStop(1, "rgba(15,23,42,0.92)"); drawPolygon(wallPolygon, wallGradient, "rgba(148,163,184,0.25)", 1.2); } const floorPolygon = projectPolygon(floorPlane); if(floorPolygon){ const floorGradient = ctx.createLinearGradient( floorPolygon[0].x, floorPolygon[0].y, floorPolygon[2].x, floorPolygon[2].y ); floorGradient.addColorStop(0, "rgba(30,41,59,0.92)"); floorGradient.addColorStop(0.5, "rgba(15,23,42,0.86)"); floorGradient.addColorStop(1, "rgba(8,11,19,0.85)"); drawPolygon(floorPolygon, floorGradient, "rgba(148,163,184,0.2)", 1.1); } const ceilingPolygon = projectPolygon(ceilingPlane); if(ceilingPolygon){ const ceilingGradient = ctx.createLinearGradient( ceilingPolygon[0].x, ceilingPolygon[0].y, ceilingPolygon[2].x, ceilingPolygon[2].y ); ceilingGradient.addColorStop(0, "rgba(35,48,78,0.74)"); ceilingGradient.addColorStop(0.55, "rgba(18,27,47,0.68)"); ceilingGradient.addColorStop(1, "rgba(7,10,18,0.64)"); drawPolygon(ceilingPolygon, ceilingGradient, "rgba(148,163,184,0.26)", 1.05); } const leftWallPolygon = projectPolygon(leftWallPlane); if(leftWallPolygon){ const leftGradient = ctx.createLinearGradient( leftWallPolygon[0].x, leftWallPolygon[0].y, leftWallPolygon[1].x, leftWallPolygon[1].y ); leftGradient.addColorStop(0, "rgba(168,204,255,0.95)"); leftGradient.addColorStop(0.48, "rgba(94,134,214,0.94)"); leftGradient.addColorStop(1, "rgba(20,32,72,0.98)"); drawPolygon(leftWallPolygon, leftGradient, "rgba(234,245,255,0.52)", 1.45); const leftFrontBottom = projectPointToScreen({ x: -halfWallWidth, y: floorDepth, z: 0 }); const leftFrontTop = projectPointToScreen({ x: -halfWallWidth, y: floorDepth, z: wallHeight }); const leftBackTop = projectPointToScreen({ x: -halfWallWidth, y: 0, z: wallHeight }); const leftBackBottom = projectPointToScreen({ x: -halfWallWidth, y: 0, z: 0 }); drawLine(leftFrontBottom, leftFrontTop, "rgba(249,250,255,0.92)", scaledLineWidth(2.45, 0.55, 1.75)); drawLine(leftFrontTop, leftBackTop, "rgba(226,241,255,0.65)", scaledLineWidth(1.85, 0.45, 1.35)); drawLine(leftFrontBottom, leftBackBottom, "rgba(210,231,255,0.55)", scaledLineWidth(1.75, 0.45, 1.25)); drawLine(leftBackBottom, leftBackTop, "rgba(199,220,255,0.6)", scaledLineWidth(1.6, 0.45, 1.2)); } const rightWallPolygon = projectPolygon(rightWallPlane); if(rightWallPolygon){ const rightGradient = ctx.createLinearGradient( rightWallPolygon[0].x, rightWallPolygon[0].y, rightWallPolygon[1].x, rightWallPolygon[1].y ); rightGradient.addColorStop(0, "rgba(18,28,60,0.97)"); rightGradient.addColorStop(0.52, "rgba(52,102,189,0.93)"); rightGradient.addColorStop(1, "rgba(118,176,255,0.95)"); drawPolygon(rightWallPolygon, rightGradient, "rgba(180,220,255,0.5)", 1.45); const rightFrontBottom = projectPointToScreen({ x: halfWallWidth, y: floorDepth, z: 0 }); const rightFrontTop = projectPointToScreen({ x: halfWallWidth, y: floorDepth, z: wallHeight }); const rightBackTop = projectPointToScreen({ x: halfWallWidth, y: 0, z: wallHeight }); const rightBackBottom = projectPointToScreen({ x: halfWallWidth, y: 0, z: 0 }); drawLine(rightFrontBottom, rightFrontTop, "rgba(248,252,255,0.9)", scaledLineWidth(2.35, 0.55, 1.7)); drawLine(rightFrontTop, rightBackTop, "rgba(210,233,255,0.62)", scaledLineWidth(1.8, 0.45, 1.3)); drawLine(rightFrontBottom, rightBackBottom, "rgba(196,221,255,0.54)", scaledLineWidth(1.7, 0.45, 1.2)); drawLine(rightBackBottom, rightBackTop, "rgba(188,214,255,0.58)", scaledLineWidth(1.55, 0.45, 1.15)); } const gridSpacing = ROOM_GRID_SPACING_MM; const maxExtent = Math.max(wallWidth, wallHeight, floorDepth, gridSpacing); const maxLines = Math.max(1, Math.min(ROOM_GRID_MAX_LINES, Math.ceil(maxExtent / gridSpacing) + 2)); const sideWallSpacing = gridSpacing; if(sideWallSpacing > 0 && wallHeight > 0){ let sideLineCount = 0; for(let depth = sideWallSpacing; depth <= floorDepth && sideLineCount < maxLines; depth += sideWallSpacing){ sideLineCount += 1; const leftBottom = projectPointToScreen({ x: -halfWallWidth, y: depth, z: 0 }); const leftTop = projectPointToScreen({ x: -halfWallWidth, y: depth, z: wallHeight }); const rightBottom = projectPointToScreen({ x: halfWallWidth, y: depth, z: 0 }); const rightTop = projectPointToScreen({ x: halfWallWidth, y: depth, z: wallHeight }); drawLine(leftBottom, leftTop, "rgba(206,222,240,0.28)", Math.max(1, scaledLineWidth(1.05))); drawLine(rightBottom, rightTop, "rgba(188,210,236,0.26)", Math.max(1, scaledLineWidth(1.02))); } sideLineCount = 0; for(let height = sideWallSpacing; height <= wallHeight && sideLineCount < maxLines; height += sideWallSpacing){ sideLineCount += 1; const leftBack = projectPointToScreen({ x: -halfWallWidth, y: 0, z: height }); const leftFront = projectPointToScreen({ x: -halfWallWidth, y: floorDepth, z: height }); const rightBack = projectPointToScreen({ x: halfWallWidth, y: 0, z: height }); const rightFront = projectPointToScreen({ x: halfWallWidth, y: floorDepth, z: height }); drawLine(leftBack, leftFront, "rgba(198,216,236,0.24)", Math.max(0.95, scaledLineWidth(0.95))); drawLine(rightBack, rightFront, "rgba(178,202,226,0.22)", Math.max(0.9, scaledLineWidth(0.9))); } } let lineCount = 0; const floorXStart = Math.ceil((-halfFloorWidth) / gridSpacing) * gridSpacing; const floorXEnd = Math.floor(halfFloorWidth / gridSpacing) * gridSpacing; if(floorXStart <= floorXEnd){ for(let x = floorXStart; x <= floorXEnd && lineCount < maxLines; x += gridSpacing){ lineCount += 1; const start = projectPointToScreen({ x, y: 0, z: 0 }); const end = projectPointToScreen({ x, y: floorDepth, z: 0 }); drawLine(start, end, "rgba(160,176,198,0.2)", Math.max(0.95, scaledLineWidth(0.9))); } } lineCount = 0; for(let y = gridSpacing; y <= floorDepth && lineCount < maxLines; y += gridSpacing){ lineCount += 1; const start = projectPointToScreen({ x: -halfFloorWidth, y, z: 0 }); const end = projectPointToScreen({ x: halfFloorWidth, y, z: 0 }); drawLine(start, end, "rgba(156,172,194,0.18)", Math.max(0.9, scaledLineWidth(0.85))); } lineCount = 0; if(floorXStart <= floorXEnd){ for(let x = floorXStart; x <= floorXEnd && lineCount < maxLines; x += gridSpacing){ lineCount += 1; const start = projectPointToScreen({ x, y: 0, z: wallHeight }); const end = projectPointToScreen({ x, y: floorDepth, z: wallHeight }); drawLine(start, end, "rgba(170,186,210,0.2)", Math.max(0.85, scaledLineWidth(0.8))); } } lineCount = 0; for(let y = gridSpacing; y <= floorDepth && lineCount < maxLines; y += gridSpacing){ lineCount += 1; const start = projectPointToScreen({ x: -halfFloorWidth, y, z: wallHeight }); const end = projectPointToScreen({ x: halfFloorWidth, y, z: wallHeight }); drawLine(start, end, "rgba(168,184,208,0.18)", Math.max(0.85, scaledLineWidth(0.75))); } lineCount = 0; const wallXStart = Math.ceil((-halfWallWidth) / gridSpacing) * gridSpacing; const wallXEnd = Math.floor(halfWallWidth / gridSpacing) * gridSpacing; if(wallXStart <= wallXEnd){ for(let x = wallXStart; x <= wallXEnd && lineCount < maxLines; x += gridSpacing){ lineCount += 1; const start = projectPointToScreen({ x, y: 0, z: 0 }); const end = projectPointToScreen({ x, y: 0, z: wallHeight }); drawLine(start, end, "rgba(176,194,220,0.22)", Math.max(0.9, scaledLineWidth(0.85))); } } lineCount = 0; for(let z = gridSpacing; z <= wallHeight && lineCount < maxLines; z += gridSpacing){ lineCount += 1; const start = projectPointToScreen({ x: -halfWallWidth, y: 0, z }); const end = projectPointToScreen({ x: halfWallWidth, y: 0, z }); drawLine(start, end, "rgba(174,192,218,0.2)", Math.max(0.85, scaledLineWidth(0.8))); } const wallBaseLeft = projectPointToScreen({ x: -halfWallWidth, y: 0, z: 0 }); const wallBaseRight = projectPointToScreen({ x: halfWallWidth, y: 0, z: 0 }); drawLine(wallBaseLeft, wallBaseRight, "rgba(250,252,255,0.95)", scaledLineWidth(2.8, 0.5, 1.35)); const floorBackLeft = projectPointToScreen({ x: -halfFloorWidth, y: 0, z: 0 }); const floorBackRight = projectPointToScreen({ x: halfFloorWidth, y: 0, z: 0 }); const floorFrontLeft = projectPointToScreen({ x: -halfFloorWidth, y: floorDepth, z: 0 }); const floorFrontRight = projectPointToScreen({ x: halfFloorWidth, y: floorDepth, z: 0 }); drawLine(floorBackLeft, floorBackRight, "rgba(245,248,255,0.7)", scaledLineWidth(1.9, 0.45, 1.25)); drawLine(floorFrontLeft, floorFrontRight, "rgba(224,236,255,0.55)", scaledLineWidth(1.8, 0.45, 1.2)); drawLine(floorBackLeft, floorFrontLeft, "rgba(228,239,255,0.62)", scaledLineWidth(1.95, 0.45, 1.25)); drawLine(floorBackRight, floorFrontRight, "rgba(226,239,255,0.6)", scaledLineWidth(1.95, 0.45, 1.25)); if(wallHeight > 0){ const ceilingBackLeft = projectPointToScreen({ x: -halfWallWidth, y: 0, z: wallHeight }); const ceilingBackRight = projectPointToScreen({ x: halfWallWidth, y: 0, z: wallHeight }); const ceilingFrontLeft = projectPointToScreen({ x: -halfWallWidth, y: floorDepth, z: wallHeight }); const ceilingFrontRight = projectPointToScreen({ x: halfWallWidth, y: floorDepth, z: wallHeight }); drawLine(ceilingBackLeft, ceilingBackRight, "rgba(242,247,255,0.66)", scaledLineWidth(2, 0.45, 1.35)); drawLine(ceilingFrontLeft, ceilingFrontRight, "rgba(222,234,255,0.52)", scaledLineWidth(1.9, 0.45, 1.3)); drawLine(ceilingBackLeft, ceilingFrontLeft, "rgba(226,237,255,0.58)", scaledLineWidth(1.85, 0.45, 1.25)); drawLine(ceilingBackRight, ceilingFrontRight, "rgba(220,234,255,0.55)", scaledLineWidth(1.85, 0.45, 1.25)); } const screenHalfWidth = screenWidth / 2; const screenBottom = clearanceMm; const screenTop = clearanceMm + screenHeight; const screenBackY = SCREEN_BACK_OFFSET_MM; const screenFrontY = screenBackY + SCREEN_THICKNESS_MM; const screenFrontPlane = [ { x: -screenHalfWidth, y: screenFrontY, z: screenBottom }, { x: screenHalfWidth, y: screenFrontY, z: screenBottom }, { x: screenHalfWidth, y: screenFrontY, z: screenTop }, { x: -screenHalfWidth, y: screenFrontY, z: screenTop }, ]; const screenBackPlane = [ { x: -screenHalfWidth, y: screenBackY, z: screenBottom }, { x: screenHalfWidth, y: screenBackY, z: screenBottom }, { x: screenHalfWidth, y: screenBackY, z: screenTop }, { x: -screenHalfWidth, y: screenBackY, z: screenTop }, ]; const screenLeftPlane = [ { x: -screenHalfWidth, y: screenBackY, z: screenBottom }, { x: -screenHalfWidth, y: screenFrontY, z: screenBottom }, { x: -screenHalfWidth, y: screenFrontY, z: screenTop }, { x: -screenHalfWidth, y: screenBackY, z: screenTop }, ]; const screenRightPlane = [ { x: screenHalfWidth, y: screenBackY, z: screenBottom }, { x: screenHalfWidth, y: screenFrontY, z: screenBottom }, { x: screenHalfWidth, y: screenFrontY, z: screenTop }, { x: screenHalfWidth, y: screenBackY, z: screenTop }, ]; const screenTopPlane = [ { x: -screenHalfWidth, y: screenBackY, z: screenTop }, { x: screenHalfWidth, y: screenBackY, z: screenTop }, { x: screenHalfWidth, y: screenFrontY, z: screenTop }, { x: -screenHalfWidth, y: screenFrontY, z: screenTop }, ]; const screenBottomPlane = [ { x: -screenHalfWidth, y: screenBackY, z: screenBottom }, { x: screenHalfWidth, y: screenBackY, z: screenBottom }, { x: screenHalfWidth, y: screenFrontY, z: screenBottom }, { x: -screenHalfWidth, y: screenFrontY, z: screenBottom }, ]; const screenFrontPolygon = projectPolygon(screenFrontPlane); const screenBackPolygon = projectPolygon(screenBackPlane); const screenLeftPolygon = projectPolygon(screenLeftPlane); const screenRightPolygon = projectPolygon(screenRightPlane); const screenTopPolygon = projectPolygon(screenTopPlane); const screenBottomPolygon = projectPolygon(screenBottomPlane); const renderScreen = (isBackView = false)=>{ const faceRenderers = []; const visibleFaces = new Set(); const computeCenter3D = (points)=>{ if(!points || !points.length){ return { x: 0, y: 0, z: 0 }; } let sumX = 0; let sumY = 0; let sumZ = 0; for(let i = 0; i < points.length; i += 1){ sumX += points[i].x; sumY += points[i].y; sumZ += points[i].z; } const inv = 1 / points.length; return { x: sumX * inv, y: sumY * inv, z: sumZ * inv }; }; const addFace = (name, polygon, planePoints, normal, drawFace)=>{ if(!polygon || !planePoints){ return; } const center = computeCenter3D(planePoints); const faceToCamera = vec3Sub(camera.position, center); if(vec3Dot(normal, faceToCamera) <= 1e-3){ return; } const avgDepth = polygon.reduce((sum, point)=>sum + (point.depth || 0), 0) / polygon.length; faceRenderers.push({ name, avgDepth, draw: ()=>drawFace(polygon) }); visibleFaces.add(name); }; const drawCabinetSeam = (startPoint, endPoint, strokeStyle, strokeWidth)=>{ const start = projectPointToScreen(startPoint); const end = projectPointToScreen(endPoint); if(start && end){ drawLine(start, end, strokeStyle, strokeWidth); } }; const drawLogoQuad = (topLeft, topRight, bottomLeft, bottomRight)=>{ if(!cabinetLogoImage || cabinetLogoNaturalWidth <= 0 || cabinetLogoNaturalHeight <= 0){ return; } if(!topLeft || !topRight || !bottomLeft || !bottomRight){ return; } ctx.save(); ctx.beginPath(); ctx.moveTo(topLeft.x, topLeft.y); ctx.lineTo(topRight.x, topRight.y); ctx.lineTo(bottomRight.x, bottomRight.y); ctx.lineTo(bottomLeft.x, bottomLeft.y); ctx.closePath(); ctx.clip(); ctx.transform( (topLeft.x - topRight.x) / cabinetLogoNaturalWidth, (topLeft.y - topRight.y) / cabinetLogoNaturalWidth, (bottomRight.x - topRight.x) / cabinetLogoNaturalHeight, (bottomRight.y - topRight.y) / cabinetLogoNaturalHeight, topRight.x, topRight.y ); ctx.drawImage(cabinetLogoImage, 0, 0); ctx.restore(); }; addFace("back", screenBackPolygon, screenBackPlane, { x: 0, y: -1, z: 0 }, (polygon)=>{ const metallicBackGradient = ctx.createLinearGradient( polygon[3].x, polygon[3].y, polygon[1].x, polygon[1].y ); metallicBackGradient.addColorStop(0, "rgba(12,13,15,1)"); metallicBackGradient.addColorStop(0.35, "rgba(28,30,34,1)"); metallicBackGradient.addColorStop(0.65, "rgba(45,47,52,1)"); metallicBackGradient.addColorStop(1, "rgba(11,12,14,1)"); drawPolygon(polygon, metallicBackGradient, "#4b5563", 1.35); const metallicSheen = ctx.createLinearGradient( polygon[0].x, polygon[0].y, polygon[1].x, polygon[1].y ); metallicSheen.addColorStop(0, "rgba(255,255,255,0.05)"); metallicSheen.addColorStop(0.5, "rgba(255,255,255,0.12)"); metallicSheen.addColorStop(1, "rgba(255,255,255,0.04)"); drawPolygon(polygon, metallicSheen); if(isBackView && hasCabinetLogo && cabinetCols > 0 && cabinetRows > 0 && cabinetWidth > 0 && cabinetHeight > 0){ const maxLogoWidthMm = Math.min(CABINET_LOGO_MAX_SIZE_MM, cabinetWidth); const maxLogoHeightMm = Math.min(CABINET_LOGO_MAX_SIZE_MM, cabinetHeight); if(maxLogoWidthMm > 0 && maxLogoHeightMm > 0){ const aspect = Number.isFinite(cabinetLogoAspect) && cabinetLogoAspect > 0 ? cabinetLogoAspect : 1; let logoWidthMm = maxLogoWidthMm; let logoHeightMm = logoWidthMm / aspect; if(!Number.isFinite(logoHeightMm) || logoHeightMm <= 0 || logoHeightMm > maxLogoHeightMm){ logoHeightMm = maxLogoHeightMm; logoWidthMm = logoHeightMm * aspect; } if(!Number.isFinite(logoWidthMm) || logoWidthMm <= 0 || logoWidthMm > maxLogoWidthMm){ logoWidthMm = maxLogoWidthMm; logoHeightMm = logoWidthMm / aspect; } if(logoWidthMm > 0 && logoHeightMm > 0){ const halfLogoWidthMm = logoWidthMm / 2; const halfLogoHeightMm = logoHeightMm / 2; for(let rowIndex = 0; rowIndex < cabinetRows; rowIndex += 1){ const centerZ = screenBottom + cabinetHeight * (rowIndex + 0.5); for(let colIndex = 0; colIndex < cabinetCols; colIndex += 1){ const centerX = -screenHalfWidth + cabinetWidth * (colIndex + 0.5); const topLeft = projectPointToScreen({ x: centerX - halfLogoWidthMm, y: screenBackY, z: centerZ + halfLogoHeightMm }); const topRight = projectPointToScreen({ x: centerX + halfLogoWidthMm, y: screenBackY, z: centerZ + halfLogoHeightMm }); const bottomLeft = projectPointToScreen({ x: centerX - halfLogoWidthMm, y: screenBackY, z: centerZ - halfLogoHeightMm }); const bottomRight = projectPointToScreen({ x: centerX + halfLogoWidthMm, y: screenBackY, z: centerZ - halfLogoHeightMm }); if(topLeft && topRight && bottomLeft && bottomRight){ drawLogoQuad(topLeft, topRight, bottomLeft, bottomRight); } } } } } } }); addFace("left", screenLeftPolygon, screenLeftPlane, { x: -1, y: 0, z: 0 }, (polygon)=>{ const leftGradient = ctx.createLinearGradient( polygon[0].x, polygon[0].y, polygon[1].x, polygon[1].y ); leftGradient.addColorStop(0, "rgba(14,15,18,1)"); leftGradient.addColorStop(0.45, "rgba(34,35,40,1)"); leftGradient.addColorStop(1, "rgba(10,11,13,1)"); drawPolygon(polygon, leftGradient, "#4b5563", 1.2); }); addFace("right", screenRightPolygon, screenRightPlane, { x: 1, y: 0, z: 0 }, (polygon)=>{ const rightGradient = ctx.createLinearGradient( polygon[0].x, polygon[0].y, polygon[1].x, polygon[1].y ); rightGradient.addColorStop(0, "rgba(32,34,38,1)"); rightGradient.addColorStop(0.5, "rgba(18,19,23,1)"); rightGradient.addColorStop(1, "rgba(8,9,11,1)"); drawPolygon(polygon, rightGradient, "#4b5563", 1.2); }); addFace("top", screenTopPolygon, screenTopPlane, { x: 0, y: 0, z: 1 }, (polygon)=>{ const topGradient = ctx.createLinearGradient( polygon[0].x, polygon[0].y, polygon[2].x, polygon[2].y ); topGradient.addColorStop(0, "rgba(26,27,31,1)"); topGradient.addColorStop(0.55, "rgba(18,19,23,1)"); topGradient.addColorStop(1, "rgba(9,10,12,1)"); drawPolygon(polygon, topGradient, "#4b5563", 1); }); addFace("bottom", screenBottomPolygon, screenBottomPlane, { x: 0, y: 0, z: -1 }, (polygon)=>{ const bottomGradient = ctx.createLinearGradient( polygon[0].x, polygon[0].y, polygon[2].x, polygon[2].y ); bottomGradient.addColorStop(0, "rgba(10,11,13,1)"); bottomGradient.addColorStop(0.55, "rgba(22,23,27,1)"); bottomGradient.addColorStop(1, "rgba(30,32,36,1)"); drawPolygon(polygon, bottomGradient, "#4b5563", 1); }); if(!isBackView){ addFace("front", screenFrontPolygon, screenFrontPlane, { x: 0, y: 1, z: 0 }, (polygon)=>{ ctx.save(); ctx.shadowColor = "rgba(15,23,42,0.78)"; ctx.shadowBlur = 24; ctx.shadowOffsetY = 18; drawPolygon(polygon, "rgba(24,27,33,1)"); ctx.restore(); const screenGradient = ctx.createLinearGradient( polygon[3].x, polygon[3].y, polygon[1].x, polygon[1].y ); screenGradient.addColorStop(0, "rgba(70,76,86,1)"); screenGradient.addColorStop(0.5, "rgba(54,59,67,1)"); screenGradient.addColorStop(1, "rgba(36,41,48,1)"); drawPolygon(polygon, screenGradient, "#6b7280", 1.6); if(screenTextureSource && screenTextureSource.imageData && screenTextureSource.width > 0 && screenTextureSource.height > 0){ const bottomLeft = polygon[0]; const bottomRight = polygon[1]; const topRight = polygon[2]; const topLeft = polygon[3]; if(bottomLeft && bottomRight && topRight && topLeft){ const textureWidth = screenTextureSource.width; const textureHeight = screenTextureSource.height; const sourceImageData = screenTextureSource.imageData; const baseMatrix = typeof ctx.getTransform === "function" ? ctx.getTransform() : { a: dpr, b: 0, c: 0, d: dpr, e: 0, f: 0 }; const scaleX = baseMatrix.a; const scaleY = baseMatrix.d; const offsetX = baseMatrix.e || 0; const offsetY = baseMatrix.f || 0; const destinationQuad = [ { x: topLeft.x * scaleX + offsetX, y: topLeft.y * scaleY + offsetY }, { x: topRight.x * scaleX + offsetX, y: topRight.y * scaleY + offsetY }, { x: bottomRight.x * scaleX + offsetX, y: bottomRight.y * scaleY + offsetY }, { x: bottomLeft.x * scaleX + offsetX, y: bottomLeft.y * scaleY + offsetY }, ]; const sourceQuad = [ { x: 0, y: 0 }, { x: textureWidth, y: 0 }, { x: textureWidth, y: textureHeight }, { x: 0, y: textureHeight }, ]; const homography = computeHomographyMatrix(sourceQuad, destinationQuad); const inverseHomography = homography ? invertHomographyMatrix(homography) : null; if(homography && inverseHomography){ const canvasWidth = ctx.canvas.width; const canvasHeight = ctx.canvas.height; const minX = Math.max(0, Math.floor(Math.min(destinationQuad[0].x, destinationQuad[1].x, destinationQuad[2].x, destinationQuad[3].x))); const maxX = Math.min(canvasWidth, Math.ceil(Math.max(destinationQuad[0].x, destinationQuad[1].x, destinationQuad[2].x, destinationQuad[3].x))); const minY = Math.max(0, Math.floor(Math.min(destinationQuad[0].y, destinationQuad[1].y, destinationQuad[2].y, destinationQuad[3].y))); const maxY = Math.min(canvasHeight, Math.ceil(Math.max(destinationQuad[0].y, destinationQuad[1].y, destinationQuad[2].y, destinationQuad[3].y))); const destWidth = maxX - minX; const destHeight = maxY - minY; const sourceData = sourceImageData.data || sourceImageData; const srcWidth = sourceImageData.width || textureWidth; const srcHeight = sourceImageData.height || textureHeight; if(destWidth > 0 && destHeight > 0 && sourceData && srcWidth > 0 && srcHeight > 0){ ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); const output = ctx.getImageData(minX, minY, destWidth, destHeight); ctx.restore(); const outputData = output.data; const epsilon = 1e-3; for(let yIndex = 0; yIndex < destHeight; yIndex += 1){ const deviceY = minY + yIndex + 0.5; for(let xIndex = 0; xIndex < destWidth; xIndex += 1){ const deviceX = minX + xIndex + 0.5; if(!isPointInConvexPolygon({ x: deviceX, y: deviceY }, destinationQuad)){ continue; } const denom = inverseHomography[6] * deviceX + inverseHomography[7] * deviceY + inverseHomography[8]; if(Math.abs(denom) <= 1e-12){ continue; } const sourceX = (inverseHomography[0] * deviceX + inverseHomography[1] * deviceY + inverseHomography[2]) / denom; const sourceY = (inverseHomography[3] * deviceX + inverseHomography[4] * deviceY + inverseHomography[5]) / denom; if(sourceX >= -epsilon && sourceX <= srcWidth - 1 + epsilon && sourceY >= -epsilon && sourceY <= srcHeight - 1 + epsilon){ const [r, g, b, a] = bilinearSample(sourceData, srcWidth, srcHeight, sourceX, sourceY); const offset = (yIndex * destWidth + xIndex) * 4; outputData[offset] = r; outputData[offset + 1] = g; outputData[offset + 2] = b; outputData[offset + 3] = a; } } } ctx.save(); ctx.setTransform(1,0,0,1,0,0); ctx.putImageData(output, minX, minY); ctx.restore(); } } } } const highlightGradient = ctx.createRadialGradient( (polygon[0].x + polygon[1].x) / 2, (polygon[0].y + polygon[3].y) / 2, Math.max(12, Math.abs(polygon[0].x - polygon[1].x) * 0.05), (polygon[0].x + polygon[1].x) / 2, (polygon[0].y + polygon[3].y) / 2, Math.max(40, Math.abs(polygon[0].x - polygon[1].x) * 0.75) ); highlightGradient.addColorStop(0, "rgba(148,163,184,0.22)"); highlightGradient.addColorStop(1, "rgba(36,42,52,0)"); drawPolygon(polygon, highlightGradient); }); } faceRenderers.sort((a, b)=>b.avgDepth - a.avgDepth); for(let i = 0; i < faceRenderers.length; i += 1){ faceRenderers[i].draw(); } const frontFaceName = isBackView ? "back" : "front"; const faceY = isBackView ? screenBackY : screenFrontY; const columnStrokeColor = isBackView ? "rgba(156,163,175,0.55)" : "rgba(203,213,245,0.45)"; const rowStrokeColor = isBackView ? "rgba(156,163,175,0.55)" : "rgba(203,213,245,0.45)"; const shouldRenderFacingGridLines = (frontFaceGridLinesVisible || isBackView) && visibleFaces.has(frontFaceName); if(shouldRenderFacingGridLines){ if(cabinetCols > 1 && cabinetWidth > 0){ const columnStrokeWidth = cabinetLineWidth(1.05); for(let colIndex = 1; colIndex < cabinetCols; colIndex += 1){ const x = -screenHalfWidth + cabinetWidth * colIndex; drawCabinetSeam( { x, y: faceY, z: screenBottom }, { x, y: faceY, z: screenTop }, columnStrokeColor, columnStrokeWidth ); } } if(cabinetRows > 1 && cabinetHeight > 0){ const rowStrokeWidth = cabinetLineWidth(1.05); for(let rowIndex = 1; rowIndex < cabinetRows; rowIndex += 1){ const z = screenBottom + cabinetHeight * rowIndex; drawCabinetSeam( { x: -screenHalfWidth, y: faceY, z }, { x: screenHalfWidth, y: faceY, z }, rowStrokeColor, rowStrokeWidth ); } } } const sideRowStrokeColor = isBackView ? "rgba(156,163,175,0.5)" : "rgba(203,213,225,0.42)"; if(visibleFaces.has("left") && cabinetRows > 1 && cabinetHeight > 0){ const sideRowWidth = cabinetLineWidth(0.95); for(let rowIndex = 1; rowIndex < cabinetRows; rowIndex += 1){ const z = screenBottom + cabinetHeight * rowIndex; drawCabinetSeam( { x: -screenHalfWidth, y: screenBackY, z }, { x: -screenHalfWidth, y: screenFrontY, z }, sideRowStrokeColor, sideRowWidth ); } } if(visibleFaces.has("right") && cabinetRows > 1 && cabinetHeight > 0){ const sideRowWidth = cabinetLineWidth(0.95); for(let rowIndex = 1; rowIndex < cabinetRows; rowIndex += 1){ const z = screenBottom + cabinetHeight * rowIndex; drawCabinetSeam( { x: screenHalfWidth, y: screenBackY, z }, { x: screenHalfWidth, y: screenFrontY, z }, sideRowStrokeColor, sideRowWidth ); } } const topColumnStrokeColor = isBackView ? "rgba(156,163,175,0.5)" : "rgba(211,219,235,0.4)"; if(visibleFaces.has("top") && cabinetCols > 1 && cabinetWidth > 0){ const topColumnWidth = cabinetLineWidth(0.9); for(let colIndex = 1; colIndex < cabinetCols; colIndex += 1){ const x = -screenHalfWidth + cabinetWidth * colIndex; drawCabinetSeam( { x, y: screenBackY, z: screenTop }, { x, y: screenFrontY, z: screenTop }, topColumnStrokeColor, topColumnWidth ); } } const bottomColumnStrokeColor = isBackView ? "rgba(140,148,160,0.48)" : "rgba(198,206,220,0.4)"; if(visibleFaces.has("bottom") && cabinetCols > 1 && cabinetWidth > 0){ const bottomColumnWidth = cabinetLineWidth(0.9); for(let colIndex = 1; colIndex < cabinetCols; colIndex += 1){ const x = -screenHalfWidth + cabinetWidth * colIndex; drawCabinetSeam( { x, y: screenBackY, z: screenBottom }, { x, y: screenFrontY, z: screenBottom }, bottomColumnStrokeColor, bottomColumnWidth ); } } }; const screenNormal = { x: 0, y: 1, z: 0 }; const cameraFacingDot = vec3Dot(camera.forward, screenNormal); const cameraBehindScreen = cameraFacingDot > 0.05; if(!cameraBehindScreen){ renderScreen(false); } if(visualPersonEnabled && bodyModel && bodyModel.triangles && bodyModel.triangles.length){ let lightDirection = bodyModel.lightDirection; if(!lightDirection){ lightDirection = { x: 0, y: 0, z: 1 }; } const lightLength = vec3Length(lightDirection); const normalizedLight = lightLength > 1e-6 ? vec3Normalize(lightDirection) : { x: 0, y: 0, z: 1 }; const bodyTriangles = []; for(let i=0;i!pt)){ continue; } const depthAvg = (projected[0].depth + projected[1].depth + projected[2].depth) / 3; const normal = tri.normal || { x: 0, y: 0, z: 1 }; const shade = clamp((vec3Dot(normal, normalizedLight) + 1) / 2, 0, 1); bodyTriangles.push({ points: projected, depth: depthAvg, shade }); } bodyTriangles.sort((a,b)=>b.depth - a.depth); const strokeWidth = scaledLineWidth(0.95, 0.4, 1.1); bodyTriangles.forEach((tri)=>{ const fillColor = mixRgba(BODY_MODEL_FILL_DARK, BODY_MODEL_FILL_LIGHT, tri.shade, 0.9); const strokeColor = mixRgba(BODY_MODEL_STROKE_DARK, BODY_MODEL_STROKE_LIGHT, tri.shade, 0.85); drawPolygon(tri.points, fillColor, strokeColor, strokeWidth); }); } if(cameraBehindScreen){ renderScreen(true); } const clearanceStart = projectPointToScreen({ x: 0, y: screenFrontY, z: screenBottom }); const clearanceEnd = projectPointToScreen({ x: 0, y: 0, z: 0 }); if(clearanceStart && clearanceEnd){ drawLine(clearanceStart, clearanceEnd, "rgba(226,232,240,0.55)", scaledLineWidth(1.2, 0.35, 1.05), [6,6]); const markerSize = 6; const markerTop = projectPolygon([ { x: -markerSize, y: 0, z: 0 }, { x: markerSize, y: 0, z: 0 }, { x: markerSize, y: 0, z: markerSize }, { x: -markerSize, y: 0, z: markerSize }, ]); if(markerTop){ drawPolygon(markerTop, "rgba(148,163,184,0.25)", "rgba(226,232,240,0.4)", 0.8); } } },[visualData, visualViewport.width, visualViewport.height, visualScene, visualOrbitState, bodyModel, visualPersonEnabled, cabinetLogoAsset, visualCanvasEl, screenContentEnabled, screenContentImage, screenContentFit, hideCabinetGridLines]); const visualClearanceLabel = React.useMemo(()=>{ const mm = Math.max(0, visualFloorClearanceMm); const meters = fromMM(mm, "m"); const feet = fromMM(mm, "ft"); return `${fmt(meters,3)} m (${fmtFixed(feet,1)} ft)`; },[visualFloorClearanceMm]); const visualOverallHeightLabel = React.useMemo(()=>{ if(!visualData){ return null; } const screenHeightMm = Number.isFinite(visualData.heightMm) ? visualData.heightMm : 0; const clearanceMm = Math.max(0, visualFloorClearanceMm); const totalMm = clearanceMm + screenHeightMm; const meters = fromMM(totalMm, "m"); const feet = fromMM(totalMm, "ft"); return `${fmt(meters,3)} m (${fmtFixed(feet,1)} ft)`; },[visualData, visualFloorClearanceMm]); function buildColumn(cabId,pitch){ const cab=cabinetsById[cabId]; if(!cab) return null; const grid=computeGrid(desiredWmm,desiredHmm,cab.w,cab.h,fitMode); const actualWmm=grid.cols*cab.w; const actualHmm=grid.rows*cab.h; const actualAreaMM2=actualWmm*actualHmm; const actualAreaM2=actualAreaMM2/1_000_000; const diagIn=diagonalInches(actualWmm,actualHmm); const tile=getCabinetTileResolution(cabId,pitch); let resX,resY; if(tile){ resX=tile.x*grid.cols; resY=tile.y*grid.rows; } else { resX=Math.round(actualWmm/pitch); resY=Math.round(actualHmm/pitch); } const deltaWmm=actualWmm-desiredWmm; const deltaHmm=actualHmm-desiredHmm; const deltaWpct=desiredWmm>0?(deltaWmm/desiredWmm)*100:0; const deltaHpct=desiredHmm>0?(deltaHmm/desiredHmm)*100:0; const matchPct=desiredAreaMM2>0?(actualAreaMM2/desiredAreaMM2)*100:0; const totalPixels=resX*resY; const pixelDensityMpixPerSqm = actualAreaM2>0 ? (totalPixels/actualAreaM2)/1_000_000 : null; const totalCabinets = grid.cols * grid.rows; const totalPixelsMP = totalPixels / 1_000_000; const processor = computeProcessorRecommendation(PROCESSOR_CATALOG, totalPixels, resX, resY); return { cab, pitch, grid, actualWmm, actualHmm, actualAreaMM2, actualAreaM2, diagIn, resX, resY, deltaWmm, deltaHmm, deltaWpct, deltaHpct, matchPct, totalPixels, pixelDensityMpixPerSqm, totalCabinets, totalPixelsMP, processor }; } React.useEffect(()=>{ setPitchA(prev=>ensurePitchSupported(prev,getSupportedPitches(cabA))); },[cabA]); React.useEffect(()=>{ setPitchB(prev=>ensurePitchSupported(prev,getSupportedPitches(cabB))); },[cabB]); React.useEffect(()=>{ setPitchC(prev=>ensurePitchSupported(prev,getSupportedPitches(cabC))); },[cabC]); const colA=React.useMemo(()=>buildColumn(cabA,pitchA),[cabA,pitchA,desiredWmm,desiredHmm,fitMode]); const colB=React.useMemo(()=>buildColumn(cabB,pitchB),[cabB,pitchB,desiredWmm,desiredHmm,fitMode]); const colC=React.useMemo(()=>buildColumn(cabC,pitchC),[cabC,pitchC,desiredWmm,desiredHmm,fitMode]); const displayUnit=unit; const designSignature = React.useCallback((data)=>{ if(!data || !data.cab || !data.grid) return null; const cabId = data.cab.id || ""; const pitchVal = Number.isFinite(data.pitch) ? data.pitch : 0; const cols = data.grid && Number.isFinite(data.grid.cols) ? data.grid.cols : 0; const rows = data.grid && Number.isFinite(data.grid.rows) ? data.grid.rows : 0; const widthKey = Number.isFinite(data.actualWmm) ? Math.round(data.actualWmm) : 0; const heightKey = Number.isFinite(data.actualHmm) ? Math.round(data.actualHmm) : 0; const resX = Number.isFinite(data.resX) ? data.resX : 0; const resY = Number.isFinite(data.resY) ? data.resY : 0; return [cabId, pitchVal, cols, rows, widthKey, heightKey, resX, resY].join("|"); },[]); const sigA = designSignature(colA); const sigB = designSignature(colB); const sigC = designSignature(colC); React.useEffect(()=>{ const mapping = { A: sigA, B: sigB, C: sigC }; const last = lastDesignRef.current; if(!designInitRef.current){ lastDesignRef.current = { ...mapping }; designInitRef.current = true; return; } let delta = 0; const next = { ...last }; ["A","B","C"].forEach((key)=>{ const sig = mapping[key]; if(sig !== last[key]){ next[key] = sig; if(sig){ delta += 1; } } }); lastDesignRef.current = next; if(delta>0){ incrementCounter(delta); } },[sigA,sigB,sigC,incrementCounter]); function HeaderSelect({value,onChange}){ return ( ); } function PitchSelect({value,onChange,options}){ return ( ); } function cellData(data, field){ if(!data) return "—"; const { pitch, grid, actualWmm, actualHmm, actualAreaM2, diagIn, resX, resY, deltaWmm, deltaHmm, deltaWpct, deltaHpct, matchPct, totalPixels, pixelDensityMpixPerSqm, totalCabinets, processor, totalPixelsMP } = data; const actualW=fromMM(actualWmm,displayUnit); const actualH=fromMM(actualHmm,displayUnit); const actualWft=fromMM(actualWmm,"ft"); const actualHft=fromMM(actualHmm,"ft"); const actualAreaFt2=actualWft * actualHft; switch(field){ case "layout": return `${grid.cols} × ${grid.rows}`; case "totalCabinets": { if(!Number.isFinite(totalCabinets)){ return "—"; } return `${totalCabinets.toLocaleString()} cabinets`; } case "dimension": return `${fmt(actualW,3)} ${displayUnit} × ${fmt(actualH,3)} ${displayUnit}`; case "diag": return `${Math.ceil(diagIn)} in`; case "res": return `${resX.toLocaleString()} × ${resY.toLocaleString()}`; case "aspect": return formatAspect(resX,resY); case "area": return `${fmt(actualAreaM2,3)} m² (${fmt(actualAreaFt2,3)} ft²)`; case "pixels": return formatPixelsWithStandard(totalPixels); case "pixelDensity": return Number.isFinite(pixelDensityMpixPerSqm) ? `${fmt(pixelDensityMpixPerSqm,3)} Mpix/sqm` : "—"; case "minViewingDistance": { const minViewingDistanceM = ceilToDecimals(pitch,1); const minViewingDistanceFt = ceilToDecimals(pitch * FT_PER_M,0); if(!Number.isFinite(minViewingDistanceM) || !Number.isFinite(minViewingDistanceFt)){ return "—"; } const minViewingDistanceMLabel = fmtFixed(minViewingDistanceM,1); const minViewingDistanceFtLabel = minViewingDistanceFt.toLocaleString(); return `> ${minViewingDistanceMLabel} m (> ${minViewingDistanceFtLabel} ft)`; } case "processor": { if(processor && processor.summary){ return processor.summary; } if(Number.isFinite(totalPixelsMP)){ return `${fmt(totalPixelsMP, 2)} MP load | Recommended: None available`; } if(Number.isFinite(totalPixels)){ return `${fmt(totalPixels / 1_000_000, 2)} MP load | Recommended: None available`; } return "—"; } case "match": return `${fmt(matchPct,2)} %`; case "dW": return `${fmt(deltaWmm,0)} mm (${fmt(deltaWpct,2)}%)`; case "dH": return `${fmt(deltaHmm,0)} mm (${fmt(deltaHpct,2)}%)`; case "pitchDisplay": case "pitchLabel": return fmtPitch(pitch); default: return "—"; } } const resTarget = React.useMemo(()=>{ const choice = RES_CHOICES.find(r=>r.id===desiredRes); return choice && choice.target ? choice.target : null; },[desiredRes]); const bestPitches = React.useMemo(()=>{ if(!resTarget) return null; const meetsTarget=(data)=>{ if(!data) return false; return data.resX>=resTarget.x && data.resY>=resTarget.y; }; const compute=(cabId)=>{ const rawOptions=getSupportedPitches(cabId); if(!rawOptions.length) return null; const options=rawOptions.slice().sort((a,b)=>a-b); let largestMeeting=null; let fallbackPitch=null; let fallbackScore=Infinity; options.forEach((pitch)=>{ const data=buildColumn(cabId,pitch); if(!data) return; if(meetsTarget(data)){ if(largestMeeting==null || pitch>largestMeeting){ largestMeeting=pitch; } } const score=resScore(data.resX,data.resY,resTarget); if(score{ manualPitchRef.current.A = true; setPitchA(value); },[]); const handlePitchBChange = React.useCallback((value)=>{ manualPitchRef.current.B = true; setPitchB(value); },[]); const handlePitchCChange = React.useCallback((value)=>{ manualPitchRef.current.C = true; setPitchC(value); },[]); React.useEffect(()=>{ manualPitchRef.current = { A: false, B: false, C: false }; },[desiredRes,desiredWmm,desiredHmm,fitMode]); React.useEffect(()=>{ manualPitchRef.current.A = false; },[cabA]); React.useEffect(()=>{ manualPitchRef.current.B = false; },[cabB]); React.useEffect(()=>{ manualPitchRef.current.C = false; },[cabC]); React.useEffect(()=>{ if(!bestPitches) return; const ensurePitch=(key,recommended,setPitch,currentPitch,columnData)=>{ if(recommended==null || !Number.isFinite(recommended) || !resTarget) return; const meetsTarget = columnData && columnData.resX>=resTarget.x && columnData.resY>=resTarget.y; if(manualPitchRef.current[key] && meetsTarget){ return; } manualPitchRef.current[key] = false; if(!Number.isFinite(currentPitch) || Math.abs(currentPitch-recommended)>1e-9){ setPitch(recommended); } }; ensurePitch("A",bestPitches.A,setPitchA,pitchA,colA); ensurePitch("B",bestPitches.B,setPitchB,pitchB,colB); ensurePitch("C",bestPitches.C,setPitchC,pitchC,colC); },[bestPitches,resTarget,pitchA,pitchB,pitchC,colA,colB,colC]); const screenInputRows = React.useMemo(()=>([ { label: "Cabinet Size", field: "cabinetSelect" }, { label: "Pixel Pitch", field: "pitchSelect" }, ]),[]); const screenMetricRows = React.useMemo(()=>([ { label: "Cabinet Layout (C×R)", field: "layout" }, { label: "Total Cabinets", field: "totalCabinets" }, { label: "Actual Screen Dimension", field: "dimension" }, { label: "Actual Screen Area", field: "area" }, { label: "Screen Diagonal (in)", field: "diag" }, { label: "Pixel Pitch", field: "pitchDisplay" }, { label: "Screen Resolution", field: "res" }, { label: "Aspect Ratio", field: "aspect" }, { label: "Total Pixels", field: "pixels" }, { label: "Pixel Density", field: "pixelDensity" }, { label: "Processor Recommendation", field: "processor" }, { label: "Minimum Viewing Distance", field: "minViewingDistance" }, { label: "Δ Width vs Desired", field: "dW" }, { label: "Δ Height vs Desired", field: "dH" }, { label: "Matching Percent (area)", field: "match" }, ]),[]); const screenTableRows = React.useMemo(()=>([ ...screenInputRows, ...screenMetricRows, ]),[screenInputRows,screenMetricRows]); function bestIndex(){ if(!resTarget) return -1; const scores=[colA,colB,colC].map(c=>c?resScore(c.resX,c.resY,resTarget):Infinity); let min=Infinity,idx=-1; scores.forEach((s,i)=>{ if(s0 && desiredHNum>0){ rows.push({ label: "Desired Dimensions", value: `${fmt(desiredWNum,3)} ${unit} × ${fmt(desiredHNum,3)} ${unit}`, }); } const fitChoice=FIT_MODES.find((mode)=>mode.id===fitMode); if(fitChoice && fitChoice.label){ rows.push({ label: "Fit Strategy", value: fitChoice.label }); } const resChoice=RES_CHOICES.find((choice)=>choice.id===desiredRes); if(resChoice && resChoice.label){ rows.push({ label: "Desired Resolution", value: resChoice.label }); } return rows; } function buildScreenSharePayload(label, cabId, pitch, data){ if(!data){ return null; } const cabLabel=(cabinetsById[cabId]||{}).label||cabId; const rows=[ { label: "Cabinet Size", value: cabLabel }, { label: "Cabinet Layout (C×R)", value: cellData(data,"layout") }, { label: "Total Cabinets", value: cellData(data,"totalCabinets") }, { label: "Actual Screen Dimension", value: cellData(data,"dimension") }, { label: "Actual Screen Area", value: cellData(data,"area") }, { label: "Screen Diagonal (in)", value: cellData(data,"diag") }, { label: "Pixel Pitch", value: fmtPitch(pitch) }, { label: "Screen Resolution", value: cellData(data,"res") }, { label: "Aspect Ratio", value: cellData(data,"aspect") }, { label: "Total Pixels", value: cellData(data,"pixels") }, { label: "Pixel Density", value: cellData(data,"pixelDensity") }, { label: "Processor Recommendation", value: cellData(data,"processor") }, { label: "Minimum Viewing Distance", value: cellData(data,"minViewingDistance") }, { label: "Δ Width vs Desired", value: cellData(data,"dW") }, { label: "Δ Height vs Desired", value: cellData(data,"dH") }, { label: "Matching Percent (area)", value: cellData(data,"match") }, ].filter((row)=>row.value && row.value !== "—"); const summaryRows=[...buildShareInputRows(), ...rows]; const plainLines=[`${label} — Pixel Calculator`, ...summaryRows.map((row)=>`${row.label}: ${row.value}`)]; plainLines.push(""); plainLines.push(`© ${(new Date()).getFullYear()} PixelCal.com. All rights reserved.`); return { designLabel: label, shareKind: "screen", summary: summaryRows, plainText: plainLines.join("\n"), }; } function buildManualSharePayload(label, cabId, pitch, formatted){ if(!formatted){ return null; } const cabLabel=(cabinetsById[cabId]||{}).label||cabId; const rows=[ { label: "Cabinet Size", value: cabLabel }, { label: "Cabinet Layout (C×R)", value: formatted.layoutLabel }, { label: "Total Cabinets", value: formatted.totalCabinetsLabel }, { label: "Screen Dimension", value: formatted.dimensionLabel }, { label: "Screen Dimension (ft)", value: formatted.dimensionFeetLabel }, { label: "Screen Area", value: formatted.areaLabel }, { label: "Screen Diagonal (in)", value: formatted.diagLabel }, { label: "Pixel Pitch", value: formatted.pitchLabel || fmtPitch(pitch) }, { label: "Screen Resolution", value: formatted.resolutionLabel }, { label: "Aspect Ratio", value: formatted.aspectLabel }, { label: "Total Pixels", value: formatted.totalPixelsLabel }, { label: "Pixel Density", value: formatted.pixelDensityLabel }, { label: "Processor Recommendation", value: formatted.processorRecommendation }, { label: "Minimum Viewing Distance", value: formatted.minViewingDistanceLabel }, ].filter((row)=>row.value && row.value !== "—"); const summaryRows=[...buildShareInputRows({ includeDesiredInputs:false }), ...rows]; const plainLines=[`${label} — Pixel Calculator`, ...summaryRows.map((row)=>`${row.label}: ${row.value}`)]; plainLines.push(""); plainLines.push(`© ${(new Date()).getFullYear()} PixelCal.com. All rights reserved.`); return { designLabel: label, shareKind: "manual", summary: summaryRows, plainText: plainLines.join("\n"), }; } function buildVisualSharePayload(label, cabId, pitch, formatted, clearanceMm, screenHeightMm){ if(!formatted){ return null; } const cabLabel=(cabinetsById[cabId]||{}).label||cabId; const clearanceFt=fromMM(clearanceMm, "ft"); const clearanceM=fromMM(clearanceMm, "m"); const rows=[ { label: "Cabinet Size", value: cabLabel }, { label: "Cabinet Layout (C×R)", value: formatted.layoutLabel }, { label: "Total Cabinets", value: formatted.totalCabinetsLabel }, { label: "Screen Dimension", value: formatted.dimensionLabel }, { label: "Screen Dimension (ft)", value: formatted.dimensionFeetLabel }, { label: "Screen Area", value: formatted.areaLabel }, { label: "Screen Diagonal (in)", value: formatted.diagLabel }, { label: "Pixel Pitch", value: formatted.pitchLabel || fmtPitch(pitch) }, { label: "Screen Resolution", value: formatted.resolutionLabel }, { label: "Aspect Ratio", value: formatted.aspectLabel }, { label: "Total Pixels", value: formatted.totalPixelsLabel }, { label: "Pixel Density", value: formatted.pixelDensityLabel }, { label: "Processor Recommendation", value: formatted.processorRecommendation }, { label: "Minimum Viewing Distance", value: formatted.minViewingDistanceLabel }, { label: "Bottom Edge Above Floor", value: `${fmt(clearanceM,3)} m (${fmtFixed(clearanceFt,1)} ft)` }, ].filter((row)=>row.value && row.value !== "—"); if(Number.isFinite(screenHeightMm)){ const overallMm=clearanceMm + screenHeightMm; const overallFt=fromMM(overallMm, "ft"); const overallM=fromMM(overallMm, "m"); rows.push({ label: "Overall Screen Height", value: `${fmt(overallM,3)} m (${fmtFixed(overallFt,1)} ft)` }); } const summaryRows=[...buildShareInputRows({ includeDesiredInputs:false }), ...rows]; const plainLines=[`${label} — Pixel Calculator`, ...summaryRows.map((row)=>`${row.label}: ${row.value}`)]; plainLines.push(""); plainLines.push(`© ${(new Date()).getFullYear()} PixelCal.com. All rights reserved.`); return { designLabel: label, shareKind: "visual", summary: summaryRows, plainText: plainLines.join("\n"), }; } // RFQ state & helpers (with phone + cookies) const [rqSelA, setRqSelA] = React.useState(false); const [rqSelB, setRqSelB] = React.useState(false); const [rqSelC, setRqSelC] = React.useState(false); const [rqSelM1, setRqSelM1] = React.useState(false); const [rqSelV1, setRqSelV1] = React.useState(false); const [rqName, setRqName] = React.useState(""); const [rqEmail, setRqEmail] = React.useState(""); const [rqPhone, setRqPhone] = React.useState(""); const [rqInstallEnvironment, setRqInstallEnvironment] = React.useState(""); const [rqInstallCity, setRqInstallCity] = React.useState(""); const [rqInstallCountry, setRqInstallCountry] = React.useState(""); const [rqServiceScope, setRqServiceScope] = React.useState(""); const [rqMsg, setRqMsg] = React.useState(""); const [rqError, setRqError] = React.useState(null); const [rqSending, setRqSending] = React.useState(false); const [rqSuccessMessage, setRqSuccessMessage] = React.useState(""); const [rfqOpen, setRfqOpen] = React.useState(false); const rfqContentId = "pwlc-rfq-section"; // Load cookies on mount React.useEffect(()=>{ const n=getCookie("pwlc_name"); if(n) setRqName(n); const e=getCookie("pwlc_email"); if(e) setRqEmail(e); const p=getCookie("pwlc_phone"); if(p) setRqPhone(p); },[]); const AUTO_START = '--- DESIGN SUMMARY (auto) START ---'; const AUTO_END = '--- DESIGN SUMMARY (auto) END ---'; const [shareState, setShareState] = React.useState({ open: false, designLabel: '', loading: false, error: null, result: null, }); const [shareCopyStatus, setShareCopyStatus] = React.useState(null); const captureVisualSnapshot = React.useCallback(()=>{ const canvas = visualCanvasEl; if(!canvas || typeof canvas.toDataURL !== "function"){ return null; } try { const canvasWidth = canvas.width || 0; const canvasHeight = canvas.height || 0; if(!(canvasWidth > 0) || !(canvasHeight > 0)){ return null; } const maxWidth = 1600; let dataUrl = ''; if(canvasWidth > maxWidth){ const scale = maxWidth / canvasWidth; const targetWidth = Math.round(canvasWidth * scale); const targetHeight = Math.max(1, Math.round(canvasHeight * scale)); const scaledCanvas = document.createElement('canvas'); scaledCanvas.width = targetWidth; scaledCanvas.height = targetHeight; const ctx = scaledCanvas.getContext('2d'); if(!ctx){ return null; } ctx.drawImage(canvas, 0, 0, targetWidth, targetHeight); dataUrl = scaledCanvas.toDataURL('image/png'); } else { dataUrl = canvas.toDataURL('image/png'); } if(typeof dataUrl === 'string' && dataUrl.startsWith('data:image/')){ return dataUrl; } } catch (err) { return null; } return null; },[visualCanvasEl]); const closeShare = React.useCallback(()=>{ setShareState({ open: false, designLabel: '', loading: false, error: null, result: null, }); setShareCopyStatus(null); },[]); const sendShareRequest = React.useCallback(async(payload, designLabel)=>{ if (!payload) { setShareState({ open: true, designLabel, loading: false, error: 'Design details are not available yet. Adjust the inputs and try again.', result: null, }); return; } if (typeof PWLC_AJAX === "undefined" || !PWLC_AJAX.ajax_url || !PWLC_AJAX.nonce) { setShareState({ open: true, designLabel, loading: false, error: 'Sharing is unavailable right now. Please refresh and try again.', result: null, }); return; } try { setShareState({ open: true, designLabel, loading: true, error: null, result: null, }); const fd = new FormData(); fd.append('action', 'pwlc_share_design'); fd.append('nonce', PWLC_AJAX.nonce); fd.append('payload', JSON.stringify({ ...payload, meta: { toolVersion: TOOL_VERSION, designCounter, }, })); const res = await fetch(PWLC_AJAX.ajax_url, { method: 'POST', body: fd, credentials: 'same-origin', }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const json = await res.json(); if (!json || !json.success || !json.data) { throw new Error('Unexpected response.'); } setShareState({ open: true, designLabel, loading: false, error: null, result: json.data, }); } catch (err) { setShareState({ open: true, designLabel, loading: false, error: 'We could not prepare the share link. Please try again in a moment.', result: null, }); } },[designCounter]); const copyValueToClipboard = React.useCallback(async(value)=>{ if(!value){ return 'failed'; } try { if(typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText){ await navigator.clipboard.writeText(value); return 'success'; } } catch (err) { return 'failed'; } if(typeof document !== "undefined"){ let textarea; try { textarea=document.createElement('textarea'); textarea.value=value; textarea.setAttribute('readonly',''); textarea.style.position='absolute'; textarea.style.left='-9999px'; document.body.appendChild(textarea); textarea.select(); const successful=document.execCommand('copy'); return successful ? 'success' : 'failed'; } catch (err) { return 'failed'; } finally { if(textarea && textarea.parentNode){ textarea.parentNode.removeChild(textarea); } } } return 'unavailable'; },[]); const handleShareCopy = React.useCallback(async(value,label)=>{ if(!value){ return; } const result=await copyValueToClipboard(value); if(result==='success'){ setShareCopyStatus({ target: label, message: 'Copied!' }); setTimeout(()=>setShareCopyStatus(null),1500); } else if(result==='unavailable'){ setShareCopyStatus({ target: label, message: 'Copy not supported' }); setTimeout(()=>setShareCopyStatus(null),2000); } else { setShareCopyStatus({ target: label, message: 'Copy failed' }); setTimeout(()=>setShareCopyStatus(null),2000); } },[copyValueToClipboard]); const adjustOrbitRadius = React.useCallback((multiplier)=>{ const baseState = visualOrbitTargetRef.current || visualOrbitStateRef.current; if(!baseState){ return; } const scene = visualSceneRef.current; const clampedBase = clampOrbitState(scene, baseState); const limits = computeOrbitLimits(scene, clampedBase.target); const desiredRadius = clamp(clampedBase.radius * multiplier, limits.minRadius, limits.maxRadius); if(Math.abs(desiredRadius - baseState.radius) < 1e-2){ return; } const nextTargetState = clampOrbitState(scene, { ...clampedBase, radius: desiredRadius, minRadius: limits.minRadius, maxRadius: limits.maxRadius, }); visualOrbitTargetRef.current = nextTargetState; const currentState = visualOrbitStateRef.current; if(currentState){ const currentClamped = clampOrbitState(scene, currentState); const nextImmediateState = clampOrbitState(scene, { ...currentClamped, radius: clamp(currentClamped.radius * multiplier, limits.minRadius, limits.maxRadius), minRadius: limits.minRadius, maxRadius: limits.maxRadius, }); visualOrbitStateRef.current = nextImmediateState; setVisualOrbitState(nextImmediateState); } },[]); const panOrbitByPixels = React.useCallback((deltaX,deltaY)=>{ const scene = visualSceneRef.current; if(!scene){ return; } const baseState = visualOrbitTargetRef.current || visualOrbitStateRef.current; if(!baseState){ return; } const clampedBase = clampOrbitState(scene, baseState); if(!clampedBase.target){ return; } const panSpeed = scene.baseSize / 650; const cameraOrbit = { target: clampedBase.target, azimuth: clampedBase.azimuth, polar: clampedBase.polar, radius: clampedBase.radius, }; const cameraPosition = orbitStateToCameraPosition(cameraOrbit); const basis = createCameraBasis(cameraPosition, clampedBase.target, { x: 0, y: 0, z: 1 }); const moveRight = -deltaX * panSpeed; const moveUp = deltaY * panSpeed; const delta = { x: basis.right.x * moveRight + basis.up.x * moveUp, y: basis.right.y * moveRight + basis.up.y * moveUp, z: basis.right.z * moveRight + basis.up.z * moveUp, }; const nextTarget = { x: clampedBase.target.x + delta.x, y: clampedBase.target.y + delta.y, z: clampedBase.target.z + delta.z, }; const targetState = clampOrbitState(scene, { ...clampedBase, target: nextTarget, }); visualOrbitTargetRef.current = targetState; const currentState = visualOrbitStateRef.current; if(currentState){ const currentClamped = clampOrbitState(scene, currentState); const referenceTarget = currentClamped.target || clampedBase.target; const immediateState = clampOrbitState(scene, { ...currentClamped, target: { x: (referenceTarget?.x ?? 0) + delta.x, y: (referenceTarget?.y ?? 0) + delta.y, z: (referenceTarget?.z ?? 0) + delta.z, }, }); visualOrbitStateRef.current = immediateState; setVisualOrbitState(immediateState); } },[]); const handleVisualZoomIn = React.useCallback(()=>{ adjustOrbitRadius(0.9); },[adjustOrbitRadius]); const handleVisualZoomOut = React.useCallback(()=>{ adjustOrbitRadius(1.1); },[adjustOrbitRadius]); const PAN_BUTTON_PIXEL_DELTA = 48; const handleVisualPanLeft = React.useCallback(()=>{ panOrbitByPixels(PAN_BUTTON_PIXEL_DELTA,0); },[panOrbitByPixels]); const handleVisualPanRight = React.useCallback(()=>{ panOrbitByPixels(-PAN_BUTTON_PIXEL_DELTA,0); },[panOrbitByPixels]); const handleVisualWheel = React.useCallback((event)=>{ if(!visualSceneRef.current){ return; } if(event.cancelable){ event.preventDefault(); } const multiplier = event.deltaY < 0 ? 0.9 : 1.1; adjustOrbitRadius(multiplier); },[adjustOrbitRadius]); const handleVisualPointerDown = React.useCallback((event)=>{ if(event.pointerType === 'mouse' && event.button !== 0 && event.button !== 2){ return; } const mode = event.pointerType === 'mouse' && event.button === 2 ? 'pan' : 'rotate'; visualPointerModeRef.current = mode; visualPointerStartRef.current = { x: event.clientX, y: event.clientY }; const scene = visualSceneRef.current; const targetState = visualOrbitTargetRef.current || visualOrbitStateRef.current; if(targetState){ const clamped = clampOrbitState(scene, targetState); visualOrbitTargetRef.current = clamped; visualOrbitDragStartRef.current = { target: clamped.target ? { ...clamped.target } : { x: 0, y: 0, z: 0 }, azimuth: clamped.azimuth, polar: clamped.polar, radius: clamped.radius, }; } else { visualOrbitDragStartRef.current = null; } const targetEl = event.currentTarget; if(targetEl.setPointerCapture){ try { targetEl.setPointerCapture(event.pointerId); } catch(_err) { // ignore } } event.preventDefault(); },[]); const handleVisualPointerMove = React.useCallback((event)=>{ const mode = visualPointerModeRef.current; if(!mode){ return; } const dragStart = visualOrbitDragStartRef.current; if(!dragStart){ return; } const start = visualPointerStartRef.current; const dx = event.clientX - start.x; const dy = event.clientY - start.y; if(mode === 'rotate'){ const rotateSpeed = 0.0045; const nextAzimuth = wrapAngle(dragStart.azimuth - dx * rotateSpeed); const nextPolar = clamp(dragStart.polar + dy * rotateSpeed, MIN_POLAR_ANGLE, MAX_POLAR_ANGLE); const scene = visualSceneRef.current; const targetState = visualOrbitTargetRef.current || dragStart; const nextState = clampOrbitState(scene, { ...targetState, target: dragStart.target, azimuth: nextAzimuth, polar: nextPolar, }); visualOrbitTargetRef.current = nextState; return; } if(mode === 'pan'){ const scene = visualSceneRef.current; if(!scene){ return; } const panSpeed = scene.baseSize / 650; const cameraOrbit = { target: dragStart.target, azimuth: dragStart.azimuth, polar: dragStart.polar, radius: dragStart.radius, }; const cameraPosition = orbitStateToCameraPosition(cameraOrbit); const basis = createCameraBasis(cameraPosition, dragStart.target, { x: 0, y: 0, z: 1 }); const moveRight = -dx * panSpeed; const moveUp = dy * panSpeed; const delta = { x: basis.right.x * moveRight + basis.up.x * moveUp, y: basis.right.y * moveRight + basis.up.y * moveUp, z: basis.right.z * moveRight + basis.up.z * moveUp, }; const nextTarget = { x: dragStart.target.x + delta.x, y: dragStart.target.y + delta.y, z: dragStart.target.z + delta.z, }; const targetState = visualOrbitTargetRef.current || dragStart; const nextState = clampOrbitState(scene, { ...targetState, target: nextTarget, }); visualOrbitTargetRef.current = nextState; } },[]); const handleVisualPointerEnd = React.useCallback((event)=>{ if(!visualPointerModeRef.current){ return; } visualPointerModeRef.current = null; visualOrbitDragStartRef.current = null; const targetEl = event.currentTarget; if(targetEl.releasePointerCapture){ try { targetEl.releasePointerCapture(event.pointerId); } catch(_err) { // ignore } } },[]); const handleVisualPointerCancel = React.useCallback((event)=>{ visualPointerModeRef.current = null; visualOrbitDragStartRef.current = null; if(event && event.currentTarget && typeof event.pointerId !== 'undefined' && event.currentTarget.releasePointerCapture){ try { event.currentTarget.releasePointerCapture(event.pointerId); } catch(_err) { // ignore } } },[]); React.useEffect(()=>{ if(!visualContainerEl || typeof visualContainerEl.addEventListener !== 'function'){ return undefined; } const wheelListener = (event)=>{ handleVisualWheel(event); }; visualContainerEl.addEventListener('wheel', wheelListener, { passive: false }); return ()=>{ visualContainerEl.removeEventListener('wheel', wheelListener); }; },[visualContainerEl, handleVisualWheel]); const handleColumnMouseEnter = React.useCallback((designKey)=>{ if(hoverTimeoutRef.current){ clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current=null; } setHoveredDesign(designKey); },[]); const handleColumnMouseLeave = React.useCallback((designKey)=>{ if(hoverTimeoutRef.current){ clearTimeout(hoverTimeoutRef.current); } hoverTimeoutRef.current=setTimeout(()=>{ setHoveredDesign((current)=> (current===designKey ? null : current)); hoverTimeoutRef.current=null; },100); },[]); const clearHoveredDesign = React.useCallback(()=>{ if(hoverTimeoutRef.current){ clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current=null; } setHoveredDesign(null); },[]); const handleManualHoverIn = React.useCallback(()=>handleColumnMouseEnter('M1'),[handleColumnMouseEnter]); const handleManualHoverOut = React.useCallback(()=>handleColumnMouseLeave('M1'),[handleColumnMouseLeave]); const handleVisualSummaryHoverIn = React.useCallback(()=>handleColumnMouseEnter('V1'),[handleColumnMouseEnter]); const handleVisualSummaryHoverOut = React.useCallback(()=>handleColumnMouseLeave('V1'),[handleColumnMouseLeave]); const handleDesignCopy = React.useCallback(async(designKey)=>{ let payload=null; let label=''; switch(designKey){ case 'A': payload=colA ? buildScreenSharePayload('Design A', cabA, pitchA, colA) : null; label='Design A'; break; case 'B': payload=colB ? buildScreenSharePayload('Design B', cabB, pitchB, colB) : null; label='Design B'; break; case 'C': payload=colC ? buildScreenSharePayload('Design C', cabC, pitchC, colC) : null; label='Design C'; break; case 'M1': payload=manualFormatted ? buildManualSharePayload('Design M1', manualCab, manualPitch, manualFormatted) : null; label='Design M1'; break; case 'V1': payload=visualFormatted ? buildVisualSharePayload('Design V1', manualCab, manualPitch, visualFormatted, visualFloorClearanceMm, visualData ? visualData.heightMm : null) : null; label='Design V1'; break; default: payload=null; label='Design'; } if(!payload){ setDesignActionStatus({ variant: 'info', message: `${label} is not ready to copy yet.` }); if(designActionTimeoutRef.current){ clearTimeout(designActionTimeoutRef.current); } designActionTimeoutRef.current=setTimeout(()=>setDesignActionStatus(null),2000); return; } const result=await copyValueToClipboard(payload.plainText); let message='Copy failed. Please try again.'; let variant='error'; if(result==='success'){ message=`${payload.designLabel} copied to clipboard.`; variant='success'; } else if(result==='unavailable'){ message='Copy is not supported in this environment.'; variant='info'; } setDesignActionStatus({ variant, message }); if(designActionTimeoutRef.current){ clearTimeout(designActionTimeoutRef.current); } designActionTimeoutRef.current=setTimeout(()=>setDesignActionStatus(null),2500); },[cabA, pitchA, colA, cabB, pitchB, colB, cabC, pitchC, colC, manualCab, manualPitch, manualFormatted, visualFormatted, visualFloorClearanceMm, visualData, copyValueToClipboard]); const handleShareClick = React.useCallback((key)=>{ switch(key){ case 'A': sendShareRequest(colA ? { ...buildScreenSharePayload('Design A', cabA, pitchA, colA), designKey: 'A' } : null, 'Design A'); break; case 'B': sendShareRequest(colB ? { ...buildScreenSharePayload('Design B', cabB, pitchB, colB), designKey: 'B' } : null, 'Design B'); break; case 'C': sendShareRequest(colC ? { ...buildScreenSharePayload('Design C', cabC, pitchC, colC), designKey: 'C' } : null, 'Design C'); break; case 'M1': sendShareRequest(manualFormatted ? { ...buildManualSharePayload('Design M1', manualCab, manualPitch, manualFormatted), designKey: 'M1' } : null, 'Design M1'); break; case 'V1': { const basePayload = visualFormatted ? buildVisualSharePayload('Design V1', manualCab, manualPitch, visualFormatted, visualFloorClearanceMm, visualData ? visualData.heightMm : null) : null; if(!basePayload){ sendShareRequest(null, 'Design V1'); break; } const snapshot = captureVisualSnapshot(); const payloadWithSnapshot = snapshot ? { ...basePayload, canvasSnapshot: snapshot, designKey: 'V1' } : { ...basePayload, designKey: 'V1' }; sendShareRequest(payloadWithSnapshot, 'Design V1'); break; } default: break; } },[cabA,cabB,cabC,manualCab,colA,colB,colC,manualFormatted,visualFormatted,manualPitch,pitchA,pitchB,pitchC,visualFloorClearanceMm,visualData,sendShareRequest,captureVisualSnapshot]); const shareExpires = React.useMemo(()=>{ if(!shareState.result || !shareState.result.expires_at){ return null; } const d = new Date(shareState.result.expires_at); if(Number.isNaN(d.getTime())){ return null; } return d; },[shareState.result]); function buildRFQAuto(){ const sections=[]; if(rqSelA) sections.push(buildExportText("Design A",cabA,pitchA,colA)); if(rqSelB) sections.push(buildExportText("Design B",cabB,pitchB,colB)); if(rqSelC) sections.push(buildExportText("Design C",cabC,pitchC,colC)); if(rqSelM1) sections.push(buildManualExportText("Design M1", manualCab, manualPitch, manualFormatted)); if(rqSelV1) sections.push(buildVisualExportText("Design V1", manualCab, manualPitch, visualFormatted, visualFloorClearanceMm, visualData ? visualData.heightMm : null)); return sections.join("\n\n"); } function injectAuto(prev,auto){ const s=prev.indexOf(AUTO_START); const e=prev.indexOf(AUTO_END); const block=`${AUTO_START}\n\n${auto}\n\n${AUTO_END}`; if(s!==-1 && e!==-1 && e>s){ const before=prev.slice(0,s); const after=prev.slice(e+AUTO_END.length); return `${before}${block}${after}`; } if(prev.trim().length>0){ return `${block}\n\n${prev}`; } return `${block}\n`; } React.useEffect(()=>{ const auto=buildRFQAuto(); setRqMsg(prev=>injectAuto(prev,auto)); },[rqSelA,rqSelB,rqSelC,rqSelM1,rqSelV1,cabA,cabB,cabC,manualCab,pitchA,pitchB,pitchC,manualPitch,colA,colB,colC,manualData,manualFormatted,visualFormatted,visualFloorClearanceMm,visualData]); async function onSendRFQ(){ setRqError(null); setRqSuccessMessage(""); if(!(rqSelA||rqSelB||rqSelC||rqSelM1||rqSelV1)){ setRqError("Please select at least one design to include."); return; } if(!rqName.trim()){ setRqError("Please enter your name."); return; } const emailOk = rqEmail.includes("@") && rqEmail.includes("."); if(!emailOk){ setRqError("Please enter a valid email address."); return; } if(!rqPhone.trim()){ setRqError("Please enter your phone number."); return; } if(!rqInstallEnvironment){ setRqError("Please specify the installation environment."); return; } if(!rqInstallCity.trim()){ setRqError("Please enter the installation city."); return; } if(!rqInstallCountry){ setRqError("Please select the installation country."); return; } if(!rqServiceScope){ setRqError("Please select whether you need equipment only or full service installation."); return; } if (typeof PWLC_AJAX === "undefined" || !PWLC_AJAX.ajax_url || !PWLC_AJAX.nonce) { setRqError("Form configuration missing (AJAX). Please refresh the page."); return; } const selected = ["A","B","C","M1","V1"].filter((k,i)=>[rqSelA,rqSelB,rqSelC,rqSelM1,rqSelV1][i]).join(","); const environmentLabel = INSTALL_ENVIRONMENT_OPTIONS.find(opt=>opt.value===rqInstallEnvironment)?.label || rqInstallEnvironment; const countryLabel = COUNTRY_OPTIONS.find(opt=>opt.value===rqInstallCountry)?.label || rqInstallCountry; const scopeLabel = SERVICE_SCOPE_OPTIONS.find(opt=>opt.value===rqServiceScope)?.label || rqServiceScope; const locationLabel = [rqInstallCity.trim(), countryLabel].filter(Boolean).join(", "); const detailsLines = [ "--- RFQ Details ---", `Installation Environment: ${environmentLabel || "Not specified"}`, `Installation Location: ${locationLabel || "Not specified"}`, `Service Scope: ${scopeLabel || "Not specified"}`, "--- RFQ Details End ---", "", ]; const finalMessage = `${detailsLines.join("\n")}${rqMsg}`; if(!finalMessage.trim()){ setRqError("Please provide additional details for your request."); return; } const fd = new FormData(); fd.append("action","pwlc_send_rfq"); fd.append("nonce", PWLC_AJAX.nonce); fd.append("name", rqName); fd.append("email", rqEmail); fd.append("phone", rqPhone); fd.append("install_environment", rqInstallEnvironment); fd.append("install_city", rqInstallCity); fd.append("install_country", rqInstallCountry); fd.append("service_scope", rqServiceScope); fd.append("message", finalMessage); fd.append("selected", selected); setRqSending(true); try{ const res = await fetch(PWLC_AJAX.ajax_url, { method:"POST", body: fd, credentials:"same-origin" }); let data = null; try {{ data = await res.json(); }} catch(_e) {{}} if(!res.ok || !data || data.success !== true){ const msg = (data && data.data && data.data.message) ? data.data.message : "Server error. Please try again later."; setRqError(msg); } else { // Persist cookies for 180 days setCookie("pwlc_name", rqName, 180); setCookie("pwlc_email", rqEmail, 180); setCookie("pwlc_phone", rqPhone, 180); const successMsg = (data && data.data && data.data.message) ? data.data.message : "Your RFQ is scheduled to be sent momentarily."; setRqSuccessMessage(successMsg); } } catch(err){ setRqError("Network error. Please check your connection and try again."); } finally{ setRqSending(false); } } function DesignHoverActions({ label, show, onShare, shareEnabled, onCopy, copyEnabled, onHoverIn, onHoverOut }) { if(!(shareEnabled || copyEnabled)){ return null; } const containerClasses=[ "absolute right-2 top-1/2 flex -translate-y-1/2 gap-0.5 rounded-md bg-white/95 px-1 py-[1px] text-gray-500 shadow-sm ring-1 ring-gray-200 transition-all duration-150", show ? "opacity-100 pointer-events-auto" : "pointer-events-none opacity-0" ].join(" "); const actionBaseClass="flex items-center justify-center"; const sharedInteractive="focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"; const buildActionClass=(enabled)=>[ actionBaseClass, "rounded-md p-0.5 text-gray-500 transition-colors", enabled ? "cursor-pointer hover:bg-blue-50 hover:text-blue-600" : "cursor-default opacity-40", sharedInteractive ].join(" "); const handleKeyActivate=(event, handler, enabled)=>{ if(!enabled){ return; } if(event.key === 'Enter' || event.key === ' '){ event.preventDefault(); handler(); } }; return (
{copyEnabled && ( handleKeyActivate(event,onCopy,copyEnabled)} onFocus={onHoverIn} onBlur={onHoverOut} title="Copy" > )} {shareEnabled && ( handleKeyActivate(event,onShare,shareEnabled)} onFocus={onHoverIn} onBlur={onHoverOut} title="Share" > )}
); } function HeaderCell({label,idx,hideCls,onShare,shareDisabled,onCopy,copyDisabled,hovered,onHoverIn,onHoverOut}){ const isBest = best===idx && !!resTarget; const classes=["relative px-3 py-2 flex items-center gap-2", hideCls||"", isBest?"bg-green-50 ring-1 ring-inset ring-green-400":""].filter(Boolean).join(" "); const shareEnabled=!!onShare && !shareDisabled; const copyEnabled=!!onCopy && !copyDisabled; const hasActions=!!onShare || !!onCopy; const showActions=hovered && (shareEnabled || copyEnabled); return (
{label} {isBest && (Best Match)} {hasActions && ( )}
); } const columnCellClass=(rowIdx,columnIdx,extraClasses="")=>{ const highlight=best===columnIdx && !!resTarget; const zebra=rowIdx%2 ? "bg-gray-50" : ""; const bgClass=highlight?"bg-green-50 ring-1 ring-inset ring-green-400":zebra; return ["border-t px-3 py-2", bgClass, extraClasses].filter(Boolean).join(" "); }; const tabButtonClass=(tab)=>[ "pwlc-tab-button", activeTab===tab ? "pwlc-tab-button--active" : "" ].filter(Boolean).join(" "); const hasVisualScene = !!(visualScene && visualViewport.width > 0 && visualViewport.height > 0); const visualZoomPercent = React.useMemo(()=>{ if(!visualOrbitState || !(visualOrbitState.radius > 0) || !(visualOrbitState.defaultRadius > 0)){ return 100; } return Math.round((visualOrbitState.defaultRadius / visualOrbitState.radius) * 100); },[visualOrbitState]); const visualCameraMetrics = React.useMemo(()=>{ if(!visualOrbitState){ return { distance: "—", height: "—", elevation: "—", azimuth: "—", offset: "—", }; } const cameraPosition = orbitStateToCameraPosition(visualOrbitState); const distanceM = Number.isFinite(visualOrbitState.radius) ? visualOrbitState.radius / 1000 : null; const heightM = cameraPosition && Number.isFinite(cameraPosition.z) ? cameraPosition.z / 1000 : null; const polarDeg = Number.isFinite(visualOrbitState.polar) ? visualOrbitState.polar * RAD_TO_DEG : null; const elevationDeg = polarDeg === null ? null : (90 - polarDeg); const azimuthDeg = Number.isFinite(visualOrbitState.azimuth) ? wrapAngle(visualOrbitState.azimuth) * RAD_TO_DEG : null; let horizontalOffsetM = null; if(cameraPosition && visualScene){ const roomCenter = { x: 0, y: visualScene.floorDepth / 2, z: visualScene.wallHeight / 2, }; const dx = cameraPosition.x - roomCenter.x; horizontalOffsetM = dx / 1000; } return { distance: Number.isFinite(distanceM) ? `${fmt(distanceM,2)} m` : "—", height: Number.isFinite(heightM) ? `${fmt(heightM,2)} m` : "—", elevation: Number.isFinite(elevationDeg) ? `${fmt(elevationDeg,1)}°` : "—", azimuth: Number.isFinite(azimuthDeg) ? `${fmt(azimuthDeg,1)}°` : "—", horizontalOffset: Number.isFinite(horizontalOffsetM) ? `${fmt(horizontalOffsetM,2)} m` : "—", }; },[visualOrbitState, visualScene]); const { distance: cameraDistanceLabel, height: cameraHeightLabel, elevation: cameraElevationLabel, azimuth: cameraAzimuthLabel, horizontalOffset: cameraHorizontalOffsetLabel } = visualCameraMetrics; const visualTelemetryRows = React.useMemo(()=>[ { label: "Zoom", value: `${visualZoomPercent}%` }, { label: "View distance", value: cameraDistanceLabel }, { label: "View height", value: cameraHeightLabel }, { label: "Angle Tilt", value: cameraElevationLabel }, { label: "Angle Pan", value: cameraAzimuthLabel }, { label: "Horizontal offset", value: cameraHorizontalOffsetLabel }, ],[visualZoomPercent, cameraDistanceLabel, cameraHeightLabel, cameraElevationLabel, cameraAzimuthLabel, cameraHorizontalOffsetLabel]); const visualControlButtonClass = "pwlc-visual-controls__button"; const visualControlButtonVariants = { zoomIn: "pwlc-visual-controls__button--zoom-in", panLeft: "pwlc-visual-controls__button--pan-left", center: null, panRight: "pwlc-visual-controls__button--pan-right", zoomOut: "pwlc-visual-controls__button--zoom-out", }; const getVisualControlButtonClass = (variant)=>{ if (!variant) { return visualControlButtonClass; } return `${visualControlButtonClass} ${variant}`; }; const visualControlButtonPlacement = { zoomIn: { gridColumn: "2", gridRow: "1" }, panLeft: { gridColumn: "1", gridRow: "2" }, center: { gridColumn: "2", gridRow: "2" }, panRight: { gridColumn: "3", gridRow: "2" }, zoomOut: { gridColumn: "2", gridRow: "3" }, }; const visualScreenDesignContentId = "pwlc-visual-screen-design-panel"; const visualContentDisplayContentId = "pwlc-visual-content-display-panel"; const visualCalculatorContent=(

Screen Design

{!visualScreenDesignCollapsed && (
Columns
setVisualColsInput(e.target.value)} onBlur={handleVisualColsBlur} className="pwlc-field-control pwlc-field-control--center" />
{wallWidthWarning ? (

{wallWidthWarning}

) : null}
Rows
setVisualRowsInput(e.target.value)} onBlur={handleVisualRowsBlur} className="pwlc-field-control pwlc-field-control--center pwlc-field-control--sm" />
{visualRowsError ? (

{visualRowsError}

) : null}
Display Reference Person
)}

Content Display

{!visualContentCollapsed && (
Display Image Texture
Hide Cabinet Grid Lines
)}
event.preventDefault()} style={{ touchAction: "none" }} > {hasVisualScene ? ( <>
{visualTelemetryRows.map((row)=>(
{row.label} {row.value}
))}
) : (
Adjust the cabinet, pitch, columns, and rows to preview the video wall.
)}
Body: Fixed position, stands 1.82 m tall, positioned 1 m left of center and 2 m from the screen.
Rotate: Click and drag with the left mouse button.
Pan: Drag with the right mouse button or use a two-finger drag.
Zoom: Scroll with your mouse wheel or trackpad.
Design V1 handleShareClick('V1')} shareEnabled={!!visualFormatted} onCopy={()=>handleDesignCopy('V1')} copyEnabled={!!visualFormatted} onHoverIn={handleVisualSummaryHoverIn} onHoverOut={handleVisualSummaryHoverOut} />
Cabinet Layout (C×R) {visualFormatted ? visualFormatted.layoutLabel : '—'}
Total Cabinets {visualFormatted ? visualFormatted.totalCabinetsLabel : '—'}
Screen Dimension {visualFormatted ? (
{visualFormatted.dimensionLabel}
({visualFormatted.dimensionFeetLabel})
) : '—'}
Screen Area {visualFormatted ? visualFormatted.areaLabel : '—'}
Screen Diagonal (in) {visualFormatted ? visualFormatted.diagLabel : '—'}
Pixel Pitch {visualFormatted ? visualFormatted.pitchLabel : '—'}
Screen Resolution {visualFormatted ? visualFormatted.resolutionLabel : '—'}
Aspect Ratio {visualFormatted ? visualFormatted.aspectLabel : '—'}
Total Pixels {visualFormatted ? visualFormatted.totalPixelsLabel : '—'}
Pixel Density {visualFormatted ? visualFormatted.pixelDensityLabel : '—'}
Processor Recommendation {visualFormatted ? visualFormatted.processorRecommendation : '—'}
Minimum Viewing Distance {visualFormatted ? visualFormatted.minViewingDistanceLabel : '—'}
Bottom Edge Above Floor {visualFormatted ? visualClearanceLabel : '—'}
Overall Screen Height {visualFormatted && visualOverallHeightLabel ? visualOverallHeightLabel : '—'}
); const screenCalculatorContent=( <>
Desired Area: {fmt(desiredAreaM2,3)} m²
(Computed from width × height)
Attribute
handleShareClick('A')} shareDisabled={!colA} onCopy={()=>handleDesignCopy('A')} copyDisabled={!colA} hovered={hoveredDesign==='A'} onHoverIn={()=>handleColumnMouseEnter('A')} onHoverOut={()=>handleColumnMouseLeave('A')} /> handleShareClick('B')} shareDisabled={!colB} onCopy={()=>handleDesignCopy('B')} copyDisabled={!colB} hovered={hoveredDesign==='B'} onHoverIn={()=>handleColumnMouseEnter('B')} onHoverOut={()=>handleColumnMouseLeave('B')} /> handleShareClick('C')} shareDisabled={!colC} onCopy={()=>handleDesignCopy('C')} copyDisabled={!colC} hovered={hoveredDesign==='C'} onHoverIn={()=>handleColumnMouseEnter('C')} onHoverOut={()=>handleColumnMouseLeave('C')} />
{screenTableRows.map((row, idx) => (
{row.label}
handleColumnMouseEnter('A')} onMouseLeave={()=>handleColumnMouseLeave('A')} > {row.field==="cabinetSelect" ? () : row.field==="pitchSelect" ? () : (cellData(colA,row.field))}
handleColumnMouseEnter('B')} onMouseLeave={()=>handleColumnMouseLeave('B')} > {row.field==="cabinetSelect" ? () : row.field==="pitchSelect" ? () : (cellData(colB,row.field))}
handleColumnMouseEnter('C')} onMouseLeave={()=>handleColumnMouseLeave('C')} > {row.field==="cabinetSelect" ? () : row.field==="pitchSelect" ? () : (cellData(colC,row.field))}
))}
{designActionStatus && (
{designActionStatus.message}
)} ); const manualCalculatorContent=(
Design M1 handleShareClick('M1')} shareEnabled={!!manualFormatted} onCopy={()=>handleDesignCopy('M1')} copyEnabled={!!manualFormatted} onHoverIn={handleManualHoverIn} onHoverOut={handleManualHoverOut} />
Cabinet Size
Pixel Pitch
Columns Count setManualColsInput(e.target.value)} onBlur={handleManualColsBlur} className="pwlc-field-control w-32" />
Row Count setManualRowsInput(e.target.value)} onBlur={handleManualRowsBlur} className="pwlc-field-control w-32" />
Cabinet Layout (C×R) {manualFormatted ? manualFormatted.layoutLabel : "—"}
Total Cabinets {manualFormatted ? manualFormatted.totalCabinetsLabel : "—"}
Screen Dimension {manualFormatted ? (
{manualFormatted.dimensionLabel}
({manualFormatted.dimensionFeetLabel})
) : "—"}
Screen Area {manualFormatted ? manualFormatted.areaLabel : "—"}
Screen Diagonal (in) {manualFormatted ? manualFormatted.diagLabel : "—"}
Pixel Pitch {manualFormatted ? manualFormatted.pitchLabel : fmtPitch(manualPitch)}
Screen Resolution {manualFormatted ? manualFormatted.resolutionLabel : "—"}
Aspect Ratio {manualFormatted ? manualFormatted.aspectLabel : "—"}
Total Pixels {manualFormatted ? manualFormatted.totalPixelsLabel : "—"}
Pixel Density {manualFormatted ? manualFormatted.pixelDensityLabel : "—"}
Processor Recommendation {manualFormatted ? manualFormatted.processorRecommendation : "—"}
Minimum Viewing Distance {manualFormatted ? manualFormatted.minViewingDistanceLabel : "—"}
); return (
{exportStatus ? (
{exportStatus.message}
) : null}
{activeTab==="visual" ? visualCalculatorContent : activeTab==="screen" ? screenCalculatorContent : manualCalculatorContent}
{ e.preventDefault(); onSendRFQ(); }}> {rfqOpen && (
Your request for quote will be sent to original LED manufactures and service integrators.
{rqError && (
{rqError}
)} {rqSuccessMessage && (
{rqSuccessMessage}
)}
)}

Pixel Calculator for LED Video Wall V{TOOL_VERSION} — Made by PixelCal.com — Designs Calculated: {designCounter.toLocaleString()} — © {new Date().getFullYear()} PixelCal.com. All rights reserved.

{shareState.open && (

Share {shareState.designLabel}

{shareExpires && (

Share link available until {shareExpires.toLocaleString()}

)}
{shareState.loading && (
Preparing share link…
)} {!shareState.loading && shareState.error && (
{shareState.error}
)} {!shareState.loading && shareState.result && (
{shareState.result.canvas_snapshot && (
Snapshot of the current canvas view

Snapshot captured from the visualizer canvas.

)}

Design Summary

{(shareState.result.summary || []).map((row)=>(
{row.label}
{row.value}
))}

Share via SMS

Send this design with a prefilled text message.

Open SMS Draft
Share Link
{shareState.result.share_url}
Suggested Message