Skip to main content

3D / Viewer

The viewer extensions allow you to customize the 3D canvas and its components. This includes custom layout components, dynamic models, label overlays, and canvas wrappers.

Basic Structure​

Viewer extensions are registered in the viewer object:

// In Plugin.tsx
export default {
// ...
viewer: {
canvas: { hoc: ..., description: "..." },
customLayoutComponents: { /* Ihre Custom Components */ },
models: [ /* Siehe "Dynamische Modelle" Seite */ ],
labels: {
display: { hoc: ..., description: "..." },
mobile: { hoc: ..., description: "..." }
}
}
}

Canvas​

The canvas is the Three.js / React-Three-Fiber root element that renders the entire 3D scene.

canvas​

Overrides the root canvas area:

canvas: {
hoc: (Original) => (props) => (
<div className="canvas-wrapper">
<div className="canvas-overlay">
<button className="reset-camera-btn">Kamera zurücksetzen</button>
</div>
<Original {...props} />
</div>
),
description: "Fügt Kamera-Reset-Button über Canvas hinzu"
}

Use Cases:

  • Overlays on top of the canvas
  • Custom loading indicators
  • Performance monitoring
  • Screenshot buttons

Custom Layout Components​

Custom Layout Components are React components that you can place in the Layout Editor. They have access to BOM data and the total price.

Registration​

__PROTECTED_0__

const PriceDisplay: React.FC<CustomLayoutComponentProps> = ({
name,
bom,
totalPrice
}) => {
return (
<div className="price-display-3d">
<h3>Gesamtpreis:</h3>
<div className="price">{totalPrice.toFixed(2)} €</div>
<div className="item-count">{bom.length} Artikel</div>
</div>
);
};

// Plugin Descriptor
customLayoutComponents: {
PriceDisplay: {
component: PriceDisplay,
label: "3D Preisanzeige",
description: "Zeigt Preis und Artikelanzahl in der Szene"
}
}

Use in the Layout​

After registration, the component can be used in the Layout Editor:

<CustomLayoutComponent name="PriceDisplay" />

CustomLayoutComponentProps​

All Custom Layout Components receive these props:

interface CustomLayoutComponentProps {
/** Name der Komponente (aus dem Layout-Tag) */
name: string;

/** Live Bill-of-Materials der aktuellen Konfiguration */
bom: K3BomEntry[];

/** Aggregierter Gesamtpreis */
totalPrice: number;

/** Weitere Props aus dem Layout-Tag */
[key: string]: unknown;
}

K3BomEntry​

Each BOM entry has the following structure:

interface K3BomEntry {
article: {
id: number;
no: string; // Artikelnummer
name: string;
description?: string | null;
thumbnail?: string | null;
category?: string | null;
};
qty: number; // Menge
amount?: number | null; // Optional: Override-Menge
price: {
price: number;
unit?: string; // z.B. "€/m"
};
main?: boolean; // true = Hauptartikel, false = Zubehör
}

Example: Extended Item List​

const ArticleList: React.FC<CustomLayoutComponentProps> = ({ bom }) => {
const mainArticles = bom.filter(entry => entry.main);
const accessories = bom.filter(entry => !entry.main);

return (
<div className="article-list-3d">
<section>
<h4>Hauptartikel</h4>
{mainArticles.map((entry, i) => (
<div key={i} className="article-item">
{entry.article.thumbnail && (
<img src={entry.article.thumbnail} alt={entry.article.name} />
)}
<div>
<strong>{entry.article.name}</strong>
<div>Art.-Nr.: {entry.article.no}</div>
<div>Menge: {entry.qty}</div>
</div>
</div>
))}
</section>

{accessories.length > 0 && (
<section>
<h4>Zubehör</h4>
{accessories.map((entry, i) => (
<div key={i} className="article-item accessory">
<span>{entry.article.name}</span>
<span>{entry.qty}x</span>
</div>
))}
</section>
)}
</div>
);
};

Example: Price Breakdown​

