import { useThree } from '@react-three/fiber'; import { useLayoutEffect, useMemo } from 'react'; import * as THREE from 'three'; import { applyCrossSectionClipping, createSafeBounds, getCrossSectionPlaneVisual, measureObjectBounds, normalizeCrossSectionSettings, type ApplyCrossSectionClippingOptions, type CrossSectionPlaneVisual, type CrossSectionSettings, } from './crossSection'; export interface CrossSectionControllerProps { /** * Root object whose materials should receive the clipping plane. */ root: THREE.Object3D | null | undefined; settings: CrossSectionSettings; /** * Used until a model root exists, and as a fallback for empty Object3D trees. */ fallbackBounds?: THREE.Box3 | null; /** * Increment this when geometry changes after the root reference is stable. */ boundsVersion?: number | string; showPlane?: boolean; planeColor?: THREE.ColorRepresentation; planeOpacity?: number; planePadding?: number; renderOrder?: number; clippingOptions?: ApplyCrossSectionClippingOptions; onBoundsMeasured?: (bounds: THREE.Box3) => void; onPlaneChanged?: (plane: THREE.Plane | null) => void; } const DEFAULT_PLANE_COLOR = '#38bdf8'; export function CrossSectionController({ root, settings, fallbackBounds, boundsVersion, showPlane = false, planeColor = DEFAULT_PLANE_COLOR, planeOpacity = 0.16, planePadding = 1.08, renderOrder = 30, clippingOptions, onBoundsMeasured, onPlaneChanged, }: CrossSectionControllerProps) { const gl = useThree((state) => state.gl); const normalizedSettings = normalizeCrossSectionSettings(settings); const fallbackBoundsKey = getBoundsKey(fallbackBounds); const measuredBounds = useMemo(() => { const bounds = root ? measureObjectBounds(root, fallbackBounds ?? undefined) : createSafeBounds(fallbackBounds ?? undefined); onBoundsMeasured?.(bounds.clone()); return bounds; // boundsVersion intentionally lets callers force a re-measure for animated // or lazily-attached procedural geometry. // eslint-disable-next-line react-hooks/exhaustive-deps }, [root, fallbackBoundsKey, boundsVersion]); const visual = useMemo(() => { if (!normalizedSettings.enabled) { return null; } return getCrossSectionPlaneVisual(measuredBounds, normalizedSettings, planePadding); }, [ measuredBounds, normalizedSettings.axis, normalizedSettings.enabled, normalizedSettings.invert, normalizedSettings.offset, planePadding, ]); const planeKey = visual ? getPlaneKey(visual.plane) : 'disabled'; useLayoutEffect(() => { if (!normalizedSettings.enabled) { onPlaneChanged?.(null); return undefined; } const previous = gl.localClippingEnabled; gl.localClippingEnabled = true; return () => { gl.localClippingEnabled = previous; }; }, [gl, normalizedSettings.enabled, onPlaneChanged]); useLayoutEffect(() => { if (!root || !visual) { onPlaneChanged?.(null); return undefined; } onPlaneChanged?.(visual.plane.clone()); return applyCrossSectionClipping(root, visual.plane, clippingOptions); }, [root, visual, planeKey, clippingOptions, onPlaneChanged]); if (!showPlane || !visual) { return null; } return ( ); } function getBoundsKey(bounds?: THREE.Box3 | null): string { if (!bounds) { return 'none'; } return [ bounds.min.x, bounds.min.y, bounds.min.z, bounds.max.x, bounds.max.y, bounds.max.z, ].join(':'); } function getPlaneKey(plane: THREE.Plane): string { return [ plane.normal.x, plane.normal.y, plane.normal.z, plane.constant, ].join(':'); }