ng-three-model-cropper

December 17, 2025 · View on GitHub

Angular 17+ Three.js 3D Model Cropper Library with cheap geometry cropping.

npm version npm downloads CI codecov License Angular Three.js

Overview

A highly configurable, UI-agnostic 3D model cropper component for Angular applications. Load GLB/FBX models, define a crop box, apply "cheap" triangle-pruning cropping, and export the result as GLB.

Key Features

  • Multi-format Support: Load GLB, GLTF, and FBX 3D models
  • Cheap Cropping: Triangle-pruning based cropping (no boolean CSG operations)
  • GLB Export: Export cropped models as binary GLB files
  • Visual Helpers: Configurable crop box color, grid helper, and view helper (axis indicator)
  • Angular 17-20 Compatible: Built with Angular 17, works with apps 17-20
  • Zoneless Friendly: Signal-based state management, no zone.js dependency
  • Highly Configurable: Template customization, content projection, label overrides
  • UI Agnostic: Works with MatDialog or any dialog/container system
  • Partial Ivy: Published with partial compilation for broad compatibility

Installation

npm install ng-three-model-cropper three
npm install -D @types/three

Quick Start

import { Component } from '@angular/core';
import { ModelCropperComponent, CropResult } from 'ng-three-model-cropper';

@Component({
  selector: 'app-model-editor',
  standalone: true,
  imports: [ModelCropperComponent],
  template: `
    <ntmc-model-cropper
      [srcUrl]="modelUrl"
      [downloadMode]="'download'"
      [filename]="'my-cropped-model.glb'"
      (cropApplied)="onCropApplied($event)"
      (fileReady)="onFileReady($event)"
      (loadError)="onLoadError($event)"
    />
  `,
  styles: [
    `
      :host {
        display: block;
        width: 100%;
        height: 600px;
      }
    `,
  ],
})
export class ModelEditorComponent {
  modelUrl = 'assets/models/sample.glb';

  onCropApplied(result: CropResult): void {
    console.log(`Removed ${result.trianglesRemoved} triangles`);
  }

  onFileReady(buffer: ArrayBuffer): void {
    // Handle the exported GLB ArrayBuffer (e.g., upload to server)
  }

  onLoadError(message: string): void {
    console.error('Failed to load model:', message);
  }
}

Component API

Inputs

InputTypeDefaultDescription
srcUrlstringrequiredURL to the 3D model file (GLB/GLTF/FBX)
initialCropBoxCropBoxConfigauto-calculatedInitial crop box bounds
initialTransformMeshTransformConfigidentityInitial position/rotation
rotationUnit'radians' | 'degrees''radians'Unit for rotation values passed to setRotation (UI context)
downloadMode'download' | 'emit''download'Export behavior
filenamestring'cropped-model.glb'Download filename
cropBoxColorstring'#00ff00'Hex color for crop box visualization
showGridbooleanfalseShow grid helper in the scene
showViewHelperbooleanfalseShow view helper (axis indicator)
sceneBackgroundColorstring'#2a2a2a'CSS color for scene background; supports transparency (rgba, hex with alpha, named colors)
showLoadingOverlaybooleantrueShow the loading overlay with spinner
showErrorOverlaybooleantrueShow the error overlay
showLoadingProgressbooleantrueShow loading progress percentage
spinnerColorstring'#4caf50'Hex color for the loading spinner
uiTemplateTemplateRef-Custom UI template
labelsConfigPartial<ModelCropperLabels>defaultsUI label overrides

Outputs

OutputTypeDescription
cropAppliedCropResultEmitted after cropping with statistics
fileReadyArrayBufferEmitted with GLB data (emit mode only)
loadErrorstringEmitted when model loading fails
exportErrorstringEmitted when export fails
loadingProgressChangeLoadingProgressEmitted during model loading progress

Custom UI Template

Override the default UI panel with your own template:

@Component({
  template: `
    <ntmc-model-cropper [srcUrl]="modelUrl" [uiTemplate]="customUI">
      <ng-template #customUI let-ctx>
        <!-- ctx is ModelCropperUiContext -->
        <div class="my-custom-panel">
          <mat-slider
            [value]="ctx.cropBox.minX"
            (input)="ctx.setCropBoxValue('minX', $event.value)"
          />
          <button mat-raised-button (click)="ctx.applyCrop()">Crop Model</button>
          <button mat-button (click)="ctx.download()">Export</button>
        </div>
      </ng-template>
    </ntmc-model-cropper>
  `,
})
export class CustomUiComponent {}

UI Context API (ModelCropperUiContext)

Property/MethodDescription
cropBoxCurrent crop box configuration
rotationUnitUnit used for setRotation and meshTransformUi
meshTransformCurrent position/rotation values
meshTransformUiPosition + rotation values for UI (rotation in rotationUnit)
loadingState'idle' | 'loading' | 'loaded' | 'error'
loadingProgressDetailed loading progress information
errorMessageError message if any
boxVisibleCrop box visibility state
cropBoxColorCurrent crop box color (hex string)
gridVisibleGrid helper visibility state
viewHelperVisibleView helper visibility state
canApplyCropWhether cropping is available (model loaded)
canExportWhether export is available (crop applied and valid)
setCropBox(box)Set entire crop box
setCropBoxValue(key, value)Set single crop box value
setMeshTransform(transform)Set entire transform
setPosition(partial)Update position values
setRotation(partial)Update rotation values

