mirror of
https://github.com/lighttransport/tinyusdz.git
synced 2026-01-18 01:11:17 +01:00
Add Three.js UsdLux demo with tone mapping and spectral support
- Add usdlux.html/js: Interactive Three.js demo for USD light visualization - Map USD light types to Three.js lights (Point, Spot, Directional, RectArea, Hemisphere) - Visual helpers for finite lights (spheres, disks, arrows) - Central sphere with MeshPhysicalMaterial for lighting preview - Embedded USDA scenes for quick testing - File dialog and drag-n-drop USD loading - Add tone mapping options: Raw, Reinhard, ACES 1.3, ACES 2.0, AgX, Neutral - Custom ACES 2.0 shader with AP0/AP1 matrices, gamut mapping, highlight desaturation - Exposure (-3 to +3 EV) and gamma controls - Add spectral rendering support: - CIE 1931 2-degree standard observer color matching functions - SPD to XYZ to sRGB conversion with proper gamut mapping - Spectral curve canvas visualization with rainbow gradient - Color modes: RGB (USD color), Spectral (SPD to RGB), Monochrome (single wavelength) - Demo SPD presets: D65, Illuminant A, LED spectra, sodium lamp - Blackbody spectrum generator (1000K-10000K) - Fix RenderLight enum naming: lightType -> type, LightType -> Type, Directional -> Distant - Improve dump-usdlux-cli.js with spectral display, transform formatting, XML output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -369,28 +369,34 @@ nonstd::expected<std::string, std::string> serializeLight(
|
||||
|
||||
// Light type
|
||||
json << "\"type\": \"";
|
||||
switch (light.lightType) {
|
||||
case RenderLight::LightType::Point:
|
||||
switch (light.type) {
|
||||
case RenderLight::Type::Point:
|
||||
json << "Point";
|
||||
break;
|
||||
case RenderLight::LightType::Directional:
|
||||
json << "Directional";
|
||||
case RenderLight::Type::Sphere:
|
||||
json << "Sphere";
|
||||
break;
|
||||
case RenderLight::LightType::Rect:
|
||||
case RenderLight::Type::Distant:
|
||||
json << "Distant";
|
||||
break;
|
||||
case RenderLight::Type::Rect:
|
||||
json << "Rect";
|
||||
break;
|
||||
case RenderLight::LightType::Disk:
|
||||
case RenderLight::Type::Disk:
|
||||
json << "Disk";
|
||||
break;
|
||||
case RenderLight::LightType::Cylinder:
|
||||
case RenderLight::Type::Cylinder:
|
||||
json << "Cylinder";
|
||||
break;
|
||||
case RenderLight::LightType::Dome:
|
||||
case RenderLight::Type::Dome:
|
||||
json << "Dome";
|
||||
break;
|
||||
case RenderLight::LightType::Geometry:
|
||||
case RenderLight::Type::Geometry:
|
||||
json << "Geometry";
|
||||
break;
|
||||
case RenderLight::Type::Portal:
|
||||
json << "Portal";
|
||||
break;
|
||||
}
|
||||
json << "\",";
|
||||
|
||||
@@ -429,18 +435,19 @@ nonstd::expected<std::string, std::string> serializeLight(
|
||||
// Type-specific properties
|
||||
json << "\"properties\": {";
|
||||
|
||||
switch (light.lightType) {
|
||||
case RenderLight::LightType::Point:
|
||||
case RenderLight::LightType::Disk:
|
||||
switch (light.type) {
|
||||
case RenderLight::Type::Point:
|
||||
case RenderLight::Type::Sphere:
|
||||
case RenderLight::Type::Disk:
|
||||
json << "\"radius\": " << light.radius;
|
||||
break;
|
||||
|
||||
case RenderLight::LightType::Cylinder:
|
||||
case RenderLight::Type::Cylinder:
|
||||
json << "\"radius\": " << light.radius << ",";
|
||||
json << "\"length\": " << light.length;
|
||||
break;
|
||||
|
||||
case RenderLight::LightType::Rect:
|
||||
case RenderLight::Type::Rect:
|
||||
json << "\"width\": " << light.width << ",";
|
||||
json << "\"height\": " << light.height;
|
||||
if (!light.textureFile.empty()) {
|
||||
@@ -448,11 +455,11 @@ nonstd::expected<std::string, std::string> serializeLight(
|
||||
}
|
||||
break;
|
||||
|
||||
case RenderLight::LightType::Directional:
|
||||
case RenderLight::Type::Distant:
|
||||
json << "\"angle\": " << light.angle;
|
||||
break;
|
||||
|
||||
case RenderLight::LightType::Dome:
|
||||
case RenderLight::Type::Dome:
|
||||
json << "\"textureFormat\": \"";
|
||||
switch (light.domeTextureFormat) {
|
||||
case RenderLight::DomeTextureFormat::Automatic:
|
||||
@@ -475,7 +482,7 @@ nonstd::expected<std::string, std::string> serializeLight(
|
||||
}
|
||||
break;
|
||||
|
||||
case RenderLight::LightType::Geometry:
|
||||
case RenderLight::Type::Geometry:
|
||||
json << "\"geometryMeshId\": " << light.geometry_mesh_id << ",";
|
||||
json << "\"materialSyncMode\": \"" << light.material_sync_mode << "\"";
|
||||
|
||||
@@ -487,6 +494,10 @@ nonstd::expected<std::string, std::string> serializeLight(
|
||||
json << ",\"meshPath\": \"" << mesh.abs_path << "\"";
|
||||
}
|
||||
break;
|
||||
|
||||
case RenderLight::Type::Portal:
|
||||
// Portal lights don't have special properties
|
||||
break;
|
||||
}
|
||||
|
||||
json << "}"; // Close properties
|
||||
|
||||
@@ -8712,7 +8712,7 @@ bool RenderSceneConverter::ConvertSphereLight(
|
||||
RenderLight rlight;
|
||||
rlight.name = light.name;
|
||||
rlight.abs_path = light_abs_path.full_path_name();
|
||||
rlight.lightType = RenderLight::LightType::Point;
|
||||
rlight.type = RenderLight::Type::Sphere;
|
||||
|
||||
// Extract common properties
|
||||
if (!ExtractCommonLightProperties(env, light, &rlight)) {
|
||||
@@ -8749,7 +8749,7 @@ bool RenderSceneConverter::ConvertDistantLight(
|
||||
RenderLight rlight;
|
||||
rlight.name = light.name;
|
||||
rlight.abs_path = light_abs_path.full_path_name();
|
||||
rlight.lightType = RenderLight::LightType::Directional;
|
||||
rlight.type = RenderLight::Type::Distant;
|
||||
|
||||
// Extract common properties
|
||||
if (!ExtractCommonLightProperties(env, light, &rlight)) {
|
||||
@@ -8781,7 +8781,7 @@ bool RenderSceneConverter::ConvertDomeLight(
|
||||
RenderLight rlight;
|
||||
rlight.name = light.name;
|
||||
rlight.abs_path = light_abs_path.full_path_name();
|
||||
rlight.lightType = RenderLight::LightType::Dome;
|
||||
rlight.type = RenderLight::Type::Dome;
|
||||
|
||||
// Extract common properties
|
||||
if (!ExtractCommonLightProperties(env, light, &rlight)) {
|
||||
@@ -8847,7 +8847,7 @@ bool RenderSceneConverter::ConvertRectLight(
|
||||
RenderLight rlight;
|
||||
rlight.name = light.name;
|
||||
rlight.abs_path = light_abs_path.full_path_name();
|
||||
rlight.lightType = RenderLight::LightType::Rect;
|
||||
rlight.type = RenderLight::Type::Rect;
|
||||
|
||||
// Extract common properties
|
||||
if (!ExtractCommonLightProperties(env, light, &rlight)) {
|
||||
@@ -8900,7 +8900,7 @@ bool RenderSceneConverter::ConvertDiskLight(
|
||||
RenderLight rlight;
|
||||
rlight.name = light.name;
|
||||
rlight.abs_path = light_abs_path.full_path_name();
|
||||
rlight.lightType = RenderLight::LightType::Disk;
|
||||
rlight.type = RenderLight::Type::Disk;
|
||||
|
||||
// Extract common properties
|
||||
if (!ExtractCommonLightProperties(env, light, &rlight)) {
|
||||
@@ -8932,7 +8932,7 @@ bool RenderSceneConverter::ConvertCylinderLight(
|
||||
RenderLight rlight;
|
||||
rlight.name = light.name;
|
||||
rlight.abs_path = light_abs_path.full_path_name();
|
||||
rlight.lightType = RenderLight::LightType::Cylinder;
|
||||
rlight.type = RenderLight::Type::Cylinder;
|
||||
|
||||
// Extract common properties
|
||||
if (!ExtractCommonLightProperties(env, light, &rlight)) {
|
||||
@@ -8972,7 +8972,7 @@ bool RenderSceneConverter::ConvertGeometryLight(
|
||||
RenderLight rlight;
|
||||
rlight.name = light.name;
|
||||
rlight.abs_path = light_abs_path.full_path_name();
|
||||
rlight.lightType = RenderLight::LightType::Geometry;
|
||||
rlight.type = RenderLight::Type::Geometry;
|
||||
|
||||
// Extract common properties
|
||||
if (!ExtractCommonLightProperties(env, light, &rlight)) {
|
||||
|
||||
@@ -1025,8 +1025,9 @@ json ThreeJSSceneExporter::ConvertLight(const RenderLight& light) {
|
||||
light_json["intensity"] = effective_intensity;
|
||||
|
||||
// Map RenderLight types to Three.js light types
|
||||
switch (light.lightType) {
|
||||
case RenderLight::LightType::Point:
|
||||
switch (light.type) {
|
||||
case RenderLight::Type::Point:
|
||||
case RenderLight::Type::Sphere:
|
||||
light_json["type"] = "PointLight";
|
||||
// Three.js PointLight doesn't have a physical radius, but we can store it in userData
|
||||
if (light.radius > 0.0f) {
|
||||
@@ -1038,7 +1039,7 @@ json ThreeJSSceneExporter::ConvertLight(const RenderLight& light) {
|
||||
light_json["decay"] = 2.0f;
|
||||
break;
|
||||
|
||||
case RenderLight::LightType::Directional:
|
||||
case RenderLight::Type::Distant:
|
||||
light_json["type"] = "DirectionalLight";
|
||||
// Directional lights have uniform intensity, no attenuation
|
||||
// Store angle for disk shape if specified
|
||||
@@ -1050,7 +1051,7 @@ json ThreeJSSceneExporter::ConvertLight(const RenderLight& light) {
|
||||
}
|
||||
break;
|
||||
|
||||
case RenderLight::LightType::Rect:
|
||||
case RenderLight::Type::Rect:
|
||||
// Three.js has RectAreaLight (requires RectAreaLightUniformsLib)
|
||||
light_json["type"] = "RectAreaLight";
|
||||
light_json["width"] = light.width;
|
||||
@@ -1065,7 +1066,7 @@ json ThreeJSSceneExporter::ConvertLight(const RenderLight& light) {
|
||||
}
|
||||
break;
|
||||
|
||||
case RenderLight::LightType::Disk:
|
||||
case RenderLight::Type::Disk:
|
||||
// Three.js doesn't have a disk light, use PointLight as approximation
|
||||
light_json["type"] = "PointLight";
|
||||
light_json["distance"] = 0.0f;
|
||||
@@ -1077,7 +1078,7 @@ json ThreeJSSceneExporter::ConvertLight(const RenderLight& light) {
|
||||
};
|
||||
break;
|
||||
|
||||
case RenderLight::LightType::Cylinder:
|
||||
case RenderLight::Type::Cylinder:
|
||||
// Cylinder lights with shaping are like spotlights
|
||||
if (light.shapingConeAngle > 0.0f) {
|
||||
light_json["type"] = "SpotLight";
|
||||
@@ -1112,7 +1113,7 @@ json ThreeJSSceneExporter::ConvertLight(const RenderLight& light) {
|
||||
}
|
||||
break;
|
||||
|
||||
case RenderLight::LightType::Dome:
|
||||
case RenderLight::Type::Dome:
|
||||
// Dome lights are environment maps, closest is HemisphereLight or PMREMGenerator
|
||||
light_json["type"] = "HemisphereLight";
|
||||
// HemisphereLight has groundColor (opposite hemisphere)
|
||||
@@ -1146,7 +1147,7 @@ json ThreeJSSceneExporter::ConvertLight(const RenderLight& light) {
|
||||
}
|
||||
break;
|
||||
|
||||
case RenderLight::LightType::Geometry:
|
||||
case RenderLight::Type::Geometry:
|
||||
// Geometry lights are meshes with emissive materials
|
||||
light_json["type"] = "MeshEmissive";
|
||||
light_json["geometry_mesh_id"] = light.geometry_mesh_id;
|
||||
@@ -1158,6 +1159,11 @@ json ThreeJSSceneExporter::ConvertLight(const RenderLight& light) {
|
||||
light_json["userData"]["material_sync_mode"] = light.material_sync_mode;
|
||||
}
|
||||
break;
|
||||
|
||||
case RenderLight::Type::Portal:
|
||||
// Portal lights are special lights for portals
|
||||
light_json["type"] = "PortalLight";
|
||||
break;
|
||||
}
|
||||
|
||||
// Shadow properties (if shadow is enabled)
|
||||
@@ -1192,7 +1198,7 @@ json ThreeJSSceneExporter::ConvertLight(const RenderLight& light) {
|
||||
}
|
||||
|
||||
// Shaping properties for lights that support them (but aren't spotlights)
|
||||
if (light.lightType != RenderLight::LightType::Cylinder &&
|
||||
if (light.type != RenderLight::Type::Cylinder &&
|
||||
(light.shapingConeAngle > 0.0f || light.shapingFocus != 0.0f || !light.shapingIesFile.empty())) {
|
||||
light_json["userData"]["shaping"] = {
|
||||
{"focus", light.shapingFocus},
|
||||
|
||||
@@ -12,13 +12,18 @@ function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
inputFile: null,
|
||||
format: 'json', // 'json', 'yaml', or 'summary'
|
||||
format: 'json', // 'json', 'yaml', 'summary', or 'xml'
|
||||
outputFile: null,
|
||||
lightId: null, // null means all lights
|
||||
pretty: true,
|
||||
verbose: false,
|
||||
showMeshes: false, // Show mesh light geometries
|
||||
showMaterials: false // Show material info for mesh lights
|
||||
showMaterials: false, // Show material info for mesh lights
|
||||
showTransform: false, // Show transform matrices
|
||||
showSpectral: false, // Show spectral emission data
|
||||
showAll: false, // Show all optional data
|
||||
serialized: false, // Use serialized format from getLightWithFormat
|
||||
colorMode: 'rgb' // 'rgb', 'hex', or 'kelvin'
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -29,8 +34,8 @@ function parseArgs() {
|
||||
process.exit(0);
|
||||
} else if (arg === '-f' || arg === '--format') {
|
||||
options.format = args[++i];
|
||||
if (!['json', 'yaml', 'summary'].includes(options.format)) {
|
||||
console.error('Error: Format must be "json", "yaml", or "summary"');
|
||||
if (!['json', 'yaml', 'summary', 'xml'].includes(options.format)) {
|
||||
console.error('Error: Format must be "json", "yaml", "summary", or "xml"');
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (arg === '-o' || arg === '--output') {
|
||||
@@ -45,6 +50,24 @@ function parseArgs() {
|
||||
options.showMeshes = true;
|
||||
} else if (arg === '--show-materials') {
|
||||
options.showMaterials = true;
|
||||
} else if (arg === '--show-transform' || arg === '-t') {
|
||||
options.showTransform = true;
|
||||
} else if (arg === '--show-spectral' || arg === '-s') {
|
||||
options.showSpectral = true;
|
||||
} else if (arg === '--all' || arg === '-a') {
|
||||
options.showAll = true;
|
||||
options.showMeshes = true;
|
||||
options.showMaterials = true;
|
||||
options.showTransform = true;
|
||||
options.showSpectral = true;
|
||||
} else if (arg === '--serialized') {
|
||||
options.serialized = true;
|
||||
} else if (arg === '--color-mode') {
|
||||
options.colorMode = args[++i];
|
||||
if (!['rgb', 'hex', 'kelvin'].includes(options.colorMode)) {
|
||||
console.error('Error: Color mode must be "rgb", "hex", or "kelvin"');
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (!options.inputFile) {
|
||||
options.inputFile = arg;
|
||||
}
|
||||
@@ -56,6 +79,11 @@ function parseArgs() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// XML format requires serialized mode
|
||||
if (options.format === 'xml') {
|
||||
options.serialized = true;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -69,15 +97,31 @@ Arguments:
|
||||
<usd-file> USD/USDA/USDC/USDZ file to load
|
||||
|
||||
Options:
|
||||
-f, --format <format> Output format: 'json', 'yaml', or 'summary' (default: json)
|
||||
-f, --format <format> Output format: 'json', 'yaml', 'summary', or 'xml' (default: json)
|
||||
-o, --output <file> Write output to file instead of stdout
|
||||
-l, --light <id> Dump only specific light by ID (default: all)
|
||||
--no-pretty Disable pretty-printing for JSON/YAML
|
||||
-v, --verbose Enable verbose logging
|
||||
-a, --all Show all optional data (meshes, materials, transform, spectral)
|
||||
-t, --show-transform Include transform matrices in output
|
||||
-s, --show-spectral Include spectral emission data (LTE SpectralAPI)
|
||||
--show-meshes Include mesh geometry data for mesh lights
|
||||
--show-materials Include material data for mesh lights
|
||||
-v, --verbose Enable verbose logging
|
||||
--serialized Use serialized format from WASM (required for XML)
|
||||
--color-mode <mode> Color display mode: 'rgb', 'hex', or 'kelvin' (default: rgb)
|
||||
-h, --help Show this help message
|
||||
|
||||
Light Types Supported:
|
||||
- Point Small spherical light source
|
||||
- Sphere Spherical area light (SphereLight)
|
||||
- Disk Circular area light (DiskLight)
|
||||
- Rect Rectangular area light (RectLight)
|
||||
- Cylinder Cylindrical area light (CylinderLight)
|
||||
- Distant Directional light (DistantLight/sun)
|
||||
- Dome Environment/IBL light (DomeLight)
|
||||
- Geometry Mesh emissive light (GeometryLight)
|
||||
- Portal Light portal
|
||||
|
||||
Examples:
|
||||
# Dump all lights as JSON
|
||||
node dump-usdlux-cli.js tests/feat/lux/04_complete_scene.usda
|
||||
@@ -85,12 +129,18 @@ Examples:
|
||||
# Dump as human-readable summary
|
||||
node dump-usdlux-cli.js tests/feat/lux/06_mesh_lights.usda -f summary
|
||||
|
||||
# Dump specific light as YAML
|
||||
node dump-usdlux-cli.js tests/feat/lux/04_complete_scene.usda -f yaml -l 0
|
||||
# Dump specific light as YAML with all details
|
||||
node dump-usdlux-cli.js tests/feat/lux/04_complete_scene.usda -f yaml -l 0 --all
|
||||
|
||||
# Save output to file with verbose logging
|
||||
node dump-usdlux-cli.js models/scene.usdc -o lights.json -v
|
||||
|
||||
# Dump with spectral emission data
|
||||
node dump-usdlux-cli.js tests/usda/usdlux_advanced_features.usda --show-spectral
|
||||
|
||||
# Export as XML (requires --serialized)
|
||||
node dump-usdlux-cli.js tests/feat/lux/04_complete_scene.usda -f xml -o lights.xml
|
||||
|
||||
# Dump mesh lights with geometry and material info
|
||||
node dump-usdlux-cli.js tests/feat/lux/06_mesh_lights.usda --show-meshes --show-materials
|
||||
`);
|
||||
@@ -109,104 +159,295 @@ function loadFile(filename) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatSummary(lightsData, verbose = false) {
|
||||
// Convert RGB to hex color
|
||||
function rgbToHex(r, g, b) {
|
||||
const toHex = (c) => {
|
||||
const hex = Math.round(Math.max(0, Math.min(255, c * 255))).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
// Estimate color temperature from RGB (simplified Kelvin approximation)
|
||||
function rgbToKelvin(r, g, b) {
|
||||
// Simple heuristic based on red/blue ratio
|
||||
if (b === 0) return '>10000K (warm)';
|
||||
const ratio = r / b;
|
||||
if (ratio > 2) return '~2700K (warm white)';
|
||||
if (ratio > 1.5) return '~3500K (neutral)';
|
||||
if (ratio > 1) return '~5000K (daylight)';
|
||||
if (ratio > 0.7) return '~6500K (cool daylight)';
|
||||
return '~10000K+ (blue sky)';
|
||||
}
|
||||
|
||||
// Format color based on mode
|
||||
function formatColor(color, mode = 'rgb') {
|
||||
if (!color || color.length < 3) return 'N/A';
|
||||
const [r, g, b] = color;
|
||||
|
||||
switch (mode) {
|
||||
case 'hex':
|
||||
return rgbToHex(r, g, b);
|
||||
case 'kelvin':
|
||||
return rgbToKelvin(r, g, b);
|
||||
case 'rgb':
|
||||
default:
|
||||
return `RGB(${r.toFixed(3)}, ${g.toFixed(3)}, ${b.toFixed(3)})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Format a 4x4 matrix for display
|
||||
function formatMatrix(transform, indent = '│ ') {
|
||||
if (!transform || transform.length !== 16) return `${indent}[Invalid matrix]\n`;
|
||||
|
||||
let output = '';
|
||||
for (let row = 0; row < 4; row++) {
|
||||
const values = [];
|
||||
for (let col = 0; col < 4; col++) {
|
||||
values.push(transform[row * 4 + col].toFixed(4).padStart(10));
|
||||
}
|
||||
output += `${indent}[${values.join(', ')}]\n`;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Format spectral emission data
|
||||
function formatSpectralEmission(spectral, indent = '│ ') {
|
||||
if (!spectral) return '';
|
||||
|
||||
let output = `${indent}Interpolation: ${spectral.interpolation || 'linear'}\n`;
|
||||
output += `${indent}Unit: ${spectral.unit || 'nanometers'}\n`;
|
||||
|
||||
if (spectral.preset && spectral.preset !== 'none') {
|
||||
output += `${indent}Preset: ${spectral.preset.toUpperCase()}\n`;
|
||||
}
|
||||
|
||||
if (spectral.samples && spectral.samples.length > 0) {
|
||||
output += `${indent}Samples (${spectral.samples.length} points):\n`;
|
||||
const maxSamples = 10;
|
||||
const samples = spectral.samples.slice(0, maxSamples);
|
||||
for (const [wavelength, value] of samples) {
|
||||
output += `${indent} ${wavelength.toFixed(1)}nm: ${value.toFixed(6)}\n`;
|
||||
}
|
||||
if (spectral.samples.length > maxSamples) {
|
||||
output += `${indent} ... and ${spectral.samples.length - maxSamples} more\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function formatSummary(lightsData, options = {}) {
|
||||
const { verbose = false, showTransform = false, showSpectral = false, colorMode = 'rgb' } = options;
|
||||
let output = '';
|
||||
|
||||
output += `╔════════════════════════════════════════════════════════╗\n`;
|
||||
output += `║ UsdLux Lights Summary ║\n`;
|
||||
output += `╚════════════════════════════════════════════════════════╝\n\n`;
|
||||
output += `Total Lights: ${lightsData.length}\n\n`;
|
||||
output += `\n`;
|
||||
output += ` ╔════════════════════════════════════════════════════════════════╗\n`;
|
||||
output += ` ║ UsdLux Lights Summary ║\n`;
|
||||
output += ` ╚════════════════════════════════════════════════════════════════╝\n\n`;
|
||||
|
||||
// Group lights by type
|
||||
const byType = {};
|
||||
for (const light of lightsData) {
|
||||
const type = light.type || 'unknown';
|
||||
if (!byType[type]) byType[type] = [];
|
||||
byType[type].push(light);
|
||||
}
|
||||
|
||||
output += ` Total Lights: ${lightsData.length}\n`;
|
||||
output += ` By Type: ${Object.entries(byType).map(([t, l]) => `${t}(${l.length})`).join(', ')}\n`;
|
||||
|
||||
for (let i = 0; i < lightsData.length; i++) {
|
||||
const light = lightsData[i];
|
||||
const lightType = light.type || 'unknown';
|
||||
|
||||
output += `\n┌─ Light ${i}: ${light.name || 'Unnamed'} ──────────────\n`;
|
||||
output += `│ Type: ${light.lightType}\n`;
|
||||
output += `│ Path: ${light.abs_path || 'N/A'}\n`;
|
||||
output += `\n ┌─── Light ${i}: ${light.name || 'Unnamed'} ${'─'.repeat(Math.max(0, 40 - (light.name || 'Unnamed').length))}\n`;
|
||||
output += ` │ Type: ${lightType}\n`;
|
||||
output += ` │ Path: ${light.absPath || light.abs_path || 'N/A'}\n`;
|
||||
|
||||
if (light.color) {
|
||||
const r = light.color[0].toFixed(3);
|
||||
const g = light.color[1].toFixed(3);
|
||||
const b = light.color[2].toFixed(3);
|
||||
output += `│ Color: RGB(${r}, ${g}, ${b})\n`;
|
||||
if (light.displayName) {
|
||||
output += ` │ Display Name: ${light.displayName}\n`;
|
||||
}
|
||||
|
||||
// Color
|
||||
if (light.color) {
|
||||
output += ` │ Color: ${formatColor(light.color, colorMode)}\n`;
|
||||
}
|
||||
|
||||
// Intensity & Exposure
|
||||
if (light.intensity !== undefined) {
|
||||
output += `│ Intensity: ${light.intensity.toFixed(2)}\n`;
|
||||
output += ` │ Intensity: ${light.intensity.toFixed(3)}\n`;
|
||||
}
|
||||
|
||||
if (light.exposure !== undefined && light.exposure !== 0) {
|
||||
output += `│ Exposure: ${light.exposure.toFixed(2)} EV\n`;
|
||||
output += ` │ Exposure: ${light.exposure.toFixed(2)} EV\n`;
|
||||
const effectiveIntensity = light.intensity * Math.pow(2, light.exposure);
|
||||
output += `│ Effective Intensity: ${effectiveIntensity.toFixed(2)}\n`;
|
||||
output += ` │ Effective Intensity: ${effectiveIntensity.toFixed(3)}\n`;
|
||||
}
|
||||
|
||||
// Color temperature
|
||||
if (light.enableColorTemperature && light.colorTemperature) {
|
||||
output += `│ Color Temperature: ${light.colorTemperature}K\n`;
|
||||
output += ` │ Color Temperature: ${light.colorTemperature.toFixed(0)}K`;
|
||||
if (light.colorTemperature < 3000) output += ' (warm)';
|
||||
else if (light.colorTemperature < 5000) output += ' (neutral)';
|
||||
else if (light.colorTemperature < 7000) output += ' (daylight)';
|
||||
else output += ' (cool)';
|
||||
output += '\n';
|
||||
}
|
||||
|
||||
// Diffuse/Specular multipliers
|
||||
if (verbose) {
|
||||
if (light.diffuse !== undefined && light.diffuse !== 1.0) {
|
||||
output += ` │ Diffuse Multiplier: ${light.diffuse.toFixed(3)}\n`;
|
||||
}
|
||||
if (light.specular !== undefined && light.specular !== 1.0) {
|
||||
output += ` │ Specular Multiplier: ${light.specular.toFixed(3)}\n`;
|
||||
}
|
||||
if (light.normalize) {
|
||||
output += ` │ Normalize: true (power normalized by area)\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Type-specific properties
|
||||
if (light.lightType === 'Point' || light.lightType === 'Sphere') {
|
||||
output += ` │\n`;
|
||||
output += ` │ ── Type-Specific Properties ──\n`;
|
||||
|
||||
if (lightType === 'point' || lightType === 'sphere') {
|
||||
if (light.radius !== undefined && light.radius > 0) {
|
||||
output += `│ Radius: ${light.radius.toFixed(3)}\n`;
|
||||
output += ` │ Radius: ${light.radius.toFixed(4)}\n`;
|
||||
}
|
||||
} else if (light.lightType === 'Directional') {
|
||||
} else if (lightType === 'distant') {
|
||||
if (light.angle !== undefined && light.angle > 0) {
|
||||
output += `│ Angle: ${light.angle.toFixed(2)}°\n`;
|
||||
output += ` │ Angular Diameter: ${light.angle.toFixed(2)}°\n`;
|
||||
}
|
||||
} else if (light.lightType === 'Rect') {
|
||||
} else if (lightType === 'rect') {
|
||||
if (light.width !== undefined && light.height !== undefined) {
|
||||
output += `│ Dimensions: ${light.width.toFixed(2)} × ${light.height.toFixed(2)}\n`;
|
||||
output += ` │ Dimensions: ${light.width.toFixed(3)} × ${light.height.toFixed(3)}\n`;
|
||||
output += ` │ Area: ${(light.width * light.height).toFixed(4)} sq units\n`;
|
||||
}
|
||||
if (light.textureFile) {
|
||||
output += `│ Texture: ${light.textureFile}\n`;
|
||||
output += ` │ Texture: ${light.textureFile}\n`;
|
||||
}
|
||||
} else if (light.lightType === 'Disk') {
|
||||
} else if (lightType === 'disk') {
|
||||
if (light.radius !== undefined) {
|
||||
output += `│ Radius: ${light.radius.toFixed(3)}\n`;
|
||||
output += ` │ Radius: ${light.radius.toFixed(4)}\n`;
|
||||
output += ` │ Area: ${(Math.PI * light.radius * light.radius).toFixed(4)} sq units\n`;
|
||||
}
|
||||
} else if (light.lightType === 'Cylinder') {
|
||||
} else if (lightType === 'cylinder') {
|
||||
if (light.radius !== undefined && light.length !== undefined) {
|
||||
output += `│ Radius: ${light.radius.toFixed(3)}, Length: ${light.length.toFixed(3)}\n`;
|
||||
output += ` │ Radius: ${light.radius.toFixed(4)}\n`;
|
||||
output += ` │ Length: ${light.length.toFixed(4)}\n`;
|
||||
output += ` │ Surface Area: ${(2 * Math.PI * light.radius * light.length).toFixed(4)} sq units\n`;
|
||||
}
|
||||
if (light.shapingConeAngle !== undefined && light.shapingConeAngle > 0) {
|
||||
output += `│ Shaping Cone Angle: ${light.shapingConeAngle.toFixed(2)}°\n`;
|
||||
output += `│ Cone Softness: ${(light.shapingConeSoftness || 0).toFixed(2)}\n`;
|
||||
}
|
||||
} else if (light.lightType === 'Dome') {
|
||||
} else if (lightType === 'dome') {
|
||||
if (light.textureFile) {
|
||||
output += `│ Texture: ${light.textureFile}\n`;
|
||||
output += ` │ Environment Map: ${light.textureFile}\n`;
|
||||
}
|
||||
if (light.domeTextureFormat) {
|
||||
output += `│ Texture Format: ${light.domeTextureFormat}\n`;
|
||||
output += ` │ Texture Format: ${light.domeTextureFormat}\n`;
|
||||
}
|
||||
} else if (light.lightType === 'Geometry') {
|
||||
output += `│ Mesh ID: ${light.geometry_mesh_id}\n`;
|
||||
if (light.geometry_mesh_name) {
|
||||
output += `│ Mesh Name: ${light.geometry_mesh_name}\n`;
|
||||
if (light.guideRadius !== undefined) {
|
||||
output += ` │ Guide Radius: ${light.guideRadius.toExponential(2)}\n`;
|
||||
}
|
||||
if (light.material_sync_mode) {
|
||||
output += `│ Material Sync Mode: ${light.material_sync_mode}\n`;
|
||||
if (light.envmapTextureId !== undefined && light.envmapTextureId >= 0) {
|
||||
output += ` │ Environment Texture ID: ${light.envmapTextureId}\n`;
|
||||
}
|
||||
} else if (lightType === 'geometry') {
|
||||
output += ` │ Geometry Mesh ID: ${light.geometryMeshId ?? light.geometry_mesh_id ?? 'N/A'}\n`;
|
||||
if (light.materialSyncMode || light.material_sync_mode) {
|
||||
output += ` │ Material Sync Mode: ${light.materialSyncMode || light.material_sync_mode}\n`;
|
||||
}
|
||||
if (light.mesh_geometry) {
|
||||
output += ` │ Mesh: ${light.mesh_geometry.primName}\n`;
|
||||
output += ` │ Vertices: ${light.mesh_geometry.numVertices}\n`;
|
||||
output += ` │ Faces: ${light.mesh_geometry.numFaces}\n`;
|
||||
}
|
||||
} else if (lightType === 'portal') {
|
||||
output += ` │ (Portal light - no additional properties)\n`;
|
||||
}
|
||||
|
||||
// Shaping properties (spotlight/IES)
|
||||
const hasShaping = (light.shapingConeAngle !== undefined && light.shapingConeAngle < 90) ||
|
||||
light.shapingIesFile ||
|
||||
(light.shapingFocus !== undefined && light.shapingFocus !== 0);
|
||||
|
||||
if (hasShaping) {
|
||||
output += ` │\n`;
|
||||
output += ` │ ── Shaping (Spotlight/IES) ──\n`;
|
||||
|
||||
if (light.shapingConeAngle !== undefined && light.shapingConeAngle < 90) {
|
||||
output += ` │ Cone Angle: ${light.shapingConeAngle.toFixed(2)}°\n`;
|
||||
output += ` │ Cone Softness: ${(light.shapingConeSoftness || 0).toFixed(3)}\n`;
|
||||
}
|
||||
if (light.shapingFocus !== undefined && light.shapingFocus !== 0) {
|
||||
output += ` │ Focus: ${light.shapingFocus.toFixed(3)}\n`;
|
||||
}
|
||||
if (light.shapingFocusTint && (light.shapingFocusTint[0] !== 0 || light.shapingFocusTint[1] !== 0 || light.shapingFocusTint[2] !== 0)) {
|
||||
output += ` │ Focus Tint: ${formatColor(light.shapingFocusTint, colorMode)}\n`;
|
||||
}
|
||||
if (light.shapingIesFile) {
|
||||
output += ` │ IES Profile: ${light.shapingIesFile}\n`;
|
||||
if (light.shapingIesAngleScale !== undefined && light.shapingIesAngleScale !== 0) {
|
||||
output += ` │ IES Angle Scale: ${light.shapingIesAngleScale.toFixed(3)}\n`;
|
||||
}
|
||||
if (light.shapingIesNormalize) {
|
||||
output += ` │ IES Normalize: true\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shadow properties
|
||||
if (light.shadowEnable) {
|
||||
output += `│ Shadows: Enabled\n`;
|
||||
if (verbose && light.shadowColor) {
|
||||
const r = light.shadowColor[0].toFixed(3);
|
||||
const g = light.shadowColor[1].toFixed(3);
|
||||
const b = light.shadowColor[2].toFixed(3);
|
||||
output += `│ Shadow Color: RGB(${r}, ${g}, ${b})\n`;
|
||||
if (light.shadowEnable !== undefined) {
|
||||
output += ` │\n`;
|
||||
output += ` │ ── Shadow ──\n`;
|
||||
output += ` │ Enabled: ${light.shadowEnable ? 'Yes' : 'No'}\n`;
|
||||
|
||||
if (light.shadowEnable) {
|
||||
if (light.shadowColor) {
|
||||
const isBlack = light.shadowColor[0] === 0 && light.shadowColor[1] === 0 && light.shadowColor[2] === 0;
|
||||
if (!isBlack || verbose) {
|
||||
output += ` │ Color: ${formatColor(light.shadowColor, colorMode)}\n`;
|
||||
}
|
||||
}
|
||||
if (light.shadowDistance !== undefined && light.shadowDistance > 0) {
|
||||
output += ` │ Distance: ${light.shadowDistance.toFixed(2)}\n`;
|
||||
}
|
||||
if (light.shadowFalloff !== undefined && light.shadowFalloff > 0) {
|
||||
output += ` │ Falloff: ${light.shadowFalloff.toFixed(3)}\n`;
|
||||
}
|
||||
if (light.shadowFalloffGamma !== undefined && light.shadowFalloffGamma !== 1.0) {
|
||||
output += ` │ Falloff Gamma: ${light.shadowFalloffGamma.toFixed(3)}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IES profile
|
||||
if (light.shapingIesFile) {
|
||||
output += `│ IES Profile: ${light.shapingIesFile}\n`;
|
||||
// Position/Direction
|
||||
if (light.position || light.direction) {
|
||||
output += ` │\n`;
|
||||
output += ` │ ── Spatial ──\n`;
|
||||
if (light.position) {
|
||||
output += ` │ Position: (${light.position[0].toFixed(3)}, ${light.position[1].toFixed(3)}, ${light.position[2].toFixed(3)})\n`;
|
||||
}
|
||||
if (light.direction) {
|
||||
output += ` │ Direction: (${light.direction[0].toFixed(3)}, ${light.direction[1].toFixed(3)}, ${light.direction[2].toFixed(3)})\n`;
|
||||
}
|
||||
}
|
||||
|
||||
output += `└─────────────────────────────────────────────\n`;
|
||||
// Transform matrix
|
||||
if (showTransform && light.transform) {
|
||||
output += ` │\n`;
|
||||
output += ` │ ── Transform Matrix ──\n`;
|
||||
output += formatMatrix(light.transform, ' │ ');
|
||||
}
|
||||
|
||||
// Spectral emission (LTE SpectralAPI)
|
||||
if (showSpectral && light.spectralEmission) {
|
||||
output += ` │\n`;
|
||||
output += ` │ ── Spectral Emission (LTE SpectralAPI) ──\n`;
|
||||
output += formatSpectralEmission(light.spectralEmission, ' │ ');
|
||||
}
|
||||
|
||||
output += ` └${'─'.repeat(60)}\n`;
|
||||
}
|
||||
|
||||
return output;
|
||||
@@ -272,6 +513,8 @@ async function dumpLights(options) {
|
||||
if (!options.outputFile) {
|
||||
if (options.format === 'summary') {
|
||||
console.log('No lights found in scene.');
|
||||
} else if (options.format === 'xml') {
|
||||
console.log('<?xml version="1.0"?>\n<lights/>');
|
||||
} else {
|
||||
console.log(options.format === 'json' ? '[]' : 'lights: []');
|
||||
}
|
||||
@@ -280,6 +523,7 @@ async function dumpLights(options) {
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const xmlResults = [];
|
||||
|
||||
// Determine which lights to dump
|
||||
const lightIds = options.lightId !== null
|
||||
@@ -298,41 +542,82 @@ async function dumpLights(options) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = usd.getLightWithFormat(lightId, 'json');
|
||||
let lightData;
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Error getting light ${lightId}: ${result.error}`);
|
||||
continue;
|
||||
if (options.serialized) {
|
||||
// Use serialized format
|
||||
const formatType = options.format === 'xml' ? 'xml' : 'json';
|
||||
const result = usd.getLightWithFormat(lightId, formatType);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Error getting light ${lightId}: ${result.error}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.format === 'xml') {
|
||||
xmlResults.push(result.data);
|
||||
continue;
|
||||
}
|
||||
|
||||
lightData = JSON.parse(result.data);
|
||||
} else {
|
||||
// Use direct object format (more comprehensive)
|
||||
lightData = usd.getLight(lightId);
|
||||
|
||||
if (lightData.error) {
|
||||
console.error(`Error getting light ${lightId}: ${lightData.error}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const lightData = JSON.parse(result.data);
|
||||
|
||||
if (options.verbose) {
|
||||
console.error(` Type: ${lightData.lightType}`);
|
||||
console.error(` Type: ${lightData.type}`);
|
||||
console.error(` Name: ${lightData.name || 'N/A'}`);
|
||||
if (lightData.lightType === 'Geometry') {
|
||||
console.error(` Mesh ID: ${lightData.geometry_mesh_id}`);
|
||||
if (lightData.type === 'geometry') {
|
||||
console.error(` Mesh ID: ${lightData.geometryMeshId}`);
|
||||
}
|
||||
if (lightData.spectralEmission) {
|
||||
console.error(` Has Spectral Emission: yes`);
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally fetch mesh data for geometry lights
|
||||
if (options.showMeshes && lightData.lightType === 'Geometry' && lightData.geometry_mesh_id !== undefined) {
|
||||
const meshId = lightData.geometry_mesh_id;
|
||||
if (meshId >= 0 && meshId < usd.numMeshes()) {
|
||||
if (options.showMeshes && lightData.type === 'geometry') {
|
||||
const meshId = lightData.geometryMeshId ?? lightData.geometry_mesh_id;
|
||||
if (meshId !== undefined && meshId >= 0 && meshId < usd.numMeshes()) {
|
||||
const mesh = usd.getMesh(meshId);
|
||||
lightData.mesh_geometry = {
|
||||
primName: mesh.primName,
|
||||
numVertices: mesh.faceVertexIndices.length,
|
||||
numFaces: mesh.faceVertexCounts.length
|
||||
numVertices: mesh.faceVertexIndices?.length || 0,
|
||||
numFaces: mesh.faceVertexCounts?.length || 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally fetch material data for mesh lights
|
||||
if (options.showMaterials && lightData.lightType === 'Geometry') {
|
||||
if (options.showMaterials && lightData.type === 'geometry') {
|
||||
// Find material bound to the mesh if available
|
||||
// This would require additional implementation
|
||||
lightData.material_info = 'Material lookup not yet implemented';
|
||||
const meshId = lightData.geometryMeshId ?? lightData.geometry_mesh_id;
|
||||
if (meshId !== undefined && meshId >= 0 && meshId < usd.numMeshes()) {
|
||||
const mesh = usd.getMesh(meshId);
|
||||
if (mesh.materialId !== undefined && mesh.materialId >= 0) {
|
||||
const material = usd.getMaterial(mesh.materialId);
|
||||
lightData.material_info = {
|
||||
id: mesh.materialId,
|
||||
name: material?.name || 'Unknown'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out transform if not requested (to reduce output size)
|
||||
if (!options.showTransform && !options.showAll) {
|
||||
delete lightData.transform;
|
||||
}
|
||||
|
||||
// Filter out spectral if not requested
|
||||
if (!options.showSpectral && !options.showAll) {
|
||||
delete lightData.spectralEmission;
|
||||
}
|
||||
|
||||
results.push(lightData);
|
||||
@@ -359,8 +644,22 @@ async function dumpLights(options) {
|
||||
defaultKeyType: 'PLAIN',
|
||||
defaultStringType: 'QUOTE_DOUBLE'
|
||||
});
|
||||
} else if (options.format === 'xml') {
|
||||
// Combine XML results
|
||||
output = '<?xml version="1.0" encoding="UTF-8"?>\n<lights>\n';
|
||||
for (const xml of xmlResults) {
|
||||
// Indent each light XML
|
||||
const indented = xml.split('\n').map(line => ' ' + line).join('\n');
|
||||
output += indented + '\n';
|
||||
}
|
||||
output += '</lights>';
|
||||
} else if (options.format === 'summary') {
|
||||
output = formatSummary(results, options.verbose);
|
||||
output = formatSummary(results, {
|
||||
verbose: options.verbose,
|
||||
showTransform: options.showTransform || options.showAll,
|
||||
showSpectral: options.showSpectral || options.showAll,
|
||||
colorMode: options.colorMode
|
||||
});
|
||||
}
|
||||
|
||||
// Write to file or stdout
|
||||
|
||||
650
web/js/usdlux.html
Normal file
650
web/js/usdlux.html
Normal file
@@ -0,0 +1,650 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>TinyUSDZ UsdLux Light Demo</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
#info {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
max-width: 380px;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
#info h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
color: #ffd700;
|
||||
font-size: 16px;
|
||||
}
|
||||
#info p {
|
||||
margin: 5px 0;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
||||
#file-controls {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
#fileInput {
|
||||
display: none;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 8px 14px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-right: 6px;
|
||||
margin-bottom: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
.button.secondary {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
}
|
||||
.button.danger {
|
||||
background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
|
||||
}
|
||||
#light-info {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
#light-info h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #6bb6ff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.light-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.light-item {
|
||||
padding: 10px;
|
||||
margin: 6px 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #ffd700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.light-item:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.light-item.selected {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
border-left-color: #667eea;
|
||||
}
|
||||
.light-item .light-name {
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.light-item .light-type {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 215, 0, 0.2);
|
||||
color: #ffd700;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.light-item .light-details {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.light-color-swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
#loadingIndicator {
|
||||
display: none;
|
||||
color: #ffd700;
|
||||
font-weight: bold;
|
||||
margin-top: 8px;
|
||||
}
|
||||
#loadingIndicator.active {
|
||||
display: block;
|
||||
}
|
||||
#drop-zone {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(102, 126, 234, 0.9);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
#drop-zone.active {
|
||||
display: flex;
|
||||
}
|
||||
#drop-zone-content {
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
#drop-zone-content h2 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#drop-zone-content p {
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
#scene-stats {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
#scene-stats span {
|
||||
margin-right: 15px;
|
||||
}
|
||||
.stat-label {
|
||||
color: #888;
|
||||
}
|
||||
.stat-value {
|
||||
color: #6bb6ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
#controls-info {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
}
|
||||
#embedded-select, #tonemap-select {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
}
|
||||
#embedded-select option, #tonemap-select option {
|
||||
background: #1a1a2e;
|
||||
color: white;
|
||||
}
|
||||
.settings-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.settings-section h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #95e06c;
|
||||
font-size: 13px;
|
||||
}
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.setting-row label {
|
||||
flex: 0 0 80px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
.setting-row select, .setting-row input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
.setting-value {
|
||||
width: 45px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: #6bb6ff;
|
||||
margin-left: 8px;
|
||||
}
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
height: 6px;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #667eea;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tonemap-info {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
}
|
||||
#spectral-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
#spectral-section h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #e066ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
#spectral-canvas {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#spectral-info {
|
||||
font-size: 10px;
|
||||
color: #aaa;
|
||||
margin-top: 6px;
|
||||
padding: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
#spectral-info div {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
#spectral-info strong {
|
||||
color: #e066ff;
|
||||
}
|
||||
.wavelength-preview {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 14px;
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
margin-left: 8px;
|
||||
}
|
||||
#monochrome-controls {
|
||||
display: none;
|
||||
}
|
||||
#monochrome-controls.visible {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="drop-zone">
|
||||
<div id="drop-zone-content">
|
||||
<h2>Drop USD File Here</h2>
|
||||
<p>Supports .usd, .usda, .usdc, .usdz</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="info">
|
||||
<h3>UsdLux Light Demo</h3>
|
||||
<p>
|
||||
Visualize USD lights in Three.js.<br>
|
||||
Drag & drop or use the button to load USD files.
|
||||
</p>
|
||||
|
||||
<div id="file-controls">
|
||||
<label for="embedded-select" style="font-size: 11px; color: #888;">Embedded Scenes:</label>
|
||||
<select id="embedded-select">
|
||||
<option value="basic">Basic Lights</option>
|
||||
<option value="spotlight">Spotlight with IES</option>
|
||||
<option value="area">Area Lights</option>
|
||||
<option value="dome">Dome Light (IBL)</option>
|
||||
<option value="complete" selected>Complete Scene</option>
|
||||
</select>
|
||||
<input type="file" id="fileInput" accept=".usd,.usda,.usdc,.usdz">
|
||||
<button class="button" onclick="document.getElementById('fileInput').click()">Load USD File</button>
|
||||
<button class="button secondary" onclick="window.loadEmbeddedScene()">Load Embedded</button>
|
||||
<button class="button danger" onclick="window.clearLights()">Clear Lights</button>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong>Current:</strong> <span id="currentFile">Embedded Scene</span>
|
||||
<span id="loadingIndicator">Loading...</span>
|
||||
</p>
|
||||
|
||||
<div id="light-info">
|
||||
<h4>Lights (<span id="lightCount">0</span>)</h4>
|
||||
<div class="light-list" id="lightList">
|
||||
<p style="color: #666; font-style: italic;">No lights loaded</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Display Settings</h4>
|
||||
<div class="setting-row">
|
||||
<label>Tone Map:</label>
|
||||
<select id="tonemap-select">
|
||||
<option value="raw">Raw (Linear)</option>
|
||||
<option value="reinhard">Reinhard</option>
|
||||
<option value="aces1" selected>ACES 1.3</option>
|
||||
<option value="aces2">ACES 2.0</option>
|
||||
<option value="agx">AgX</option>
|
||||
<option value="neutral">Neutral</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="tonemap-info" id="tonemap-info">Film-like response with highlight roll-off</div>
|
||||
<div class="setting-row">
|
||||
<label>Exposure:</label>
|
||||
<input type="range" id="exposure-slider" min="-3" max="3" step="0.1" value="0">
|
||||
<span class="setting-value" id="exposure-value">0.0 EV</span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>Gamma:</label>
|
||||
<input type="range" id="gamma-slider" min="1.0" max="3.0" step="0.1" value="2.2">
|
||||
<span class="setting-value" id="gamma-value">2.2</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="spectral-section">
|
||||
<h4>Spectral Settings</h4>
|
||||
<canvas id="spectral-canvas" width="340" height="120"></canvas>
|
||||
<div class="setting-row">
|
||||
<label>Color Mode:</label>
|
||||
<select id="spectral-mode-select">
|
||||
<option value="rgb" selected>RGB (USD color)</option>
|
||||
<option value="spectral">Spectral (SPD to RGB)</option>
|
||||
<option value="monochrome">Monochrome</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="monochrome-controls">
|
||||
<div class="setting-row">
|
||||
<label>Wavelength:</label>
|
||||
<input type="range" id="wavelength-slider" min="380" max="780" step="1" value="550">
|
||||
<span class="setting-value" id="wavelength-value">550nm</span>
|
||||
<span class="wavelength-preview" id="wavelength-preview"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 8px;">
|
||||
<button class="button" style="font-size: 10px; padding: 5px 10px;" onclick="window.applyDemoSpectralData()">Apply Demo SPD</button>
|
||||
</div>
|
||||
<div class="setting-row" style="margin-top: 8px;">
|
||||
<label>Blackbody:</label>
|
||||
<input type="range" id="blackbody-slider" min="1000" max="10000" step="100" value="5500">
|
||||
<span class="setting-value" id="blackbody-value">5500K</span>
|
||||
</div>
|
||||
<div id="spectral-info">
|
||||
<span style="color: #666;">Select a light to view spectral data</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scene-stats">
|
||||
<span><span class="stat-label">Lights:</span> <span class="stat-value" id="statLights">0</span></span>
|
||||
<span><span class="stat-label">Meshes:</span> <span class="stat-value" id="statMeshes">1</span></span>
|
||||
<span><span class="stat-label">FPS:</span> <span class="stat-value" id="statFPS">60</span></span>
|
||||
</div>
|
||||
|
||||
<div id="controls-info">
|
||||
<strong>Controls:</strong><br>
|
||||
Left-click + drag: Rotate<br>
|
||||
Right-click + drag: Pan<br>
|
||||
Scroll: Zoom
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// Handle file upload
|
||||
document.getElementById('fileInput').addEventListener('change', async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
document.getElementById('loadingIndicator').classList.add('active');
|
||||
document.getElementById('currentFile').textContent = file.name;
|
||||
|
||||
const customEvent = new CustomEvent('loadUSDFile', { detail: { file } });
|
||||
window.dispatchEvent(customEvent);
|
||||
|
||||
event.target.value = '';
|
||||
});
|
||||
|
||||
// Drag and drop handling
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
document.body.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
document.body.addEventListener('dragenter', () => {
|
||||
dropZone.classList.add('active');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', (e) => {
|
||||
if (e.target === dropZone) {
|
||||
dropZone.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
dropZone.classList.remove('active');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
const ext = file.name.toLowerCase().split('.').pop();
|
||||
|
||||
if (['usd', 'usda', 'usdc', 'usdz'].includes(ext)) {
|
||||
document.getElementById('loadingIndicator').classList.add('active');
|
||||
document.getElementById('currentFile').textContent = file.name;
|
||||
|
||||
const customEvent = new CustomEvent('loadUSDFile', { detail: { file } });
|
||||
window.dispatchEvent(customEvent);
|
||||
} else {
|
||||
alert('Please drop a USD file (.usd, .usda, .usdc, .usdz)');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Global functions
|
||||
window.hideLoadingIndicator = function() {
|
||||
document.getElementById('loadingIndicator').classList.remove('active');
|
||||
};
|
||||
|
||||
window.updateLightList = function(lights) {
|
||||
const lightList = document.getElementById('lightList');
|
||||
const lightCount = document.getElementById('lightCount');
|
||||
const statLights = document.getElementById('statLights');
|
||||
|
||||
lightCount.textContent = lights.length;
|
||||
statLights.textContent = lights.length;
|
||||
|
||||
if (lights.length === 0) {
|
||||
lightList.innerHTML = '<p style="color: #666; font-style: italic;">No lights loaded</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
lightList.innerHTML = '';
|
||||
|
||||
lights.forEach((light, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'light-item';
|
||||
item.dataset.index = index;
|
||||
|
||||
const colorHex = light.color ?
|
||||
`#${Math.round(light.color[0] * 255).toString(16).padStart(2, '0')}${Math.round(light.color[1] * 255).toString(16).padStart(2, '0')}${Math.round(light.color[2] * 255).toString(16).padStart(2, '0')}`
|
||||
: '#ffffff';
|
||||
|
||||
let details = `Intensity: ${(light.intensity || 1).toFixed(2)}`;
|
||||
if (light.exposure && light.exposure !== 0) {
|
||||
details += ` | Exp: ${light.exposure.toFixed(1)} EV`;
|
||||
}
|
||||
if (light.radius && light.type !== 'distant' && light.type !== 'dome') {
|
||||
details += ` | R: ${light.radius.toFixed(2)}`;
|
||||
}
|
||||
if (light.type === 'rect' && light.width && light.height) {
|
||||
details += ` | ${light.width.toFixed(1)}x${light.height.toFixed(1)}`;
|
||||
}
|
||||
if (light.shapingConeAngle && light.shapingConeAngle < 90) {
|
||||
details += ` | Cone: ${light.shapingConeAngle.toFixed(0)}`;
|
||||
}
|
||||
|
||||
// Spectral indicator
|
||||
const hasSpectral = light.spectralEmission &&
|
||||
((light.spectralEmission.samples && light.spectralEmission.samples.length > 0) ||
|
||||
(light.spectralEmission.preset && light.spectralEmission.preset !== 'none'));
|
||||
const spectralBadge = hasSpectral ?
|
||||
'<span style="background: linear-gradient(90deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #8b00ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-size: 9px; margin-left: 5px;">SPD</span>' : '';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="light-name">
|
||||
<span class="light-color-swatch" style="background: ${colorHex}"></span>
|
||||
${light.name || 'Light ' + index}
|
||||
<span class="light-type">${light.type || 'unknown'}</span>
|
||||
${spectralBadge}
|
||||
</div>
|
||||
<div class="light-details">${details}</div>
|
||||
`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
// Remove selected from all items
|
||||
lightList.querySelectorAll('.light-item').forEach(i => i.classList.remove('selected'));
|
||||
item.classList.add('selected');
|
||||
|
||||
// Focus camera on this light
|
||||
window.focusOnLight(index);
|
||||
|
||||
// Select light for spectral display
|
||||
if (window.selectLightForSpectral) {
|
||||
window.selectLightForSpectral(index);
|
||||
}
|
||||
});
|
||||
|
||||
lightList.appendChild(item);
|
||||
});
|
||||
};
|
||||
|
||||
// Embedded scene selector
|
||||
document.getElementById('embedded-select').addEventListener('change', (e) => {
|
||||
window.loadEmbeddedScene(e.target.value);
|
||||
});
|
||||
|
||||
// Tone mapping descriptions
|
||||
const tonemapDescriptions = {
|
||||
'raw': 'No tone mapping - linear HDR values clamped',
|
||||
'reinhard': 'Simple curve, preserves detail in highlights',
|
||||
'aces1': 'ACES 1.3 - Film-like response with highlight roll-off',
|
||||
'aces2': 'ACES 2.0 - Improved gamut mapping and desaturation',
|
||||
'agx': 'AgX - Modern filmic look, similar to Blender',
|
||||
'neutral': 'Neutral - Balanced tone curve for general use'
|
||||
};
|
||||
|
||||
// Tone mapping selector
|
||||
document.getElementById('tonemap-select').addEventListener('change', (e) => {
|
||||
const value = e.target.value;
|
||||
window.setToneMapping(value);
|
||||
document.getElementById('tonemap-info').textContent = tonemapDescriptions[value] || '';
|
||||
});
|
||||
|
||||
// Exposure slider
|
||||
document.getElementById('exposure-slider').addEventListener('input', (e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
document.getElementById('exposure-value').textContent = value.toFixed(1) + ' EV';
|
||||
window.setExposure(value);
|
||||
});
|
||||
|
||||
// Gamma slider
|
||||
document.getElementById('gamma-slider').addEventListener('input', (e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
document.getElementById('gamma-value').textContent = value.toFixed(1);
|
||||
window.setGamma(value);
|
||||
});
|
||||
|
||||
// Spectral mode selector
|
||||
document.getElementById('spectral-mode-select').addEventListener('change', (e) => {
|
||||
const mode = e.target.value;
|
||||
window.setSpectralMode(mode);
|
||||
|
||||
// Show/hide monochrome controls
|
||||
const monoControls = document.getElementById('monochrome-controls');
|
||||
if (mode === 'monochrome') {
|
||||
monoControls.classList.add('visible');
|
||||
updateWavelengthPreview(parseInt(document.getElementById('wavelength-slider').value));
|
||||
} else {
|
||||
monoControls.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Wavelength slider for monochrome mode
|
||||
document.getElementById('wavelength-slider').addEventListener('input', (e) => {
|
||||
const wavelength = parseInt(e.target.value);
|
||||
document.getElementById('wavelength-value').textContent = wavelength + 'nm';
|
||||
updateWavelengthPreview(wavelength);
|
||||
window.setMonochromeWavelength(wavelength);
|
||||
});
|
||||
|
||||
// Update wavelength preview color
|
||||
function updateWavelengthPreview(wavelength) {
|
||||
const rgb = window.wavelengthToRGB ? window.wavelengthToRGB(wavelength) : { r: 0.5, g: 0.5, b: 0.5 };
|
||||
const preview = document.getElementById('wavelength-preview');
|
||||
if (preview) {
|
||||
preview.style.backgroundColor = `rgb(${Math.round(rgb.r * 255)}, ${Math.round(rgb.g * 255)}, ${Math.round(rgb.b * 255)})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize wavelength preview
|
||||
setTimeout(() => {
|
||||
updateWavelengthPreview(550);
|
||||
}, 500);
|
||||
|
||||
// Blackbody temperature slider
|
||||
document.getElementById('blackbody-slider').addEventListener('input', (e) => {
|
||||
const temp = parseInt(e.target.value);
|
||||
document.getElementById('blackbody-value').textContent = temp + 'K';
|
||||
});
|
||||
|
||||
// Apply blackbody on slider change end
|
||||
document.getElementById('blackbody-slider').addEventListener('change', (e) => {
|
||||
const temp = parseInt(e.target.value);
|
||||
if (window.applyBlackbodyToSelected) {
|
||||
window.applyBlackbodyToSelected(temp);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="module" src="./usdlux.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1985
web/js/usdlux.js
Normal file
1985
web/js/usdlux.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user