Looking inside 3D models with Clipping Planes
Viewing cross-sections of a model is a basic feature in CAD and engineering. But implementing it in a web-based 3D viewer is trickier than it looks.
What is Clipping?
Clipping places a virtual plane in 3D space and removes rendering on one side of it. It doesn't modify the model's geometry. Instead, it discards fragments at the WebGL shader stage, so there's virtually no performance cost.
Before Clipping After Clipping
┌──────────┐ ┌─────┐
│ │ │ │←plane
│ Model │ → │Model│
│ │ │ │
└──────────┘ └─────┘
Back side removed, interior visible
Voxel3D's Clipping System
Voxel3D's Clipping system is composed of three independent layers.
1. ClippingPlane (Data Layer)
The data model that manages plane state.
Material-Level Clipping
There are two ways to apply Clipping in Three.js:
// Method 1: Global clipping
renderer.clippingPlanes = [plane];
// Problem: Grid, Axes, Gizmo all get clipped too
// Method 2: Per-material clipping (Voxel3D)
mesh.material.clippingPlanes = [plane];
// Advantage: Only models are clipped, helpers remain intact
Voxel3D uses per-material clipping. This keeps helper objects like Grid, Axes, and PlaneHelper unaffected by clipping.
Three ClipSide Modes
| Mode | Behavior | Use Case |
|---|---|---|
| far | Removes geometry behind the plane (normal direction) | Standard cross-section |
| near | Removes geometry in front of the plane | Inverted cross-section |
| both | Shows only between two planes | Slab visualization |
The both mode internally creates two Plane objects, offset by half the slabThickness in each direction, producing a thin cross-section slice.
Constant Clamping
The plane's position (constant) is restricted to the model's bounding box range, preventing the plane from moving outside the model.
2. ClippingPlaneGizmo (UI Layer)
Interactive handles that let users manipulate planes in real time.
Dual Visualization
- PlaneHelper: Always visible semi-transparent rectangle showing plane position and normal direction.
- PivotControls: Transform handles inspired by drei's PivotControls from React Three Fiber. Shown only when selected, supporting simultaneous translation and rotation.
Transform Pipeline
When a user drags the Gizmo:
- Receive world matrix from PivotControls
- Extract position and quaternion
- Apply rotation to baseNormal to compute the current normal
- Calculate constant = -position.dot(currentNormal)
- Clamp within bounds and update the plane
- Sync material clippingPlanes + regenerate edges
Z-fighting prevention is handled with depthWrite=false and polygonOffset on the PlaneHelper.
3. ClippedEdgeGenerator (Rendering Layer)
Visualizes the outline of the clipped cross-section. The BVH covered in the previous post plays a central role here.
Merged Geometry + BVH
Instead of processing each mesh individually, all scene models are merged into a single geometry.
Individual processing:
Iterate mesh 1 triangles → intersection calc
Iterate mesh 2 triangles → intersection calc
...
Iterate mesh N triangles → intersection calc
→ Inefficient (proportional to mesh count)
Merged BVH:
Merge all meshes in world coordinates → Build single BVH
→ shapecast() traverses only intersecting triangles
→ O(log n) performance regardless of mesh count
Triangle-Plane Intersection
For each triangle found by shapecast():
- Calculate intersection points between the triangle's three edges and the plane
- Usually 2 points are found, forming a line segment that's part of the outline
- If a vertex lies exactly on the plane, 3 points may result, with duplicates removed by distance threshold
Multi-Plane Edge Clipping
When multiple planes are active simultaneously, each edge is additionally clipped against the other planes:
For edge A-B, apply other plane P:
A, B on same side → keep or discard entirely
A, B on different sides → cut at intersection point
→ Only edges within the intersection of all plane regions remain
Performance Optimizations
- rAF throttling: Maximum one edge recalculation per frame, even during rapid dragging
- Segment limit: Default 100,000 to protect memory
- Cache: Merged geometry is reused, rebuilt only when models are added/removed
- WorldUnits lines: Consistent line thickness regardless of zoom level (0.01m)
End-to-End Flow
User: "Create X-axis Clipping Plane"
↓
ClippingManager.createPlane('x')
├─ Compute model bounding box (center, range)
├─ Create ClippingPlane (constant = center.x)
├─ Create ClippingPlaneGizmo (PlaneHelper + PivotControls)
├─ Register node in SceneGraph (checkable, deletable)
└─ Apply clippingPlanes to all model materials
└─ Generate cross-section edges via BVH shapecast
User: "Drag Gizmo"
↓
PivotControls.onDrag
├─ Extract position, quaternion from world matrix
├─ Recalculate normal + constant (clamped to bounds)
├─ Update material clippingPlanes
└─ scheduleEdgeUpdate() (rAF throttled)
└─ Edge regeneration on next frame
User: "Select mesh"
↓
Selection event → setTargetMesh(mesh)
├─ Apply clippingPlanes only to selected mesh's material
└─ Regenerate edges (based on selected mesh)
Comparison with Previous Work
At my previous job, I offloaded clipping intersection calculations to Workers + WASM for an industrial 3D viewer. We had to handle large CAD models with millions of triangles.
In Voxel3D, main-thread BVH shapecast + rAF throttling delivers sufficient performance while keeping the architecture much simpler.
Choosing the right technology for the right scale matters.