Notes:

  • meshTransform.rotation is stored internally in radians (Three.js native).
  • For numeric inputs (native steppers), prefer meshTransformUi.rotation so degrees/radians display stays stable and step-aligned. | toggleBoxVisibility(visible) | Show/hide crop box | | setCropBoxColor(color) | Set crop box color (hex string) | | toggleGridVisibility(visible) | Show/hide grid helper | | toggleViewHelperVisibility(visible) | Show/hide view helper | | applyCrop() | Execute cropping | | download() | Trigger export | | resetCropBox() | Reset crop box to defaults | | resetTransform() | Reset transform to identity |

Using with MatDialog

import { MatDialog } from '@angular/material/dialog';
import { ModelCropperComponent } from 'ng-three-model-cropper';

@Component({...})
export class AppComponent {
  constructor(private dialog: MatDialog) {}

  openCropper(): void {
    const dialogRef = this.dialog.open(ModelCropperDialogComponent, {
      width: '90vw',
      height: '80vh',
      data: { modelUrl: 'assets/model.glb' }
    });

    dialogRef.afterClosed().subscribe(result => {
      if (result?.fileBuffer) {
        // Handle exported GLB
      }
    });
  }
}

@Component({
  standalone: true,
  imports: [ModelCropperComponent, MatDialogModule],
  template: `
    <mat-dialog-content>
      <ntmc-model-cropper
        [srcUrl]="data.modelUrl"
        [downloadMode]="'emit'"
        (fileReady)="onFileReady($event)"
        (cropApplied)="onCropApplied($event)"
      />
    </mat-dialog-content>
    <mat-dialog-actions>
      <button mat-button mat-dialog-close>Cancel</button>
      <button mat-button [mat-dialog-close]="result">Save</button>
    </mat-dialog-actions>
  `
})
export class ModelCropperDialogComponent {
  result: { fileBuffer?: ArrayBuffer; cropResult?: CropResult } = {};

  constructor(@Inject(MAT_DIALOG_DATA) public data: { modelUrl: string }) {}

  onFileReady(buffer: ArrayBuffer): void {
    this.result.fileBuffer = buffer;
  }

  onCropApplied(cropResult: CropResult): void {
    this.result.cropResult = cropResult;
  }
}

Types

interface CropBoxConfig {
  minX: number;
  minY: number;
  minZ: number;
  maxX: number;
  maxY: number;
  maxZ: number;
}

interface MeshTransformConfig {
  position: { x: number; y: number; z: number };
  rotation: { x: number; y: number; z: number };
}

/**
 * Angle unit for rotation values
 */
type AngleUnit = 'radians' | 'degrees';

interface CropResult {
  success: boolean;
  trianglesRemoved: number;
  trianglesKept: number;
  meshesProcessed: number;
}

interface LoadingProgress {
  state: LoadingState;
  percentage: number;
  loaded: number;
  total: number;
  message: string;
}

type DownloadMode = 'download' | 'emit';
type LoadingState = 'idle' | 'loading' | 'loaded' | 'error';

Architecture

The library is organized for multi-version compatibility:

src/lib/
├── core/                 # Framework-agnostic (Three.js only)
│   ├── types.ts          # Interfaces and type definitions
│   ├── ui-context.ts     # UI context interface
│   ├── model-crop-engine.ts  # Main Three.js engine
│   └── cheap-cropper.ts  # Triangle-pruning cropper
└── ng/                   # Angular 17 adapter
    ├── model-cropper.service.ts   # Angular service wrapper
    └── model-cropper.component.ts # Standalone component

Testing

The library includes a comprehensive test suite covering all modules. Code coverage is tracked via Codecov.

Running Tests

# Run library tests (headless, single run)
npm run test:lib

# Run library tests (watch mode for development)
npm run test:lib:watch

# Run demo app tests
npm test

Development (Demo App)

# Run the demo app (recommended)
npm run dev

In development mode, the demo app resolves ng-three-model-cropper directly from the library source (projects/model-cropper/src). This avoids intermittent Vite pre-transform resolution failures that can happen when using the built dist/ output while it is being regenerated.

Optional: Test the built dist/ output

If you specifically want to validate the packaged output in dist/model-cropper:

npm run dev:dist

This runs a one-time library build first. If you want live rebuilds of the library output, run npm run watch:lib in a separate terminal.

Future Angular Versions

The core/ folder is framework-agnostic. To support Angular 19/20+:

  1. Create a new branch (e.g., angular-20)
  2. Update workspace to Angular 20
  3. Create ng/adapter-angular20/ with updated components
  4. Publish as ng-three-model-cropper@2.x with updated peer dependencies

Build & Publish

# Build library
npm run build:lib

# Publish to npm
npm run publish:lib

Requirements

  • Angular 17-20
  • Three.js >= 0.150.0
  • TypeScript 5.x

License

Apache License 2.0