@zendir/ui 0.2.20 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ZenSpace3DUtils.js","sources":["../../../src/react/3d/ZenSpace3DUtils.ts"],"sourcesContent":["/**\r\n * @zendir/ui - ZenSpace3D Utility Functions\r\n * \r\n * Coordinate transformations, TLE propagation, coverage calculations,\r\n * and other utilities for 3D space visualization.\r\n */\r\n\r\nimport type {\r\n Vector3D,\r\n LatLonAlt,\r\n SphericalCoords,\r\n TLEData,\r\n Satellite,\r\n OrbitPath,\r\n CoverageHexGrid,\r\n VisibilityCone as _VisibilityCone,\r\n} from './ZenSpace3DTypes';\r\n\r\n// =============================================================================\r\n// Constants\r\n// =============================================================================\r\n\r\n/** Earth's radius in km */\r\nexport const EARTH_RADIUS_KM = 6371;\r\n\r\n/** Earth's gravitational parameter (km³/s²) */\r\nexport const EARTH_MU = 398600.4418;\r\n\r\n/** Astronomical Unit in km */\r\nexport const AU_KM = 149597870.7;\r\n\r\n/** Degrees to radians */\r\nexport const DEG_TO_RAD = Math.PI / 180;\r\n\r\n/** Radians to degrees */\r\nexport const RAD_TO_DEG = 180 / Math.PI;\r\n\r\n/** J2000 epoch (January 1, 2000, 12:00 TT) */\r\nexport const J2000_EPOCH = new Date('2000-01-01T12:00:00Z');\r\n\r\n/** Seconds per day */\r\nexport const SECONDS_PER_DAY = 86400;\r\n\r\n/** Minutes per day */\r\nexport const MINUTES_PER_DAY = 1440;\r\n\r\n// =============================================================================\r\n// Scene Scale Constants\r\n// =============================================================================\r\n\r\n/** Scene Earth radius (render units) */\r\nexport const SCENE_EARTH_RADIUS = 3000;\r\n\r\n/** Scene scale factor (km to render units) */\r\nexport const KM_TO_SCENE = SCENE_EARTH_RADIUS / EARTH_RADIUS_KM;\r\n\r\n/** Scene scale for Solar System (different scale) */\r\nexport const SOLAR_SYSTEM_SCALE = 1; // 1 unit = 1 km in solar system mode\r\n\r\n// =============================================================================\r\n// Coordinate Conversions\r\n// =============================================================================\r\n\r\n/**\r\n * Convert latitude/longitude/altitude to Cartesian (ECI-like for rendering)\r\n * @param lat Latitude in degrees\r\n * @param lon Longitude in degrees\r\n * @param altKm Altitude in kilometers\r\n * @param earthRadius Scene radius for Earth\r\n * @returns Cartesian coordinates\r\n */\r\nexport function latLonAltToCartesian(\r\n lat: number,\r\n lon: number,\r\n altKm: number = 0,\r\n earthRadius: number = SCENE_EARTH_RADIUS\r\n): Vector3D {\r\n const r = earthRadius + altKm * KM_TO_SCENE;\r\n const phi = (90 - lat) * DEG_TO_RAD;\r\n const theta = (lon + 180) * DEG_TO_RAD;\r\n \r\n return {\r\n x: -r * Math.sin(phi) * Math.cos(theta),\r\n y: r * Math.cos(phi),\r\n z: r * Math.sin(phi) * Math.sin(theta),\r\n };\r\n}\r\n\r\n/**\r\n * Convert Cartesian to latitude/longitude/altitude\r\n * @param position Cartesian position\r\n * @param earthRadius Scene radius for Earth\r\n * @returns Lat/Lon/Alt\r\n */\r\nexport function cartesianToLatLonAlt(\r\n position: Vector3D,\r\n earthRadius: number = SCENE_EARTH_RADIUS\r\n): LatLonAlt {\r\n const r = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);\r\n const lat = 90 - Math.acos(position.y / r) * RAD_TO_DEG;\r\n const lon = Math.atan2(position.z, -position.x) * RAD_TO_DEG - 180;\r\n const alt = (r - earthRadius) / KM_TO_SCENE;\r\n \r\n return { latitude: lat, longitude: lon, altitude: alt };\r\n}\r\n\r\n/**\r\n * Convert Cartesian to spherical coordinates\r\n */\r\nexport function cartesianToSpherical(position: Vector3D): SphericalCoords {\r\n const radius = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);\r\n const theta = Math.atan2(position.z, position.x);\r\n const phi = Math.acos(position.y / radius);\r\n \r\n return { radius, theta, phi };\r\n}\r\n\r\n/**\r\n * Convert spherical to Cartesian coordinates\r\n */\r\nexport function sphericalToCartesian(coords: SphericalCoords): Vector3D {\r\n return {\r\n x: coords.radius * Math.sin(coords.phi) * Math.cos(coords.theta),\r\n y: coords.radius * Math.cos(coords.phi),\r\n z: coords.radius * Math.sin(coords.phi) * Math.sin(coords.theta),\r\n };\r\n}\r\n\r\n/**\r\n * Convert ECI (Earth-Centered Inertial) to ECEF (Earth-Centered Earth-Fixed)\r\n * @param eci ECI position (km)\r\n * @param gmst Greenwich Mean Sidereal Time (radians)\r\n */\r\nexport function eciToEcef(eci: Vector3D, gmst: number): Vector3D {\r\n const cosGmst = Math.cos(gmst);\r\n const sinGmst = Math.sin(gmst);\r\n \r\n return {\r\n x: eci.x * cosGmst + eci.y * sinGmst,\r\n y: -eci.x * sinGmst + eci.y * cosGmst,\r\n z: eci.z,\r\n };\r\n}\r\n\r\n/**\r\n * Calculate Greenwich Mean Sidereal Time\r\n * @param date Date/time\r\n * @returns GMST in radians\r\n */\r\nexport function calculateGMST(date: Date): number {\r\n const jd = dateToJulianDate(date);\r\n const t = (jd - 2451545.0) / 36525.0;\r\n \r\n // GMST in degrees\r\n let gmst = 280.46061837 + \r\n 360.98564736629 * (jd - 2451545.0) + \r\n 0.000387933 * t * t - \r\n t * t * t / 38710000.0;\r\n \r\n // Normalize to 0-360\r\n gmst = gmst % 360;\r\n if (gmst < 0) gmst += 360;\r\n \r\n return gmst * DEG_TO_RAD;\r\n}\r\n\r\n/**\r\n * Convert Date to Julian Date\r\n */\r\nexport function dateToJulianDate(date: Date): number {\r\n const year = date.getUTCFullYear();\r\n const month = date.getUTCMonth() + 1;\r\n const day = date.getUTCDate();\r\n const hour = date.getUTCHours();\r\n const minute = date.getUTCMinutes();\r\n const second = date.getUTCSeconds();\r\n \r\n let y = year;\r\n let m = month;\r\n \r\n if (m <= 2) {\r\n y -= 1;\r\n m += 12;\r\n }\r\n \r\n const a = Math.floor(y / 100);\r\n const b = 2 - a + Math.floor(a / 4);\r\n \r\n const jd = Math.floor(365.25 * (y + 4716)) +\r\n Math.floor(30.6001 * (m + 1)) +\r\n day + b - 1524.5 +\r\n (hour + minute / 60 + second / 3600) / 24;\r\n \r\n return jd;\r\n}\r\n\r\n// =============================================================================\r\n// TLE Parsing & Propagation (Simplified SGP4)\r\n// =============================================================================\r\n\r\n/**\r\n * Parse Two-Line Element set\r\n */\r\nexport function parseTLE(line1: string, line2: string): TLEData | null {\r\n try {\r\n // Line 1 parsing\r\n const epochYear = parseInt(line1.substring(18, 20));\r\n const epochDay = parseFloat(line1.substring(20, 32));\r\n const bstar = parseScientificNotation(line1.substring(53, 61));\r\n \r\n // Line 2 parsing\r\n const inclination = parseFloat(line2.substring(8, 16));\r\n const raan = parseFloat(line2.substring(17, 25));\r\n const eccentricityStr = '0.' + line2.substring(26, 33);\r\n const eccentricity = parseFloat(eccentricityStr);\r\n const argOfPerigee = parseFloat(line2.substring(34, 42));\r\n const meanAnomaly = parseFloat(line2.substring(43, 51));\r\n const meanMotion = parseFloat(line2.substring(52, 63));\r\n \r\n // Calculate epoch date\r\n const fullYear = epochYear < 57 ? 2000 + epochYear : 1900 + epochYear;\r\n const epoch = new Date(Date.UTC(fullYear, 0, 1));\r\n epoch.setTime(epoch.getTime() + (epochDay - 1) * SECONDS_PER_DAY * 1000);\r\n \r\n return {\r\n line1,\r\n line2,\r\n epoch,\r\n meanMotion,\r\n eccentricity,\r\n inclination,\r\n raan,\r\n argOfPerigee,\r\n meanAnomaly,\r\n bstar,\r\n };\r\n } catch (e) {\r\n if (import.meta.env.DEV) {\r\n console.error('Failed to parse TLE:', e);\r\n }\r\n return null;\r\n }\r\n}\r\n\r\n/**\r\n * Parse TLE scientific notation (e.g., \" 12345-4\" = 0.12345e-4)\r\n */\r\nfunction parseScientificNotation(str: string): number {\r\n const trimmed = str.trim();\r\n if (trimmed.length === 0) return 0;\r\n \r\n // Find the position of the exponent sign\r\n let mantissa: string;\r\n let exponent: number;\r\n \r\n const lastSignPos = Math.max(trimmed.lastIndexOf('+'), trimmed.lastIndexOf('-'));\r\n if (lastSignPos > 0) {\r\n mantissa = '0.' + trimmed.substring(0, lastSignPos).replace(/[^0-9]/g, '');\r\n exponent = parseInt(trimmed.substring(lastSignPos));\r\n } else {\r\n mantissa = '0.' + trimmed.replace(/[^0-9]/g, '');\r\n exponent = 0;\r\n }\r\n \r\n return parseFloat(mantissa) * Math.pow(10, exponent);\r\n}\r\n\r\n/**\r\n * Simplified SGP4 propagation\r\n * @param tle TLE data\r\n * @param minutesFromEpoch Minutes from TLE epoch\r\n * @returns Position in km (ECI coordinates)\r\n */\r\nexport function propagateSGP4Simple(tle: TLEData, minutesFromEpoch: number): Vector3D {\r\n // Semi-major axis from mean motion\r\n const n = tle.meanMotion * 2 * Math.PI / MINUTES_PER_DAY; // rad/min\r\n const a = Math.pow(EARTH_MU / (n * n / 60 / 60), 1/3); // km\r\n \r\n // Current mean anomaly\r\n const M = (tle.meanAnomaly + (360 * tle.meanMotion * minutesFromEpoch / MINUTES_PER_DAY)) % 360;\r\n const M_rad = M * DEG_TO_RAD;\r\n \r\n // Solve Kepler's equation for eccentric anomaly (simplified Newton-Raphson)\r\n let E = M_rad;\r\n for (let i = 0; i < 10; i++) {\r\n E = M_rad + tle.eccentricity * Math.sin(E);\r\n }\r\n \r\n // True anomaly\r\n const cosE = Math.cos(E);\r\n const sinE = Math.sin(E);\r\n const nu = Math.atan2(\r\n Math.sqrt(1 - tle.eccentricity ** 2) * sinE,\r\n cosE - tle.eccentricity\r\n );\r\n \r\n // Distance\r\n const r = a * (1 - tle.eccentricity * cosE);\r\n \r\n // Position in orbital plane\r\n const xOrb = r * Math.cos(nu);\r\n const yOrb = r * Math.sin(nu);\r\n \r\n // RAAN and inclination with simple precession\r\n const raanRad = tle.raan * DEG_TO_RAD;\r\n const incRad = tle.inclination * DEG_TO_RAD;\r\n const argPeriRad = tle.argOfPerigee * DEG_TO_RAD;\r\n \r\n // Rotation matrices\r\n const cosRaan = Math.cos(raanRad);\r\n const sinRaan = Math.sin(raanRad);\r\n const cosInc = Math.cos(incRad);\r\n const sinInc = Math.sin(incRad);\r\n const cosArgPeri = Math.cos(argPeriRad);\r\n const sinArgPeri = Math.sin(argPeriRad);\r\n \r\n // Transform to ECI\r\n const x = (cosRaan * cosArgPeri - sinRaan * sinArgPeri * cosInc) * xOrb +\r\n (-cosRaan * sinArgPeri - sinRaan * cosArgPeri * cosInc) * yOrb;\r\n const y = (sinRaan * cosArgPeri + cosRaan * sinArgPeri * cosInc) * xOrb +\r\n (-sinRaan * sinArgPeri + cosRaan * cosArgPeri * cosInc) * yOrb;\r\n const z = (sinArgPeri * sinInc) * xOrb + (cosArgPeri * sinInc) * yOrb;\r\n \r\n return { x, y, z };\r\n}\r\n\r\n/**\r\n * Propagate satellite to specific time\r\n * @param satellite Satellite with TLE\r\n * @param time Target time\r\n * @returns Position in km (ECI)\r\n */\r\nexport function propagateSatellite(satellite: Satellite, time: Date): Vector3D | null {\r\n if (!satellite.tle) return null;\r\n \r\n const minutesFromEpoch = (time.getTime() - satellite.tle.epoch.getTime()) / 60000;\r\n return propagateSGP4Simple(satellite.tle, minutesFromEpoch);\r\n}\r\n\r\n/**\r\n * Generate orbit path for a satellite\r\n * @param satellite Satellite with TLE\r\n * @param startTime Start time\r\n * @param duration Duration in minutes\r\n * @param steps Number of steps\r\n */\r\nexport function generateOrbitPath(\r\n satellite: Satellite,\r\n startTime: Date,\r\n duration: number = 90,\r\n steps: number = 180\r\n): OrbitPath | null {\r\n if (!satellite.tle) return null;\r\n \r\n const points: Vector3D[] = [];\r\n const startMinutes = (startTime.getTime() - satellite.tle.epoch.getTime()) / 60000;\r\n \r\n for (let i = 0; i <= steps; i++) {\r\n const minutesFromEpoch = startMinutes + (i / steps) * duration;\r\n const pos = propagateSGP4Simple(satellite.tle, minutesFromEpoch);\r\n \r\n // Convert to scene coordinates\r\n points.push({\r\n x: pos.x * KM_TO_SCENE,\r\n y: pos.z * KM_TO_SCENE, // Swap Y/Z for Three.js\r\n z: pos.y * KM_TO_SCENE,\r\n });\r\n }\r\n \r\n return {\r\n objectId: satellite.id,\r\n points,\r\n timeSpan: duration * 60,\r\n isFuture: true,\r\n color: satellite.color || '#22d3ee',\r\n };\r\n}\r\n\r\n// =============================================================================\r\n// Coverage & Visibility Calculations\r\n// =============================================================================\r\n\r\n/**\r\n * Calculate if a satellite is visible from a ground station\r\n * @param satPos Satellite position (scene units)\r\n * @param gsLatLon Ground station lat/lon\r\n * @param minElevation Minimum elevation angle (degrees)\r\n */\r\nexport function isVisible(\r\n satPos: Vector3D,\r\n gsLatLon: { latitude: number; longitude: number },\r\n minElevation: number = 5\r\n): boolean {\r\n const gsPos = latLonAltToCartesian(gsLatLon.latitude, gsLatLon.longitude, 0);\r\n \r\n // Vector from ground station to satellite\r\n const toSat = {\r\n x: satPos.x - gsPos.x,\r\n y: satPos.y - gsPos.y,\r\n z: satPos.z - gsPos.z,\r\n };\r\n \r\n // Ground station \"up\" vector (normal to Earth surface)\r\n const up = normalize(gsPos);\r\n \r\n // Calculate elevation angle\r\n const elevation = 90 - Math.acos(dot(normalize(toSat), up)) * RAD_TO_DEG;\r\n \r\n return elevation >= minElevation;\r\n}\r\n\r\n/**\r\n * Calculate visibility cone vertices for rendering\r\n * @param gsLatLon Ground station position\r\n * @param minElevation Minimum elevation angle\r\n * @param altitude Altitude for cone apex (km)\r\n * @param segments Number of segments for cone circle\r\n */\r\nexport function calculateVisibilityConeGeometry(\r\n gsLatLon: { latitude: number; longitude: number },\r\n minElevation: number = 5,\r\n altitude: number = 2000,\r\n segments: number = 32\r\n): Vector3D[] {\r\n const gsPos = latLonAltToCartesian(gsLatLon.latitude, gsLatLon.longitude, 0);\r\n const vertices: Vector3D[] = [];\r\n \r\n // Cone apex is at ground station\r\n vertices.push(gsPos);\r\n \r\n // Calculate cone opening angle\r\n const coneAngle = (90 - minElevation) * DEG_TO_RAD;\r\n const coneRadius = altitude * Math.tan(coneAngle);\r\n \r\n // Get local tangent plane vectors\r\n const up = normalize(gsPos);\r\n const east = normalize(cross({ x: 0, y: 1, z: 0 }, up));\r\n const north = cross(up, east);\r\n \r\n // Generate cone base circle\r\n for (let i = 0; i <= segments; i++) {\r\n const angle = (i / segments) * Math.PI * 2;\r\n const basePoint = {\r\n x: gsPos.x + up.x * altitude * KM_TO_SCENE + \r\n (east.x * Math.cos(angle) + north.x * Math.sin(angle)) * coneRadius * KM_TO_SCENE,\r\n y: gsPos.y + up.y * altitude * KM_TO_SCENE + \r\n (east.y * Math.cos(angle) + north.y * Math.sin(angle)) * coneRadius * KM_TO_SCENE,\r\n z: gsPos.z + up.z * altitude * KM_TO_SCENE + \r\n (east.z * Math.cos(angle) + north.z * Math.sin(angle)) * coneRadius * KM_TO_SCENE,\r\n };\r\n vertices.push(basePoint);\r\n }\r\n \r\n return vertices;\r\n}\r\n\r\n/**\r\n * Generate footprint polygon for a satellite\r\n * @param satPos Satellite position (scene units)\r\n * @param minElevation Minimum elevation angle\r\n * @param segments Number of segments\r\n */\r\nexport function calculateFootprint(\r\n satPos: Vector3D,\r\n minElevation: number = 5,\r\n segments: number = 36\r\n): Array<{ latitude: number; longitude: number }> {\r\n const satLla = cartesianToLatLonAlt(satPos);\r\n const footprint: Array<{ latitude: number; longitude: number }> = [];\r\n \r\n // Simplified footprint calculation\r\n // Central angle of visibility circle\r\n const earthAngularRadius = Math.asin(EARTH_RADIUS_KM / (EARTH_RADIUS_KM + satLla.altitude));\r\n const elevAngle = minElevation * DEG_TO_RAD;\r\n const nadir = Math.PI / 2 - elevAngle - earthAngularRadius;\r\n const footprintRadius = nadir * RAD_TO_DEG;\r\n \r\n for (let i = 0; i < segments; i++) {\r\n const bearing = (i / segments) * 360;\r\n const point = destinationPoint(\r\n satLla.latitude,\r\n satLla.longitude,\r\n footprintRadius * 111.32, // Approximate km per degree\r\n bearing\r\n );\r\n footprint.push(point);\r\n }\r\n \r\n return footprint;\r\n}\r\n\r\n/**\r\n * Calculate destination point given start, distance, and bearing\r\n */\r\nfunction destinationPoint(\r\n lat: number,\r\n lon: number,\r\n distanceKm: number,\r\n bearing: number\r\n): { latitude: number; longitude: number } {\r\n const R = EARTH_RADIUS_KM;\r\n const d = distanceKm / R;\r\n const brng = bearing * DEG_TO_RAD;\r\n const lat1 = lat * DEG_TO_RAD;\r\n const lon1 = lon * DEG_TO_RAD;\r\n \r\n const lat2 = Math.asin(\r\n Math.sin(lat1) * Math.cos(d) +\r\n Math.cos(lat1) * Math.sin(d) * Math.cos(brng)\r\n );\r\n \r\n const lon2 = lon1 + Math.atan2(\r\n Math.sin(brng) * Math.sin(d) * Math.cos(lat1),\r\n Math.cos(d) - Math.sin(lat1) * Math.sin(lat2)\r\n );\r\n \r\n return {\r\n latitude: lat2 * RAD_TO_DEG,\r\n longitude: lon2 * RAD_TO_DEG,\r\n };\r\n}\r\n\r\n// =============================================================================\r\n// Hexagonal Grid for Coverage\r\n// =============================================================================\r\n\r\n/**\r\n * Generate a hexagonal grid covering the globe\r\n * @param resolution Grid resolution (lower = fewer hexagons)\r\n */\r\nexport function generateHexGrid(resolution: number = 2): CoverageHexGrid {\r\n const hexagons: CoverageHexGrid['hexagons'] = [];\r\n \r\n // Simplified hex grid generation (not true H3, but visually similar)\r\n const latStep = 180 / (resolution * 8);\r\n const lonStep = 360 / (resolution * 16);\r\n \r\n let id = 0;\r\n for (let lat = -90 + latStep / 2; lat < 90; lat += latStep) {\r\n // Adjust longitude step for latitude (narrower near poles)\r\n const adjustedLonStep = lonStep / Math.cos(lat * DEG_TO_RAD);\r\n const offset = (Math.floor((lat + 90) / latStep) % 2) * (adjustedLonStep / 2);\r\n \r\n for (let lon = -180 + offset; lon < 180; lon += adjustedLonStep) {\r\n hexagons.push({\r\n id: `hex_${id++}`,\r\n value: 0,\r\n center: { latitude: lat, longitude: lon },\r\n });\r\n }\r\n }\r\n \r\n return {\r\n resolution,\r\n hexagons,\r\n colorScale: { min: '#1a1a2e', max: '#00ff88' },\r\n };\r\n}\r\n\r\n/**\r\n * Update hex grid coverage values based on satellite footprints\r\n */\r\nexport function updateHexGridCoverage(\r\n grid: CoverageHexGrid,\r\n satellites: Satellite[],\r\n time: Date\r\n): CoverageHexGrid {\r\n // Clone grid\r\n const updatedGrid = {\r\n ...grid,\r\n hexagons: grid.hexagons.map(h => ({ ...h, value: 0 })),\r\n };\r\n \r\n satellites.forEach(sat => {\r\n const pos = propagateSatellite(sat, time);\r\n if (!pos) return;\r\n \r\n const scenePos = {\r\n x: pos.x * KM_TO_SCENE,\r\n y: pos.z * KM_TO_SCENE,\r\n z: pos.y * KM_TO_SCENE,\r\n };\r\n \r\n const footprint = calculateFootprint(scenePos);\r\n \r\n // Check each hexagon\r\n updatedGrid.hexagons.forEach(hex => {\r\n const inFootprint = isPointInPolygon(hex.center, footprint);\r\n if (inFootprint) {\r\n hex.value = Math.min(1, hex.value + 0.2);\r\n }\r\n });\r\n });\r\n \r\n return updatedGrid;\r\n}\r\n\r\n/**\r\n * Point-in-polygon test (simplified)\r\n */\r\nfunction isPointInPolygon(\r\n point: { latitude: number; longitude: number },\r\n polygon: Array<{ latitude: number; longitude: number }>\r\n): boolean {\r\n let inside = false;\r\n const n = polygon.length;\r\n \r\n for (let i = 0, j = n - 1; i < n; j = i++) {\r\n const xi = polygon[i].longitude;\r\n const yi = polygon[i].latitude;\r\n const xj = polygon[j].longitude;\r\n const yj = polygon[j].latitude;\r\n \r\n if (((yi > point.latitude) !== (yj > point.latitude)) &&\r\n (point.longitude < (xj - xi) * (point.latitude - yi) / (yj - yi) + xi)) {\r\n inside = !inside;\r\n }\r\n }\r\n \r\n return inside;\r\n}\r\n\r\n// =============================================================================\r\n// Vector Math Utilities\r\n// =============================================================================\r\n\r\nexport function dot(a: Vector3D, b: Vector3D): number {\r\n return a.x * b.x + a.y * b.y + a.z * b.z;\r\n}\r\n\r\nexport function cross(a: Vector3D, b: Vector3D): Vector3D {\r\n return {\r\n x: a.y * b.z - a.z * b.y,\r\n y: a.z * b.x - a.x * b.z,\r\n z: a.x * b.y - a.y * b.x,\r\n };\r\n}\r\n\r\nexport function magnitude(v: Vector3D): number {\r\n return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);\r\n}\r\n\r\nexport function normalize(v: Vector3D): Vector3D {\r\n const m = magnitude(v);\r\n if (m === 0) return { x: 0, y: 0, z: 0 };\r\n return { x: v.x / m, y: v.y / m, z: v.z / m };\r\n}\r\n\r\nexport function subtract(a: Vector3D, b: Vector3D): Vector3D {\r\n return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z };\r\n}\r\n\r\nexport function add(a: Vector3D, b: Vector3D): Vector3D {\r\n return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z };\r\n}\r\n\r\nexport function scale(v: Vector3D, s: number): Vector3D {\r\n return { x: v.x * s, y: v.y * s, z: v.z * s };\r\n}\r\n\r\nexport function distance(a: Vector3D, b: Vector3D): number {\r\n return magnitude(subtract(a, b));\r\n}\r\n\r\nexport function lerp(a: Vector3D, b: Vector3D, t: number): Vector3D {\r\n return {\r\n x: a.x + (b.x - a.x) * t,\r\n y: a.y + (b.y - a.y) * t,\r\n z: a.z + (b.z - a.z) * t,\r\n };\r\n}\r\n\r\n// =============================================================================\r\n// Animation Utilities\r\n// =============================================================================\r\n\r\n/**\r\n * Ease-out cubic function\r\n */\r\nexport function easeOutCubic(t: number): number {\r\n return 1 - Math.pow(1 - t, 3);\r\n}\r\n\r\n/**\r\n * Ease-in-out cubic function\r\n */\r\nexport function easeInOutCubic(t: number): number {\r\n return t < 0.5\r\n ? 4 * t * t * t\r\n : 1 - Math.pow(-2 * t + 2, 3) / 2;\r\n}\r\n\r\n/**\r\n * Smooth step function\r\n */\r\nexport function smoothstep(edge0: number, edge1: number, x: number): number {\r\n const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));\r\n return t * t * (3 - 2 * t);\r\n}\r\n\r\n// =============================================================================\r\n// Color Utilities\r\n// =============================================================================\r\n\r\n/**\r\n * Interpolate between two colors\r\n */\r\nexport function lerpColor(color1: string, color2: string, t: number): string {\r\n const c1 = hexToRgb(color1);\r\n const c2 = hexToRgb(color2);\r\n \r\n if (!c1 || !c2) return color1;\r\n \r\n const r = Math.round(c1.r + (c2.r - c1.r) * t);\r\n const g = Math.round(c1.g + (c2.g - c1.g) * t);\r\n const b = Math.round(c1.b + (c2.b - c1.b) * t);\r\n \r\n return `rgb(${r}, ${g}, ${b})`;\r\n}\r\n\r\n/**\r\n * Hex to RGB\r\n */\r\nexport function hexToRgb(hex: string): { r: number; g: number; b: number } | null {\r\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\r\n return result\r\n ? {\r\n r: parseInt(result[1], 16),\r\n g: parseInt(result[2], 16),\r\n b: parseInt(result[3], 16),\r\n }\r\n : null;\r\n}\r\n\r\n/**\r\n * Get color from value (0-1) on a gradient scale\r\n */\r\nexport function getGradientColor(\r\n value: number,\r\n colors: string[] = ['#1a1a2e', '#0f3460', '#16c79a', '#ff6b6b']\r\n): string {\r\n const clampedValue = Math.max(0, Math.min(1, value));\r\n const scaledValue = clampedValue * (colors.length - 1);\r\n const index = Math.floor(scaledValue);\r\n const t = scaledValue - index;\r\n \r\n if (index >= colors.length - 1) return colors[colors.length - 1];\r\n \r\n return lerpColor(colors[index], colors[index + 1], t);\r\n}\r\n\r\n// =============================================================================\r\n// Format Utilities\r\n// =============================================================================\r\n\r\n/**\r\n * Format time duration as HH:MM:SS\r\n */\r\nexport function formatDuration(seconds: number): string {\r\n const h = Math.floor(seconds / 3600);\r\n const m = Math.floor((seconds % 3600) / 60);\r\n const s = Math.floor(seconds % 60);\r\n \r\n return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;\r\n}\r\n\r\n/**\r\n * Format distance in appropriate units\r\n */\r\nexport function formatDistance(km: number): string {\r\n if (km < 1) {\r\n return `${(km * 1000).toFixed(0)} m`;\r\n } else if (km < 1000) {\r\n return `${km.toFixed(1)} km`;\r\n } else if (km < AU_KM / 100) {\r\n return `${(km / 1000).toFixed(2)} thousand km`;\r\n } else {\r\n return `${(km / AU_KM).toFixed(4)} AU`;\r\n }\r\n}\r\n\r\n/**\r\n * Format velocity\r\n */\r\nexport function formatVelocity(kmPerSec: number): string {\r\n return `${kmPerSec.toFixed(3)} km/s`;\r\n}\r\n\r\n/**\r\n * Format date/time for display\r\n */\r\nexport function formatDateTime(date: Date): string {\r\n return date.toISOString().replace('T', ' ').split('.')[0] + ' UTC';\r\n}\r\n"],"names":[],"mappings":"AAuBO,MAAM,kBAAkB;AASxB,MAAM,aAAa,KAAK,KAAK;AAG7B,MAAM,aAAa,MAAM,KAAK;"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Lightweight PNG analysis for capture QA (brightness / "all black" detection).
3
+ * Used by tests and optional runtime checks — keep dependency-free (no pngjs).
4
+ */
5
+ /** ITU-R BT.709 luma from RGB */
6
+ export declare function rgbToLuma(r: number, g: number, b: number): number;
7
+ /**
8
+ * Sample mean luma of PNG bytes by drawing to a small canvas (browser / happy-dom).
9
+ * Returns 0 if decode fails. For very large images, samples a grid (max ~4096 samples).
10
+ */
11
+ export declare function meanLuminanceFromPngBytes(png: Uint8Array): Promise<number>;
12
+ /**
13
+ * True if the image is uniformly dark (likely failed render / empty GL buffer).
14
+ * Threshold is mean luma on 0–255 scale (default 5 ≈ pure black).
15
+ */
16
+ export declare function isPngMostlyBlack(png: Uint8Array, threshold?: number): Promise<boolean>;
@@ -15,6 +15,10 @@ export type { ZenSpace3DCesiumProps } from './ZenSpace3DCesium';
15
15
  export type { ZenSpace3DProps, ZenSpace3DHandle, ViewMode, CameraMode, ObjectCategory, SpaceObject, Satellite, Debris, SpaceStation, Asteroid, TLEData, OrbitPath, ManeuverNode, VisibilityCone, SatelliteCoverage, CoverageRegion, CoverageHexGrid, OverpassInfo, OrbitDesignArc, CoverageCell, TimeState, SelectionState, CameraState, LayerVisibility, ToolType, ToolState, Vector3D, LatLonAlt, SphericalCoords, SceneConfig, SceneObject, ZenSpace3DCallbacks, } from './ZenSpace3DTypes';