const PriceBreakdown: React.FC<CustomLayoutComponentProps> = ({ 
bom,
totalPrice
}) => {
const mainTotal = bom
.filter(e => e.main)
.reduce((sum, e) => sum + (e.price.price * e.qty), 0);

const accessoriesTotal = bom
.filter(e => !e.main)
.reduce((sum, e) => sum + (e.price.price * e.qty), 0);

return (
<div className="price-breakdown">
<h3>Preisaufstellung</h3>

<div className="breakdown-item">
<span>Hauptprodukte:</span>
<span>{mainTotal.toFixed(2)} €</span>
</div>

<div className="breakdown-item">
<span>Zubehör:</span>
<span>{accessoriesTotal.toFixed(2)} €</span>
</div>

<div className="breakdown-divider" />

<div className="breakdown-total">
<strong>Gesamt:</strong>
<strong>{totalPrice.toFixed(2)} €</strong>
</div>
</div>
);
};

Passing Custom Props​

You can pass additional props in the layout tag:

<CustomLayoutComponent 
name="PriceDisplay"
showVat={true}
currency="EUR"
/>

In the component:

const PriceDisplay: React.FC<CustomLayoutComponentProps> = ({ 
name,
totalPrice,
showVat,
currency
}) => {
const vat = showVat ? totalPrice * 0.19 : 0;

return (
<div>
<div>Netto: {totalPrice.toFixed(2)} {currency}</div>
{showVat && <div>MwSt (19%): {vat.toFixed(2)} {currency}</div>}
{showVat && <div>Brutto: {(totalPrice + vat).toFixed(2)} {currency}</div>}
</div>
);
};

Dynamic Models​

See the Dynamische Modelle documentation for details on viewer.models.

In short:

viewer: {
models: [
{
type: "vendor.my-model",
label: "Mein Modell",
description: "Ein parametrisches 3D-Modell",
component: MyModelComponent,
materials: ["surface"],
disabledForAR: false,
propsDialog: { /* ... */ },
defaultProps: { /* ... */ }
}
]
}

Labels & Hotspots​

Labels are overlay elements that are rendered on top of the 3D scene (e.g., dimension chains, hotspots).

labels.display​

Overrides the desktop label display (alias for ui.layout.labelActionDisplay):

labels: {
display: {
hoc: (Original) => (props) => (
<div className="custom-labels-wrapper">
<Original {...props} />
<div className="label-legend">
📏 = Maßkette | 📍 = Hotspot
</div>
</div>
),
description: "Fügt Legende für Labels hinzu"
}
}

labels.mobile​

Overrides the mobile label display (alias for ui.layout.mobileLabelActionDisplay):

labels: {
mobile: {
hoc: (Original) => (props) => (
<div className="mobile-labels-optimized">
<Original {...props} />
</div>
),
description: "Optimierte Label-Darstellung für mobile"
}
}

Scene Components (experimental)​

Experimental

sceneComponents is an experimental feature and is subject to change.

Scene Components are inserted directly into the Three.js scene:

viewer: {
sceneComponents: {
ambientLight: {
hoc: (Original) => (props) => (
<>
<Original {...props} />
<ambientLight intensity={0.5} />
</>
),
description: "Fügt zusätzliches Umgebungslicht hinzu"
}
}
}

Common names include "OrbitControls", "Bounds", "ContactShadows", and the scene’s lights—this always refers to the name of the entry in the scene JSON within the Scene Editor.

Limit Orbit Rotation​

If you want the product to rotate only within a specific angle range—such as a wall-mounted porch that shouldn’t be viewed from the back—wrap the OrbitControls in the following:

sceneComponents: {
OrbitControls: {
description: "Schränkt die Drehung ein, wenn die Veranda an der Wand hängt",
hoc: (Wrapped) => (props) => {
const freestanding = useConfigurationVariable("freistehend");
const limit = Math.PI * (85 / 180);
return (
<Wrapped
{...props}
minAzimuthAngle={freestanding ? -Infinity : -limit}
maxAzimuthAngle={freestanding ? Infinity : limit}
/>
);
},
},
}

First, wrap props to preserve the settings from the Scene Editor, and then override only the parts controlled by your plugin.

Never omit the prop; instead, pass the default value

A prop that disappears between two renders is reset by R3F—but for Three.js objects with constructor arguments (OrbitControls, EffectComposer, etc.), there is no default prototype value, so R3F falls back to 0. Simply stopping the pass of minAzimuthAngle/maxAzimuthAngle locks the rotation at [0, 0] and freezes it instead of releasing it. The ternary operators above always return a value—that’s exactly what makes them safe.

When there are multiple limits, a Defaults object provides a clearer structure:

