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)
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.
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 : {})}
/>;
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
The CustomLayoutComponentProps will be expanded in the future to include:
- Access to attribute values
- Configuration status
- Camera information
- Additional configurator metadata