16
16
  export { latLonAltToCartesian, cartesianToLatLonAlt, cartesianToSpherical, sphericalToCartesian, eciToEcef, calculateGMST, dateToJulianDate, parseTLE, propagateSGP4Simple, propagateSatellite, generateOrbitPath, isVisible, calculateVisibilityConeGeometry, calculateFootprint, generateHexGrid, updateHexGridCoverage, dot, cross, magnitude, normalize, subtract, add, scale, distance, lerp, easeOutCubic, easeInOutCubic, smoothstep, lerpColor, hexToRgb, getGradientColor, formatDuration, formatDistance, formatVelocity, formatDateTime, EARTH_RADIUS_KM, EARTH_MU, AU_KM, DEG_TO_RAD, RAD_TO_DEG, J2000_EPOCH, SCENE_EARTH_RADIUS, KM_TO_SCENE, } from './ZenSpace3DUtils';
17
17
  export { atmosphereVertexShader, atmosphereFragmentShader, starsVertexShader, starsFragmentShader, coverageVertexShader, coverageFragmentShader, visibilityConeVertexShader, visibilityConeFragmentShader, orbitLineVertexShader, orbitLineFragmentShader, objectGlowVertexShader, objectGlowFragmentShader, selectionRingVertexShader, selectionRingFragmentShader, terminatorVertexShader, terminatorFragmentShader, gridVertexShader, gridFragmentShader, planetRingVertexShader, planetRingFragmentShader, debrisVertexShader, debrisFragmentShader, createAtmosphereUniforms, createStarsUniforms, createVisibilityConeUniforms, } from './ZenSpace3DShaders';
