3D / Viewer
Die Viewer-Erweiterungen ermöglichen es, den 3D-Canvas und dessen Komponenten anzupassen. Dies umfasst Custom Layout Components, dynamische Modelle, Label-Overlays und Canvas-Wrapper.
Grundstruktur
Viewer-Erweiterungen werden im viewer Objekt registriert:
// 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
Der Canvas ist das Three.js / React-Three-Fiber Root-Element, das die gesamte 3D-Szene rendert.
canvas
Überschreibt den Root Canvas-Bereich:
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"
}
Anwendungsbeispiele:
- Overlays über dem Canvas
- Custom Loading-Indikatoren
- Performance-Monitoring
- Screenshot-Buttons
Custom Layout Components
Custom Layout Components sind React-Komponenten, die Sie im Layout-Editor platzieren können. Sie haben Zugriff auf BOM-Daten und Gesamtpreis.
Registrierung
import type { CustomLayoutComponentProps } from "k3-plugin-api";
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"
}
}
Verwendung im Layout
Nach der Registrierung kann die Komponente im Layout-Editor verwendet werden:
<CustomLayoutComponent name="PriceDisplay" />
CustomLayoutComponentProps
Alle Custom Layout Components erhalten diese 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
Jeder BOM-Eintrag hat diese Struktur:
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
}
Beispiel: Erweiterte Artikelliste
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>
);
};
Beispiel: Preis-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>
);
};
Custom Props übergeben
Sie können zusätzliche Props im Layout-Tag übergeben:
<CustomLayoutComponent
name="PriceDisplay"
showVat={true}
currency="EUR"
/>
In der Komponente:
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>
);
};
Dynamische Modelle
Siehe die Dynamische Modelle Dokumentation für Details zu viewer.models.
Kurz:
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 sind Overlay-Elemente, die über der 3D-Szene gerendert werden (z.B. Maßketten, Hotspots).
labels.display
Überschreibt die Desktop-Label-Anzeige (alias für 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
Überschreibt die mobile Label-Anzeige (alias für 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 (experimentell)
sceneComponents ist ein experimentelles Feature und kann sich noch ändern.
Scene Components werden direkt in die Three.js-Szene eingefügt:
viewer: {
sceneComponents: {
ambientLight: {
hoc: (Original) => (props) => (
<>
<Original {...props} />
<ambientLight intensity={0.5} />
</>
),
description: "Fügt zusätzliches Umgebungslicht hinzu"
}
}
}
Gängige Namen sind "OrbitControls", "Bounds", "ContactShadows" und die Lichter der Szene – gemeint ist immer der Name des Eintrags im Szenen-JSON des Szenen-Editors.
Orbit-Rotation einschränken
Soll sich das Produkt nur in einem bestimmten Winkelbereich drehen lassen – etwa eine wandmontierte Veranda, die man nicht von hinten ansehen soll – wickeln Sie die OrbitControls ein:
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}
/>
);
},
},
}
Spreaden Sie props zuerst, damit die Einstellungen aus dem Szenen-Editor erhalten bleiben, und überschreiben Sie danach nur das, was Ihr Plugin steuert.
Eine Prop, die zwischen zwei Renderings verschwindet, wird von R3F zurückgesetzt – bei Three.js-Objekten mit Konstruktor-Argumenten (OrbitControls, EffectComposer …) gibt es dafür aber keinen Prototyp-Standardwert, R3F fällt auf 0 zurück. minAzimuthAngle/maxAzimuthAngle einfach nicht mehr zu übergeben klemmt die Drehung also auf [0, 0] und friert sie ein, statt sie freizugeben. Die Ternaries oben liefern immer einen Wert – genau das macht sie sicher.
Bei mehreren Grenzen ist ein Defaults-Objekt übersichtlicher:
// Die echten Standardwerte der OrbitControls, explizit übergeben
const unrestrictedLimits = {
minAzimuthAngle: -Infinity,
maxAzimuthAngle: Infinity,
minPolarAngle: 0,
maxPolarAngle: Math.PI,
};
<Wrapped
{...unrestrictedLimits}
{...props}
{...(active ? engravingLimits : {})}
/>;
Wenn die Grenzen nicht von der Konfiguration abhängen, tragen Sie minAzimuthAngle/maxAzimuthAngle direkt am OrbitControls-Eintrag im Szenen-JSON des Szenen-Editors ein.
Die Kameraposition selbst setzen Sie nicht hier, sondern mit useSetCameraPosition().
Vollständiges Beispiel
import type { CustomLayoutComponentProps, HOC } from "k3-plugin-api";
// 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 bei BOM-Berechnungen
BOM-Daten können groß werden. Memoizen Sie aufwändige Berechnungen:
const totalQty = useMemo(
() => bom.reduce((sum, entry) => sum + entry.qty, 0),
[bom]
);
2. Responsive Design für Custom Components
Custom Layout Components sollten responsive sein:
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 für BOM-Daten
BOM kann während des Ladens leer oder undefined sein:
if (!bom || bom.length === 0) {
return <div>Keine Artikel in der Konfiguration</div>;
}
4. Währungsformatierung
Nutzen Sie einheitliche Währungsformatierung:
const formatPrice = (price: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(price);
};
5. TypeScript verwenden
Nutzen Sie die bereitgestellten Typen:
import type {
CustomLayoutComponentProps,
K3BomEntry,
K3ViewerExtensions
} from "k3-plugin-api";
Layout-Editor Verwendung
Nach der Registrierung können Custom Layout Components im Layout-Editor verwendet werden:
<CustomLayoutComponent name="ConfigSummary" />
<CustomLayoutComponent name="PriceDisplay" showVat={true} />
Sie erscheinen als normale React-Komponenten und können mit CSS positioniert werden.
Debugging
Loggen Sie Props während der Entwicklung:
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: Weitere Daten
Die CustomLayoutComponentProps werden in Zukunft erweitert um:
- Zugriff auf Merkmalswerte
- Konfigurationsstatus
- Kamera-Informationen
- Weitere Konfigurator-Metadaten