From 4e4743303c4d3eae2d709f862cb169d798be609b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 17:47:08 +0000 Subject: [PATCH 1/5] feat(render): translucent materials via MeshPhysicalMaterial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional transmission/ior/thickness/attenuation/clearcoat fields to MaterialPreset and MaterialDef. SceneMesh swaps to meshPhysicalMaterial when transmission > 0 so glass enclosures and acrylic housings render with refraction instead of looking like opaque polished plastic. New presets: Acrylic (Clear), Polycarbonate (Frosted). Existing Glass and Tinted Glass presets gain transmission=1.0 / 0.95. The .loon vcode format keeps its positional M-line — the new fields survive in the JSON .vcad format only for now. --- .../2026-05-05-translucent-materials.json | 13 ++ packages/app/src/components/SceneMesh.tsx | 158 +++++++++++++++--- packages/app/src/data/materials.ts | 61 ++++++- packages/ir/src/index.ts | 13 ++ 4 files changed, 214 insertions(+), 31 deletions(-) create mode 100644 changelog/entries/2026-05-05-translucent-materials.json diff --git a/changelog/entries/2026-05-05-translucent-materials.json b/changelog/entries/2026-05-05-translucent-materials.json new file mode 100644 index 00000000..82bd6d43 --- /dev/null +++ b/changelog/entries/2026-05-05-translucent-materials.json @@ -0,0 +1,13 @@ +{ + "id": "2026-05-05-translucent-materials", + "version": "0.9.4", + "date": "2026-05-05", + "category": "feat", + "title": "Translucent and glass materials in the viewport", + "summary": "Material presets now support transmission, IOR, thickness, and clearcoat so glass enclosures and acrylic housings render with realistic refraction.", + "features": [ + "rendering", + "materials", + "glass" + ] +} diff --git a/packages/app/src/components/SceneMesh.tsx b/packages/app/src/components/SceneMesh.tsx index d9914adb..f93ad3b6 100644 --- a/packages/app/src/components/SceneMesh.tsx +++ b/packages/app/src/components/SceneMesh.tsx @@ -24,6 +24,95 @@ const FACE_HIGHLIGHT_COLOR = new THREE.Color(0x00d4ff); // cyan for face selecti const DEG2RAD = Math.PI / 180; const NORMAL_TOLERANCE = 0.01; // Tolerance for grouping triangles by normal +interface PbrMaterialProps { + color?: THREE.Color; + vertexColors?: boolean; + emissive?: THREE.Color; + emissiveIntensity?: number; + metalness: number; + roughness: number; + envMapIntensity?: number; + side?: THREE.Side; + transmission?: number; + ior?: number; + thickness?: number; + attenuationDistance?: number; + attenuationColor?: [number, number, number]; + clearcoat?: number; + clearcoatRoughness?: number; +} + +/** + * Renders `` when transmission is set so the part shows + * up as glass / translucent plastic; otherwise emits the cheaper + * ``. Both share the same prop surface for the standard + * PBR fields. + */ +function PbrMaterial({ + color, + vertexColors, + emissive, + emissiveIntensity, + metalness, + roughness, + envMapIntensity = 1.0, + side = THREE.DoubleSide, + transmission, + ior, + thickness, + attenuationDistance, + attenuationColor, + clearcoat, + clearcoatRoughness, +}: PbrMaterialProps) { + const usePhysical = + (transmission !== undefined && transmission > 0) || + (clearcoat !== undefined && clearcoat > 0); + + if (usePhysical) { + const attColor = attenuationColor + ? new THREE.Color(attenuationColor[0], attenuationColor[1], attenuationColor[2]) + : undefined; + return ( + 0} + /> + ); + } + + return ( + + ); +} + /** Find all triangle indices that share the same normal as the given triangle */ function findCoplanarTriangles( mesh: TriangleMesh, @@ -256,6 +345,13 @@ export function ImportedMesh({ mesh, materialKey }: ImportedMeshProps) { color: preset.color, metallic: preset.metallic, roughness: preset.roughness, + transmission: preset.transmission, + ior: preset.ior, + thickness: preset.thickness, + attenuationDistance: preset.attenuationDistance, + attenuationColor: preset.attenuationColor, + clearcoat: preset.clearcoat, + clearcoatRoughness: preset.clearcoatRoughness, }; } return null; @@ -354,13 +450,17 @@ export function ImportedMesh({ mesh, materialKey }: ImportedMeshProps) { return ( - {showWireframe && geoReady && } @@ -447,6 +547,22 @@ export const SceneMesh = memo(function SceneMesh({ // Resolve material from document, with live preview override const materialDef = useMemo(() => { // Check for active preview for this part + const synthesizeFromPreset = (preset: ReturnType) => { + if (!preset) return null; + return { + name: preset.name, + color: preset.color, + metallic: preset.metallic, + roughness: preset.roughness, + transmission: preset.transmission, + ior: preset.ior, + thickness: preset.thickness, + attenuationDistance: preset.attenuationDistance, + attenuationColor: preset.attenuationColor, + clearcoat: preset.clearcoat, + clearcoatRoughness: preset.clearcoatRoughness, + }; + }; if (previewMaterial?.partId === partInfo.id) { const previewKey = previewMaterial.materialKey; // First check document materials @@ -454,27 +570,11 @@ export const SceneMesh = memo(function SceneMesh({ return materials[previewKey]; } // Fall back to preset materials library - const preset = getMaterialByKey(previewKey); - if (preset) { - return { - name: preset.name, - color: preset.color, - metallic: preset.metallic, - roughness: preset.roughness, - }; - } + const fromPreset = synthesizeFromPreset(getMaterialByKey(previewKey)); + if (fromPreset) return fromPreset; } if (materials[materialKey]) return materials[materialKey]; - const preset = getMaterialByKey(materialKey); - if (preset) { - return { - name: preset.name, - color: preset.color, - metallic: preset.metallic, - roughness: preset.roughness, - }; - } - return null; + return synthesizeFromPreset(getMaterialByKey(materialKey)); }, [materials, materialKey, previewMaterial, partInfo.id]); // Check if this material should use a procedural shader @@ -836,16 +936,20 @@ export const SceneMesh = memo(function SceneMesh({ {/* Use procedural shader if available, otherwise standard PBR */} {!shaderMaterial && ( - )} {showWireframe && geoReady && } diff --git a/packages/app/src/data/materials.ts b/packages/app/src/data/materials.ts index f9fc4c2e..08fe883a 100644 --- a/packages/app/src/data/materials.ts +++ b/packages/app/src/data/materials.ts @@ -26,6 +26,23 @@ export interface MaterialPreset { density: number; // kg/m³ /** Optional procedural shader for realistic textures */ proceduralShader?: ProceduralShaderType; + /** + * Light transmission, 0..1. Non-zero switches the renderer to a physical + * material so the part renders as glass / translucent plastic. + */ + transmission?: number; + /** Index of refraction. Common: 1.45 (acrylic), 1.5 (glass), 1.585 (polycarbonate). */ + ior?: number; + /** Volume thickness in mm; controls absorption depth for tinted transmission. */ + thickness?: number; + /** Distance (mm) over which transmitted light is fully absorbed by `attenuationColor`. */ + attenuationDistance?: number; + /** Tint color applied to transmitted light, RGB 0-1. */ + attenuationColor?: [number, number, number]; + /** Clearcoat layer strength, 0..1. */ + clearcoat?: number; + /** Roughness of the clearcoat layer, 0..1. */ + clearcoatRoughness?: number; } export const CATEGORY_LABELS: Record = { @@ -266,24 +283,60 @@ export const MATERIAL_PRESETS: MaterialPreset[] = [ proceduralShader: "wood", }, - // Glass (2) + // Glass (4) { key: "glass", name: "Glass", category: "glass", - color: [0.85, 0.9, 0.95], + color: [0.95, 0.97, 1.0], metallic: 0.0, - roughness: 0.05, + roughness: 0.02, density: 2500, + transmission: 1.0, + ior: 1.5, + thickness: 2.0, }, { key: "glass-tinted", name: "Tinted Glass", category: "glass", - color: [0.3, 0.4, 0.45], + color: [0.85, 0.9, 0.95], metallic: 0.0, roughness: 0.05, density: 2500, + transmission: 0.95, + ior: 1.5, + thickness: 3.0, + attenuationDistance: 25, + attenuationColor: [0.3, 0.45, 0.55], + }, + { + key: "acrylic-clear", + name: "Acrylic (Clear)", + category: "glass", + color: [0.98, 0.98, 1.0], + metallic: 0.0, + roughness: 0.05, + density: 1180, + transmission: 0.95, + ior: 1.49, + thickness: 2.0, + clearcoat: 0.5, + clearcoatRoughness: 0.05, + }, + { + key: "polycarbonate-frosted", + name: "Polycarbonate (Frosted)", + category: "glass", + color: [0.92, 0.94, 0.96], + metallic: 0.0, + roughness: 0.35, + density: 1200, + transmission: 0.7, + ior: 1.585, + thickness: 2.5, + attenuationDistance: 50, + attenuationColor: [0.85, 0.9, 0.95], }, // Composites (3) diff --git a/packages/ir/src/index.ts b/packages/ir/src/index.ts index 066bbf53..51e0fbca 100644 --- a/packages/ir/src/index.ts +++ b/packages/ir/src/index.ts @@ -508,6 +508,19 @@ export interface MaterialDef { roughness: number; density?: number; friction?: number; + /** + * Optional physically-based extensions. When `transmission > 0` the renderer + * switches to a `MeshPhysicalMaterial` so the part renders as glass / + * translucent plastic. The vcode (`.loon`) text format does not yet round-trip + * these — they survive the JSON `.vcad` format only. + */ + transmission?: number; + ior?: number; + thickness?: number; + attenuationDistance?: number; + attenuationColor?: [number, number, number]; + clearcoat?: number; + clearcoatRoughness?: number; } /** An entry in the scene — a root node with an assigned material. */ From dac6a23170f26b54e234eb4c69a0e4848076c5b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 17:47:51 +0000 Subject: [PATCH 2/5] fix(render): keep AO running during camera motion at reduced samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-processing pass was gated on !isCameraMoving, so AO and vignette vanished the moment the user started orbiting. The scene went visibly flat during interaction, then snapped back to "good" at rest. Now the EffectComposer stays mounted across motion and we drop the AO sample count from 6 → 3 (and denoise 4 → 1) while moving. Same depth cues during orbit, no framerate cliff. --- .../app/src/components/ViewportContent.tsx | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/ViewportContent.tsx b/packages/app/src/components/ViewportContent.tsx index dae984a6..fda12385 100644 --- a/packages/app/src/components/ViewportContent.tsx +++ b/packages/app/src/components/ViewportContent.tsx @@ -1785,18 +1785,21 @@ export function ViewportContent({ mode = "3d" }: { mode?: "3d" | "pcb" }) { )} - {/* Post-processing effects - disabled during camera motion for FPS, and - while a WebXR session is active. EffectComposer renders to an - offscreen target and blits to the canvas, which doesn't write to the - XR layer's framebuffer — so in VR/AR the scene goes black and only - objects rendered directly by WebXRManager (hands, controllers) show. */} - {engineReady && !isCameraMoving && !xrPresenting && sceneSettings.postProcessing.ambientOcclusion?.enabled !== false && sceneSettings.postProcessing.vignette?.enabled !== false && ( + {/* Post-processing effects. Sample counts drop while the camera is + moving so the scene keeps depth without tanking framerate, then + ramp back up once orbit settles. Disabled entirely while a WebXR + session is active — EffectComposer renders to an offscreen target + and blits to the canvas, which doesn't write to the XR layer's + framebuffer, so in VR/AR the scene would go black and only + objects rendered directly by WebXRManager (hands, controllers) + would show. */} + {engineReady && !xrPresenting && sceneSettings.postProcessing.ambientOcclusion?.enabled !== false && sceneSettings.postProcessing.vignette?.enabled !== false && ( )} {/* AO only mode */} - {engineReady && !isCameraMoving && !xrPresenting && sceneSettings.postProcessing.ambientOcclusion?.enabled !== false && sceneSettings.postProcessing.vignette?.enabled === false && ( + {engineReady && !xrPresenting && sceneSettings.postProcessing.ambientOcclusion?.enabled !== false && sceneSettings.postProcessing.vignette?.enabled === false && ( )} {/* Vignette only mode */} - {engineReady && !isCameraMoving && !xrPresenting && sceneSettings.postProcessing.ambientOcclusion?.enabled === false && sceneSettings.postProcessing.vignette?.enabled !== false && ( + {engineReady && !xrPresenting && sceneSettings.postProcessing.ambientOcclusion?.enabled === false && sceneSettings.postProcessing.vignette?.enabled !== false && ( Date: Tue, 5 May 2026 17:52:38 +0000 Subject: [PATCH 3/5] feat(tessellate): chord and angular tolerance for adaptive segmentation Adds optional `chord_tolerance` and `angular_tolerance` fields to TessellationParams plus a `circle_segments_for_radius()` helper that raises the segment count so curvature error stays below the configured sag. Cylindrical, conical, spherical, and toroidal face tessellators now use this so a 1mm fillet doesn't get the same 32 segments as a 100mm cylinder. Defaults stay `None`, so existing callers see no behavior change. New constructor `TessellationParams::from_tolerances(chord, angular)` is the opt-in path for downstream consumers. --- crates/vcad-kernel-tessellate/src/lib.rs | 126 +++++++++++++++++++++-- 1 file changed, 118 insertions(+), 8 deletions(-) diff --git a/crates/vcad-kernel-tessellate/src/lib.rs b/crates/vcad-kernel-tessellate/src/lib.rs index b45502e3..91e8c0a0 100644 --- a/crates/vcad-kernel-tessellate/src/lib.rs +++ b/crates/vcad-kernel-tessellate/src/lib.rs @@ -280,14 +280,27 @@ impl Default for TriangleMesh { } /// Tessellation parameters controlling mesh quality. +/// +/// `circle_segments` / `latitude_segments` set a fixed lower bound. When +/// `chord_tolerance` and/or `angular_tolerance` are also set, the per-feature +/// segment count is raised so curvature error stays below the tolerance — a +/// 1mm fillet then gets fewer triangles than a 100mm cylinder, while large +/// cylinders no longer look faceted under polished lighting. #[derive(Debug, Clone, Copy)] pub struct TessellationParams { - /// Number of segments for circular features. + /// Minimum number of segments for circular features. pub circle_segments: u32, /// Number of segments along the height of cylindrical/conical features. pub height_segments: u32, - /// Number of latitude bands for spherical features. + /// Minimum number of latitude bands for spherical features. pub latitude_segments: u32, + /// Optional sag tolerance in model units (mm). When set, segment counts + /// are raised so the chord between two adjacent samples never deviates + /// from the true surface by more than this distance. + pub chord_tolerance: Option, + /// Optional angular tolerance in radians. When set, segment counts are + /// raised so no single segment subtends more than this angle. + pub angular_tolerance: Option, } impl Default for TessellationParams { @@ -296,10 +309,15 @@ impl Default for TessellationParams { circle_segments: 32, height_segments: 1, latitude_segments: 16, + chord_tolerance: None, + angular_tolerance: None, } } } +const ADAPTIVE_SEG_FLOOR: u32 = 3; +const ADAPTIVE_SEG_CEIL: u32 = 256; + impl TessellationParams { /// Create params from a segment count hint (used for circular features). pub fn from_segments(segments: u32) -> Self { @@ -307,7 +325,63 @@ impl TessellationParams { circle_segments: segments.max(3), height_segments: 1, latitude_segments: (segments / 2).max(4), + chord_tolerance: None, + angular_tolerance: None, + } + } + + /// Create params from a chord tolerance (mm) and an angular tolerance + /// (radians). Both are optional, but at least one must be specified to + /// drive adaptive subdivision. + pub fn from_tolerances(chord: Option, angular: Option) -> Self { + Self { + circle_segments: 16, + height_segments: 1, + latitude_segments: 8, + chord_tolerance: chord, + angular_tolerance: angular, + } + } + + /// Number of segments around the circumference of a feature with the + /// given radius. Always at least `circle_segments`; raised if a + /// chord or angular tolerance is set. + pub fn circle_segments_for_radius(&self, radius: f64) -> u32 { + let mut n = self.circle_segments.max(ADAPTIVE_SEG_FLOOR); + if let Some(tol) = self.chord_tolerance { + // Sag for a chord on a circle of radius r with n segments is + // e = r * (1 - cos(π/n)) + // Solving for n given a target e gives + // n = π / acos(1 − e/r) + // Clamp the acos argument so a degenerate (tol >= r) case + // doesn't blow up — we just fall through to the floor. + if radius > 0.0 && tol > 0.0 && tol < radius { + let arg = (1.0 - tol / radius).clamp(-1.0, 1.0); + let denom = arg.acos(); + if denom > 1e-9 { + let segs = (PI / denom).ceil() as u32; + n = n.max(segs); + } + } + } + if let Some(ang) = self.angular_tolerance { + if ang > 1e-9 { + let segs = ((2.0 * PI) / ang).ceil() as u32; + n = n.max(segs); + } } + n.clamp(ADAPTIVE_SEG_FLOOR, ADAPTIVE_SEG_CEIL) + } + + /// Number of latitude bands for a sphere of the given radius. The + /// returned count is roughly half of the longitude segment count, so + /// triangles stay reasonably square. + pub fn latitude_segments_for_radius(&self, radius: f64) -> u32 { + let lon = self.circle_segments_for_radius(radius); + self.latitude_segments + .max(ADAPTIVE_SEG_FLOOR) + .max(lon / 2) + .clamp(ADAPTIVE_SEG_FLOOR, ADAPTIVE_SEG_CEIL) } } @@ -2084,7 +2158,10 @@ fn tessellate_cylindrical_face( ) -> TriangleMesh { let face = &topo.faces[face_id]; let surface = &geom.surfaces[face.surface_index]; - let n_circ = params.circle_segments.max(3) as usize; + // `n_circ` is the lower bound; once we extract the cylinder's actual + // radius below, `circle_segments_for_radius` may raise it so that the + // chord/angular tolerance is respected. + let mut n_circ = params.circle_segments.max(3) as usize; let n_height = params.height_segments.max(1) as usize; // Determine the v (height) parameter range by projecting seam vertices @@ -2102,7 +2179,9 @@ fn tessellate_cylindrical_face( .as_any() .downcast_ref::() { - radius = Some(cyl.radius.abs().max(1e-6)); + let r = cyl.radius.abs().max(1e-6); + radius = Some(r); + n_circ = (params.circle_segments_for_radius(r) as usize).max(3); // Project vertices onto axis to get v parameter and compute U angles let mut vmin = f64::MAX; let mut vmax = f64::MIN; @@ -2388,8 +2467,21 @@ fn tessellate_spherical_face( return tessellate_spherical_cap(surface.as_ref(), &loop_verts, params, reversed); } - let n_lon = params.circle_segments as usize; - let n_lat = params.latitude_segments as usize; + let sphere_radius = surface + .as_any() + .downcast_ref::() + .map(|s| s.radius.abs().max(1e-6)); + let (n_lon, n_lat) = if let Some(r) = sphere_radius { + ( + params.circle_segments_for_radius(r) as usize, + params.latitude_segments_for_radius(r) as usize, + ) + } else { + ( + params.circle_segments as usize, + params.latitude_segments as usize, + ) + }; let mut mesh = TriangleMesh::new(); @@ -3004,7 +3096,7 @@ fn tessellate_conical_face( ) -> TriangleMesh { let face = &topo.faces[face_id]; let surface = &geom.surfaces[face.surface_index]; - let n_circ = params.circle_segments as usize; + let mut n_circ = params.circle_segments as usize; let n_height = params.height_segments as usize; // Get seam vertices to determine the cone extent @@ -3047,6 +3139,13 @@ fn tessellate_conical_face( v_max = v_max.max(v); } + // Pick the widest ring's radius for adaptive sampling so the base of + // a wide frustum doesn't end up as faceted as its tip. + let max_radius = v_max.abs().max(v_min.abs()) * half_angle.sin().abs(); + if max_radius > 1e-9 { + n_circ = (params.circle_segments_for_radius(max_radius) as usize).max(3); + } + // Generate mesh using surface.evaluate() let y_dir = axis.cross(ref_dir); let mut mesh = TriangleMesh::new(); @@ -3255,7 +3354,18 @@ fn tessellate_toroidal_face( ) -> TriangleMesh { let face = &topo.faces[face_id]; let surface = &geom.surfaces[face.surface_index]; - let n_circ = params.circle_segments.max(3) as usize; + // Use the wider of the major and tube radii to size the segmentation. + // A small fillet torus (tiny tube) doesn't need many around-the-major- + // axis samples; the major arc is the dominant curvature. + let torus_radius = surface + .as_any() + .downcast_ref::() + .map(|t| t.major_radius.abs().max(t.minor_radius.abs()).max(1e-6)); + let n_circ = if let Some(r) = torus_radius { + (params.circle_segments_for_radius(r) as usize).max(3) + } else { + params.circle_segments.max(3) as usize + }; let mut mesh = TriangleMesh::new(); From 64d47bae7053d3fa6c3376d42478d17270ff1a54 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 17:57:02 +0000 Subject: [PATCH 4/5] feat(render): silhouette outline post-process around every part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a screen-space outline pass that draws a subtle dark contour around every rendered part — the touch polished CAD viewers use to keep parts legible against busy backgrounds. Implementation: / so the Outline post-effect + picks them up via the Selection context. Outline runs + regardless of vcad's own selection state — this just tells + the post-process which Object3Ds to find silhouettes for. */} + {/* Debug: mesh boundary edges (holes in tessellation). Toggle with Ctrl+Shift+B or @@ -1793,42 +1812,38 @@ export function ViewportContent({ mode = "3d" }: { mode?: "3d" | "pcb" }) { framebuffer, so in VR/AR the scene would go black and only objects rendered directly by WebXRManager (hands, controllers) would show. */} - {engineReady && !xrPresenting && sceneSettings.postProcessing.ambientOcclusion?.enabled !== false && sceneSettings.postProcessing.vignette?.enabled !== false && ( - - - - - )} - {/* AO only mode */} - {engineReady && !xrPresenting && sceneSettings.postProcessing.ambientOcclusion?.enabled !== false && sceneSettings.postProcessing.vignette?.enabled === false && ( - - - - )} - {/* Vignette only mode */} - {engineReady && !xrPresenting && sceneSettings.postProcessing.ambientOcclusion?.enabled === false && sceneSettings.postProcessing.vignette?.enabled !== false && ( + {engineReady && !xrPresenting && ( + sceneSettings.postProcessing.ambientOcclusion?.enabled !== false || + sceneSettings.postProcessing.vignette?.enabled !== false || + silhouetteEnabled + ) && ( - + {sceneSettings.postProcessing.ambientOcclusion?.enabled !== false && ( + + )} + {silhouetteEnabled && ( + + )} + {sceneSettings.postProcessing.vignette?.enabled !== false && ( + + )} )} - + ); } diff --git a/packages/ir/src/index.ts b/packages/ir/src/index.ts index 51e0fbca..ded8d3d2 100644 --- a/packages/ir/src/index.ts +++ b/packages/ir/src/index.ts @@ -713,6 +713,18 @@ export interface Vignette { darkness?: number; } +/** Silhouette / outline effect settings. Draws a subtle dark contour + * around every rendered part for a more "CAD viewer" look. */ +export interface Silhouette { + enabled: boolean; + /** Edge strength multiplier (default 2.0). Higher = thicker outline. */ + edgeStrength?: number; + /** Outline color of edges that face the camera, packed 0xRRGGBB (default 0x000000). */ + visibleEdgeColor?: number; + /** Outline color of edges occluded by other geometry, packed 0xRRGGBB (default 0x000000). */ + hiddenEdgeColor?: number; +} + /** Tone mapping algorithm. */ export type ToneMapping = | "none" @@ -727,6 +739,7 @@ export interface PostProcessing { ambientOcclusion?: AmbientOcclusion; bloom?: Bloom; vignette?: Vignette; + silhouette?: Silhouette; toneMapping?: ToneMapping; exposure?: number; } From 0bdecd8c8d9f89bec427c8e8fb2d774f8ec43669 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 18:00:54 +0000 Subject: [PATCH 5/5] fix(render): build EffectComposer children as a typed array EffectComposer types its children as `JSX.Element | JSX.Element[]`, which rejects the inlined `cond && ` shorthand because that expression is `false | Element`. Build the array up-front so disabled effects drop out cleanly. --- .../app/src/components/ViewportContent.tsx | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/app/src/components/ViewportContent.tsx b/packages/app/src/components/ViewportContent.tsx index e921959b..11b7c03e 100644 --- a/packages/app/src/components/ViewportContent.tsx +++ b/packages/app/src/components/ViewportContent.tsx @@ -1,3 +1,4 @@ +import type React from "react"; import { useRef, useEffect, useMemo, useState, useCallback, Suspense } from "react"; import { Spherical, Vector3, Box3, Plane, Raycaster, Vector2, Quaternion, Matrix4, Color, TOUCH, PerspectiveCamera, WebGLRenderTarget, SRGBColorSpace, ACESFilmicToneMapping } from "three"; @@ -1812,38 +1813,49 @@ export function ViewportContent({ mode = "3d" }: { mode?: "3d" | "pcb" }) { framebuffer, so in VR/AR the scene would go black and only objects rendered directly by WebXRManager (hands, controllers) would show. */} - {engineReady && !xrPresenting && ( - sceneSettings.postProcessing.ambientOcclusion?.enabled !== false || - sceneSettings.postProcessing.vignette?.enabled !== false || - silhouetteEnabled - ) && ( - - {sceneSettings.postProcessing.ambientOcclusion?.enabled !== false && ( + {engineReady && !xrPresenting && (() => { + const aoEnabled = sceneSettings.postProcessing.ambientOcclusion?.enabled !== false; + const vignetteEnabled = sceneSettings.postProcessing.vignette?.enabled !== false; + if (!aoEnabled && !vignetteEnabled && !silhouetteEnabled) return null; + // EffectComposer's children type is strict (`JSX.Element | JSX.Element[]`), + // so we build the array up-front rather than inlining `cond && ` + // expressions, which would resolve to `false` when disabled. + const effects: React.JSX.Element[] = []; + if (aoEnabled) { + effects.push( - )} - {silhouetteEnabled && ( + />, + ); + } + if (silhouetteEnabled) { + effects.push( - )} - {sceneSettings.postProcessing.vignette?.enabled !== false && ( + />, + ); + } + if (vignetteEnabled) { + effects.push( - )} - - )} + />, + ); + } + return {effects}; + })()} ); }