diff --git a/changelog/entries/2026-05-05-silhouette-outline.json b/changelog/entries/2026-05-05-silhouette-outline.json new file mode 100644 index 00000000..1bf9b74b --- /dev/null +++ b/changelog/entries/2026-05-05-silhouette-outline.json @@ -0,0 +1,12 @@ +{ + "id": "2026-05-05-silhouette-outline", + "version": "0.9.4", + "date": "2026-05-05", + "category": "feat", + "title": "Subtle dark silhouette around every part", + "summary": "A screen-space outline post-process draws a thin dark contour around every rendered part, the same touch polished CAD viewers use to keep parts legible against busy backgrounds.", + "features": [ + "rendering", + "outline" + ] +} 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/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(); 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/components/ViewportContent.tsx b/packages/app/src/components/ViewportContent.tsx index dae984a6..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"; @@ -19,7 +20,14 @@ import { Lightformer, ContactShadows, } from "@react-three/drei"; -import { EffectComposer, N8AO, Vignette } from "@react-three/postprocessing"; +import { + EffectComposer, + N8AO, + Outline, + Select, + Selection, + Vignette, +} from "@react-three/postprocessing"; import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; import { GridPlane } from "./GridPlane"; import { SceneMesh, ImportedMesh } from "./SceneMesh"; @@ -1430,8 +1438,14 @@ export function ViewportContent({ mode = "3d" }: { mode?: "3d" | "pcb" }) { return env.intensity ?? 1.0; }, [sceneSettings.environment]); + // Silhouette outline. Default on so the viewport gets the subtle dark + // contour around each part that polished CAD viewers all use; users can + // turn it off through SceneSettings. + const silhouetteSettings = sceneSettings.postProcessing.silhouette; + const silhouetteEnabled = silhouetteSettings?.enabled !== false; + return ( - <> + {/* Engine-independent content - renders immediately */} {/* Scene lights from document settings */} {sceneSettings.lights.map((light) => { @@ -1687,7 +1701,12 @@ export function ViewportContent({ mode = "3d" }: { mode?: "3d" | "pcb" }) { {/* Plane gizmo at origin - inside rotation group so kernel planes display correctly */} - {/* Scene meshes - Assembly mode (instances) */} + {/* Wrap rendered parts in + {/* Scene meshes - Assembly mode (instances) */} {scene?.instances?.map((inst: EvaluatedInstance) => { const instanceSelectionId = getInstanceSelectionId(inst); // Create a minimal PartInfo-like object for instance rendering @@ -1739,6 +1758,7 @@ export function ViewportContent({ mode = "3d" }: { mode?: "3d" | "pcb" }) { /> ); })} + {/* Debug: mesh boundary edges (holes in tessellation). Toggle with Ctrl+Shift+B or @@ -1785,47 +1805,57 @@ 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 && ( - - - - - )} - {/* AO only mode */} - {engineReady && !isCameraMoving && !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 && ( - - - - )} - + {/* 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 && (() => { + 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( + , + ); + } + if (silhouetteEnabled) { + effects.push( + , + ); + } + if (vignetteEnabled) { + effects.push( + , + ); + } + return {effects}; + })()} + ); } 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..ded8d3d2 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. */ @@ -700,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" @@ -714,6 +739,7 @@ export interface PostProcessing { ambientOcclusion?: AmbientOcclusion; bloom?: Bloom; vignette?: Vignette; + silhouette?: Silhouette; toneMapping?: ToneMapping; exposure?: number; }