import * as THREE from 'three'; export type CrossSectionAxis = 'x' | 'y' | 'z'; export interface CrossSectionSettings { enabled: boolean; /** * Axis normal for the cutting plane. The plane itself is perpendicular to this * axis, e.g. axis "x" produces a Y/Z plane. */ axis?: CrossSectionAxis; /** * Normalised plane position along the selected axis. * -1 = minimum model bound, 0 = model centre, 1 = maximum model bound. */ offset?: number; /** * Flips the clipping normal without moving the plane. */ invert?: boolean; } export interface NormalizedCrossSectionSettings { enabled: boolean; axis: CrossSectionAxis; offset: number; invert: boolean; } export interface CrossSectionSnapshot { settings: NormalizedCrossSectionSettings; bounds: THREE.Box3; center: THREE.Vector3; size: THREE.Vector3; point: THREE.Vector3; normal: THREE.Vector3; plane: THREE.Plane; } export interface CrossSectionPlaneVisual { axis: CrossSectionAxis; plane: THREE.Plane; position: THREE.Vector3; quaternion: THREE.Quaternion; width: number; height: number; normal: THREE.Vector3; } export interface ApplyCrossSectionClippingOptions { /** * Keeps any clipping planes already assigned to a material and appends the * cross-section plane. Disable when the viewer owns material clipping entirely. */ preserveExistingPlanes?: boolean; /** * Enables clipped shadow rendering on touched materials. */ clipShadows?: boolean; /** * Traverse the full object subtree. Enabled by default. */ recursive?: boolean; } type ObjectWithMaterial = THREE.Object3D & { material?: THREE.Material | THREE.Material[]; }; type MaterialSnapshot = { material: THREE.Material; clippingPlanes: THREE.Plane[] | null; clipShadows: boolean; }; const AXIS_INDEX: Record = { x: 0, y: 1, z: 2, }; const PLANE_DIMENSIONS: Record = { x: ['y', 'z'], y: ['x', 'z'], z: ['x', 'y'], }; const UNIT_BOUNDS = new THREE.Box3( new THREE.Vector3(-0.5, -0.5, -0.5), new THREE.Vector3(0.5, 0.5, 0.5), ); const EPSILON = 1e-6; export function normalizeCrossSectionSettings( settings?: Partial | null, ): NormalizedCrossSectionSettings { return { enabled: Boolean(settings?.enabled), axis: settings?.axis ?? 'x', offset: clampCrossSectionOffset(settings?.offset ?? 0), invert: Boolean(settings?.invert), }; } export function clampCrossSectionOffset(offset: number): number { if (!Number.isFinite(offset)) { return 0; } return Math.min(1, Math.max(-1, offset)); } export function getAxisNormal( axis: CrossSectionAxis, invert = false, target = new THREE.Vector3(), ): THREE.Vector3 { target.set(0, 0, 0); target.setComponent(AXIS_INDEX[axis], invert ? -1 : 1); return target; } export function isFiniteBox3(bounds: THREE.Box3 | null | undefined): bounds is THREE.Box3 { if (!bounds) { return false; } return ( Number.isFinite(bounds.min.x) && Number.isFinite(bounds.min.y) && Number.isFinite(bounds.min.z) && Number.isFinite(bounds.max.x) && Number.isFinite(bounds.max.y) && Number.isFinite(bounds.max.z) && !bounds.isEmpty() ); } /** * Returns a finite, non-empty Box3. Degenerate axes are expanded so clipping * maths and helper geometry remain stable for flat/2D assets. */ export function createSafeBounds( bounds?: THREE.Box3 | null, fallbackBounds: THREE.Box3 = UNIT_BOUNDS, ): THREE.Box3 { const source = isFiniteBox3(bounds) ? bounds : fallbackBounds; const safe = source.clone(); if (!isFiniteBox3(safe)) { return UNIT_BOUNDS.clone(); } const center = safe.getCenter(new THREE.Vector3()); const size = safe.getSize(new THREE.Vector3()); for (const axis of ['x', 'y', 'z'] as const) { const index = AXIS_INDEX[axis]; if (Math.abs(size.getComponent(index)) < EPSILON) { safe.min.setComponent(index, center.getComponent(index) - 0.5); safe.max.setComponent(index, center.getComponent(index) + 0.5); } } return safe; } export function measureObjectBounds( object: THREE.Object3D | null | undefined, fallbackBounds: THREE.Box3 = UNIT_BOUNDS, ): THREE.Box3 { if (!object) { return createSafeBounds(fallbackBounds); } return createSafeBounds(new THREE.Box3().setFromObject(object), fallbackBounds); } export function getCrossSectionPoint( bounds: THREE.Box3, axis: CrossSectionAxis, offset: number, target = new THREE.Vector3(), ): THREE.Vector3 { const safeBounds = createSafeBounds(bounds); const center = safeBounds.getCenter(target); const size = safeBounds.getSize(new THREE.Vector3()); const index = AXIS_INDEX[axis]; const halfExtent = Math.max(size.getComponent(index) / 2, EPSILON); center.setComponent(index, center.getComponent(index) + halfExtent * clampCrossSectionOffset(offset)); return center; } export function createCrossSectionSnapshot( bounds: THREE.Box3, settings: Partial, ): CrossSectionSnapshot | null { const normalized = normalizeCrossSectionSettings(settings); if (!normalized.enabled) { return null; } const safeBounds = createSafeBounds(bounds); const center = safeBounds.getCenter(new THREE.Vector3()); const size = safeBounds.getSize(new THREE.Vector3()); const point = getCrossSectionPoint(safeBounds, normalized.axis, normalized.offset); const normal = getAxisNormal(normalized.axis, normalized.invert); const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, point); return { settings: normalized, bounds: safeBounds, center, size, point, normal, plane, }; } export function createCrossSectionPlane( bounds: THREE.Box3, settings: Partial, ): THREE.Plane | null { return createCrossSectionSnapshot(bounds, settings)?.plane ?? null; } export function getCrossSectionPlaneVisual( bounds: THREE.Box3, settings: Partial, padding = 1.08, ): CrossSectionPlaneVisual | null { const snapshot = createCrossSectionSnapshot(bounds, settings); if (!snapshot) { return null; } const [widthAxis, heightAxis] = PLANE_DIMENSIONS[snapshot.settings.axis]; const width = Math.max(snapshot.size.getComponent(AXIS_INDEX[widthAxis]) * padding, EPSILON); const height = Math.max(snapshot.size.getComponent(AXIS_INDEX[heightAxis]) * padding, EPSILON); const quaternion = new THREE.Quaternion().setFromUnitVectors( new THREE.Vector3(0, 0, 1), snapshot.normal.clone().normalize(), ); return { axis: snapshot.settings.axis, plane: snapshot.plane.clone(), position: snapshot.point.clone(), quaternion, width, height, normal: snapshot.normal.clone(), }; } /** * Applies a local clipping plane to every material in an Object3D subtree and * returns a restore function. The function deliberately snapshots original * material state so temporary viewer modes do not leak into shared materials * after unmounting or changing machines. */ export function applyCrossSectionClipping( root: THREE.Object3D, plane: THREE.Plane | null, options: ApplyCrossSectionClippingOptions = {}, ): () => void { const preserveExistingPlanes = options.preserveExistingPlanes ?? true; const clipShadows = options.clipShadows ?? true; const recursive = options.recursive ?? true; const snapshots: MaterialSnapshot[] = []; const touchedMaterials = new Set(); const visit = (object: THREE.Object3D) => { const materials = getObjectMaterials(object as ObjectWithMaterial); for (const material of materials) { if (touchedMaterials.has(material)) { continue; } touchedMaterials.add(material); snapshots.push({ material, clippingPlanes: material.clippingPlanes ? [...material.clippingPlanes] : null, clipShadows: material.clipShadows, }); if (plane) { const existingPlanes = preserveExistingPlanes && material.clippingPlanes ? material.clippingPlanes.filter((existingPlane) => existingPlane !== plane) : []; material.clippingPlanes = [...existingPlanes, plane]; material.clipShadows = clipShadows; } else if (!preserveExistingPlanes) { material.clippingPlanes = null; } material.needsUpdate = true; } }; if (recursive) { root.traverse(visit); } else { visit(root); } return () => { for (const snapshot of snapshots) { snapshot.material.clippingPlanes = snapshot.clippingPlanes ? [...snapshot.clippingPlanes] : null; snapshot.material.clipShadows = snapshot.clipShadows; snapshot.material.needsUpdate = true; } }; } function getObjectMaterials(object: ObjectWithMaterial): THREE.Material[] { if (!object.material) { return []; } return Array.isArray(object.material) ? object.material.filter(Boolean) : [object.material]; }