18
+ export { createCesiumCaptureSource, ecefMetersToSdkPosition, eulerXyzToHpr, ecefEulerXyzToHpr, computeDofBlurPx } from './CesiumCaptureSource';
19
+ export type { CesiumCaptureSourceOptions, CesiumCaptureSourceHandle } from './CesiumCaptureSource';
20
+ /** PNG brightness helpers for capture QA (e.g. detect all-black frames) */
21
+ export { rgbToLuma, meanLuminanceFromPngBytes, isPngMostlyBlack } from './capturePngAnalysis';
18
22
  export { loadThree, isThreeLoaded } from './threeLoader';
19
23
  export type { ThreeModule, OrbitControlsType } from './threeLoader';
20
24
  export { EarthViewer } from './EarthViewer';
@@ -0,0 +1,140 @@
1
+ import { default as React } from 'react';
2
+
3
+ /** Camera capture arguments */
4
+ export interface CaptureArgs {
5
+ monochromatic: boolean;
6
+ resolution: number;
7
+ coc: number;
8
+ pixel_pitch: number;
9
+ focusing_distance: number;
10
+ aperture: number;
11
+ focal_length: number;
12
+ fov: number;
13
+ sample: boolean;
14
+ position: [number, number, number];
15
+ rotation: [number, number, number];
16
+ /**
17
+ * Output image format. Defaults to 'png' when omitted.
18
+ * - 'png' → lossless, larger file (good for scientific/archival use)
19
+ * - 'jpeg' → lossy, smaller file (~3-5× smaller than PNG at q=0.92)
20
+ */
21
+ format?: 'png' | 'jpeg';
22
+ /**
23
+ * JPEG quality factor (0–1). Only used when format='jpeg'. Default: 0.92.
24
+ * Ignored for PNG (which is always lossless).
25
+ */
26
+ jpeg_quality?: number;
27
+ }
28
+ /** Incoming capture request */
29
+ export interface CaptureRequest {
30
+ type: 'capture';
31
+ req_id: number;
32
+ args: CaptureArgs;
33
+ }
34
+ /** Imperative handle exposed via ref */
35
+ export interface CaptureHandle {
36
+ /** Process a capture request, returns image bytes */
37
+ capture: (request: CaptureRequest) => Promise<Uint8Array>;
38
+ /** The server's unique ID */
39
+ serverId: string;
40
+ }
41
+ /** Captured image entry stored for gallery display */
42
+ export interface CapturedImage {
43
+ reqId: number;
44
+ dataUrl: string;
45
+ timestamp: number;
46
+ /** Raw image byte count */
47
+ bytes: number;
48
+ /** The CaptureArgs used for this capture (camera settings, position, etc.) */
49
+ args?: CaptureArgs;
50
+ }
51
+ export interface CaptureProps {
52
+ /** Whether host transport is connected (drives status indicator) */
53
+ connected?: boolean;
54
+ /**
55
+ * Image source for capture responses. Three tiers:
56
+ * 1. `Uint8Array` -- static bytes returned for every request
57
+ * 2. `(args) => Promise<Uint8Array>` -- async renderer (e.g. CesiumCaptureSource)
58
+ * 3. `undefined` (default) -- auto-detects CesiumJS; falls back to placeholder image
59
+ */
60
+ imageSource?: Uint8Array | ((args: CaptureArgs) => Promise<Uint8Array>);
61
+ /**
62
+ * Pre-load the CesiumJS renderer on mount instead of waiting for the first
63
+ * capture request. Only applies when `imageSource` is not provided (auto-detect mode).
64
+ * @default true
65
+ */
66
+ preload?: boolean;
67
+ /**
68
+ * Explicit server ID. When provided the component uses this value instead
69
+ * of generating a new UUID. Useful for persisting across reloads via localStorage.
70
+ */
71
+ serverId?: string;
72
+ /** Fired after each capture is processed */
73
+ onCapture?: (request: CaptureRequest) => void;
74
+ /** Called once on mount with the server ID */
75
+ onReady?: (serverId: string) => void;
76
+ /**
77
+ * Start with the image gallery expanded. When true, the component renders
78
+ * in a flex-column layout designed to fill its container height.
79
+ * @default false
80
+ */
81
+ defaultGalleryExpanded?: boolean;
82
+ /** Custom style */
83
+ style?: React.CSSProperties;
84
+ }
85
+ /**
86
+ * Bundled astronaut placeholder PNG (or 1x1 fallback). Exported for
87
+ * `CesiumCaptureSource` when globe capture fails (e.g. missing Cesium assets, WebGL).
88
+ */
89
+ export declare function loadCapturePlaceholderImage(): Promise<Uint8Array>;
90
+ export interface UseCaptureOptions {
91
+ /** Image to return: static bytes or async function receiving capture args */
92
+ imageSource?: Uint8Array | ((args: CaptureArgs) => Promise<Uint8Array>);
93
+ /**
94
+ * Pre-load the CesiumJS renderer on mount (only when imageSource is undefined).
95
+ * Set to false for lazy init on first capture request.
96
+ * @default true
97
+ */
98
+ preload?: boolean;
99
+ /**
100
+ * Explicit server ID. When provided the hook uses this value instead of
101
+ * generating a new `crypto.randomUUID()`. Useful for persisting the ID
102
+ * across page reloads (e.g. from localStorage).
103
+ */
104
+ serverId?: string;
105
+ }
106
+ export interface UseCaptureResult {
107
+ /** Stable unique server ID */
108
+ serverId: string;
109
+ /** Process a capture request, returns image bytes (empty if paused) */
110
+ capture: (request: CaptureRequest) => Promise<Uint8Array>;
111
+ /** Whether capture processing is paused */
112
+ paused: boolean;
113
+ /** Toggle pause state */
114
+ setPaused: (paused: boolean) => void;
115
+ /** Number of successfully processed requests */
116
+ requestCount: number;
117
+ /** Most recent request */
118
+ lastRequest: CaptureRequest | null;
119
+ /** Gallery of captured images (newest first) */
120
+ capturedImages: CapturedImage[];
121
+ }
122
+ /**
123
+ * Hook for capture server logic. Generates a stable UUID, manages
124
+ * pause state, and processes capture requests into image bytes.
125
+ *
126
+ * @example
127
+ * ```tsx
128
+ * const { serverId, capture, paused, setPaused } = useCapture();
129
+ * // call capture(request) when external message arrives
130
+ * ```
131
+ */
132
+ export declare function useCapture(options?: UseCaptureOptions): UseCaptureResult;
133
+ /**
134
+ * Capture server UI component. Displays server status, pause/resume control,
135
+ * copy-ID button, and a collapsible gallery of captured images.
136
+ *
137
+ * Access `capture()` and `serverId` imperatively via the forwarded ref.
138
+ */
139
+ export declare const Capture: React.MemoExoticComponent<React.ForwardRefExoticComponent<CaptureProps & React.RefAttributes<CaptureHandle>>>;
140
+ export default Capture;