// Die echten Standardwerte der OrbitControls, explizit übergeben
const unrestrictedLimits = {
minAzimuthAngle: -Infinity,
maxAzimuthAngle: Infinity,
minPolarAngle: 0,
maxPolarAngle: Math.PI,
};

<Wrapped
{...unrestrictedLimits}
{...props}
{...(active ? engravingLimits : {})}
/>;
Fixed limits do not require a plugin

If the limits do not depend on the configuration, enter minAzimuthAngle/maxAzimuthAngle directly in the OrbitControls entry in the Scene Editor’s scene JSON.

You do not set the camera position itself here; instead, use PROTECTED_44.

Complete Example​

__PROTECTED_1__

// Custom Layout Component
const ConfigSummary: React.FC<CustomLayoutComponentProps> = ({
bom,
totalPrice
}) => {
return (
<div className="config-summary-3d">
<h3>Ihre Konfiguration</h3>

<div className="summary-stats">
<div className="stat">
<label>Artikel:</label>
<value>{bom.length}</value>
</div>
<div className="stat">
<label>Gesamtpreis:</label>
<value>{totalPrice.toFixed(2)} €</value>
</div>
</div>

<div className="article-preview">
{bom.slice(0, 3).map((entry, i) => (
<div key={i} className="preview-item">
<span>{entry.article.name}</span>
<span className="qty">×{entry.qty}</span>
</div>
))}
{bom.length > 3 && (
<div className="more">+ {bom.length - 3} weitere</div>
)}
</div>
</div>
);
};

// Canvas mit Toolbar
const canvasHOC: HOC = (Original) => (props) => (
<div className="enhanced-canvas">
<div className="canvas-toolbar">
<button>🏠 Ansicht zurücksetzen</button>
<button>📸 Screenshot</button>
</div>
<Original {...props} />
</div>
);

// Labels mit Custom Styling
const labelsHOC: HOC = (Original) => (props) => (
<div className="enhanced-labels">
<Original {...props} />
<style jsx>{`
.enhanced-labels {
/* Custom Label Styles */
}
`}</style>
</div>
);

// Im Plugin registrieren:
export default {
// ...
viewer: {
canvas: {
hoc: canvasHOC,
description: "Fügt Toolbar über Canvas hinzu"
},

customLayoutComponents: {
ConfigSummary: {
component: ConfigSummary,
label: "Konfigurations-Zusammenfassung",
description: "Zeigt kompakte Zusammenfassung in 3D-Szene"
}
},

labels: {
display: {
hoc: labelsHOC,
description: "Custom Label Styling"
}
}
}
};

Best Practices​

1. Performance for BOM Calculations​

BOM data can become large. Use memoization for computationally intensive calculations:

const totalQty = useMemo(
() => bom.reduce((sum, entry) => sum + entry.qty, 0),
[bom]
);

2. Responsive Design for Custom Components​

Custom layout components should be responsive:

const ConfigSummary: React.FC<CustomLayoutComponentProps> = ({ bom }) => {
const [isMobile, setIsMobile] = useState(false);

useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);

return (
<div className={isMobile ? "summary-mobile" : "summary-desktop"}>
{/* ... */}
</div>
);
};

3. Null Checks for BOM Data​

The BOM may be empty or undefined during loading:

if (!bom || bom.length === 0) {
return <div>Keine Artikel in der Konfiguration</div>;
}

4. Currency Formatting​

Use consistent currency formatting:

const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(price);
};

5. Use TypeScript​

Use the provided types:

__PROTECTED_2__
CustomLayoutComponentProps,
K3BomEntry,
K3ViewerExtensions
} from "k3-plugin-api";

Using the Layout Editor​

After registration, custom layout components can be used in the layout editor:

<CustomLayoutComponent name="ConfigSummary" />
<CustomLayoutComponent name="PriceDisplay" showVat={true} />

They appear as normal React components and can be positioned using CSS.

Debugging​

Log props during development:

const MyComponent: React.FC<CustomLayoutComponentProps> = (props) => {
console.log("Custom Layout Component Props:", props);
console.log("BOM:", props.bom);
console.log("Total Price:", props.totalPrice);

return <div>...</div>;
};

WIP: Additional Data​

Work in Progress

The CustomLayoutComponentProps will be expanded in the future to include:

  • Access to attribute values
  • Configuration status
  • Camera information
  • Additional configurator metadata