2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung (konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text- Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback, falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform) fuer fluessige Interaktion ohne React-Re-Render je Frame. Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM (Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext- Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
// ambientCG-API-Client — Live-Zugriff auf die KOMPLETTE CC0-Materialbibliothek
|
||||
// (ambientcg.com, ~2000+ Materialien). Statt alles zu bündeln (Gigabytes)
|
||||
// durchsucht die App die Bibliothek zur Laufzeit über die API, zeigt Thumbnails
|
||||
// und lädt die Textur-Karten eines Materials ERST bei Auswahl herunter,
|
||||
// entpackt das Zip (jszip) und erzeugt Blob-URLs je Karte — direkt kompatibel
|
||||
// mit `MaterialRuntime` (runtime.ts), das beliebige URLs/Blob-URLs lädt.
|
||||
//
|
||||
// CORS: Die Such-JSON (`ambientcg.com/api/v2/full_json`) und der Download-
|
||||
// Starter (`ambientcg.com/get`, ein 302-Redirect) senden KEINE CORS-Header und
|
||||
// sind daher aus dem Browser NICHT direkt abrufbar. Sie laufen deshalb über
|
||||
// einen Proxy (Dev: Vite-Proxy `/ambientcg` → siehe vite.config.ts; Prod: eine
|
||||
// eigene Proxy-Route, konfigurierbar über VITE_AMBIENTCG_PROXY). Die Thumbnails
|
||||
// (acg-media.struffelproductions.com) senden `Access-Control-Allow-Origin: *`
|
||||
// und werden daher DIREKT geladen (kein Proxy nötig).
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import JSZip from "jszip";
|
||||
import type { ComponentMaterial } from "../model/types";
|
||||
|
||||
/**
|
||||
* Proxy-Basis für die CORS-behafteten ambientCG-Endpunkte (`/api/...` und
|
||||
* `/get`). Default `/ambientcg` — im Dev vom Vite-Proxy auf `https://
|
||||
* ambientcg.com` gemappt. Für Prod via `VITE_AMBIENTCG_PROXY` überschreibbar
|
||||
* (z. B. eine eigene Proxy-Route). Ohne trailing slash.
|
||||
*/
|
||||
// `import.meta.env` wird von Vite injiziert; da im Projekt keine vite/client-
|
||||
// Typen eingebunden sind, greifen wir defensiv typisiert darauf zu.
|
||||
const ENV = (import.meta as unknown as { env?: Record<string, string | undefined> })
|
||||
.env;
|
||||
const PROXY_BASE: string =
|
||||
ENV?.VITE_AMBIENTCG_PROXY?.replace(/\/$/, "") ?? "/ambientcg";
|
||||
|
||||
/** Ein Suchtreffer der ambientCG-Bibliothek (für das Thumbnail-Grid). */
|
||||
export interface AmbientMaterial {
|
||||
/** ambientCG-Asset-ID, z. B. „Wood095". */
|
||||
id: string;
|
||||
/** Anzeigename (aus der API; sonst die ID). */
|
||||
name: string;
|
||||
/** Kategorie-Anzeigename, z. B. „Wood" (kann leer sein). */
|
||||
category: string;
|
||||
/** Thumbnail-URL (direkt ladbar, CORS `*`). */
|
||||
thumbUrl: string;
|
||||
}
|
||||
|
||||
/** Eine Seite Suchtreffer plus Gesamtzahl (für Pagination/„mehr laden"). */
|
||||
export interface AmbientSearchResult {
|
||||
items: AmbientMaterial[];
|
||||
/** Gesamtzahl gefundener Assets (server-seitig gezählt). */
|
||||
total: number;
|
||||
/** Verwendeter Offset dieser Seite. */
|
||||
offset: number;
|
||||
/** Angeforderte Seitengröße. */
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** Verfügbare Textur-Auflösungen (ambientCG-Attribut-Präfix). */
|
||||
export type AmbientResolution = "1K" | "2K" | "4K";
|
||||
|
||||
/** Optionen für {@link searchMaterials}. */
|
||||
export interface SearchOptions {
|
||||
/** Freitext-Suche (leer = alle, nach Popularität sortiert). */
|
||||
query?: string;
|
||||
/** Optionaler Kategorie-Filter (ambientCG-Kategorie-Schlüssel). */
|
||||
category?: string;
|
||||
/** Seitengröße (Default 24). */
|
||||
limit?: number;
|
||||
/** Offset für Pagination (Default 0). */
|
||||
offset?: number;
|
||||
/** Abbruch-Signal (z. B. bei neuer Suche). */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// ── Interne API-Typen (nur die genutzten Felder) ───────────────────────────
|
||||
|
||||
interface RawPreviewImage {
|
||||
[size: string]: string | undefined;
|
||||
}
|
||||
|
||||
interface RawAsset {
|
||||
assetId: string;
|
||||
displayName?: string;
|
||||
customDisplayName?: string;
|
||||
displayCategory?: string;
|
||||
category?: string | null;
|
||||
previewImage?: RawPreviewImage;
|
||||
}
|
||||
|
||||
interface RawFullJson {
|
||||
numberOfResults?: number;
|
||||
foundAssets?: RawAsset[];
|
||||
}
|
||||
|
||||
/** Baut eine Proxy-URL für einen ambientCG-Pfad (mit führendem `/`). */
|
||||
function proxyUrl(path: string): string {
|
||||
return `${PROXY_BASE}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/** Wählt das größte verfügbare Thumbnail aus dem previewImage-Objekt. */
|
||||
function pickThumb(preview: RawPreviewImage | undefined): string {
|
||||
if (!preview) return "";
|
||||
// Bevorzugt größere PNG-Thumbnails (bessere Vorschau im Grid).
|
||||
const order = ["512-PNG", "256-PNG", "128-PNG", "64-PNG"];
|
||||
for (const key of order) {
|
||||
const url = preview[key];
|
||||
if (url) return url;
|
||||
}
|
||||
// Fallback: irgendein vorhandener Wert.
|
||||
for (const v of Object.values(preview)) if (v) return v;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Durchsucht die ambientCG-Materialbibliothek. Liefert eine Seite Treffer mit
|
||||
* Thumbnails plus die Gesamtzahl (für „mehr laden"). Läuft über den Proxy
|
||||
* (CORS). Wirft bei Netz-/CORS-Fehlern — der Aufrufer zeigt einen Hinweis.
|
||||
*/
|
||||
export async function searchMaterials(
|
||||
opts: SearchOptions = {},
|
||||
): Promise<AmbientSearchResult> {
|
||||
const { query, category, limit = 24, offset = 0, signal } = opts;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("type", "Material");
|
||||
// Nur die für Grid + IDs nötigen Daten anfordern (kleinere Antwort).
|
||||
params.set("include", "imageData");
|
||||
params.set("limit", String(limit));
|
||||
params.set("offset", String(offset));
|
||||
if (query && query.trim()) params.set("q", query.trim());
|
||||
if (category && category.trim()) params.set("category", category.trim());
|
||||
// Ohne Freitext nach Popularität sortieren (sinnvolle Default-Reihenfolge).
|
||||
if (!query || !query.trim()) params.set("sort", "Popular");
|
||||
|
||||
const url = proxyUrl(`/api/v2/full_json?${params.toString()}`);
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`ambientCG-API HTTP ${res.status}`);
|
||||
}
|
||||
const json = (await res.json()) as RawFullJson;
|
||||
|
||||
const items: AmbientMaterial[] = (json.foundAssets ?? []).map((a) => ({
|
||||
id: a.assetId,
|
||||
name: a.customDisplayName || a.displayName || a.assetId,
|
||||
category: a.displayCategory || a.category || "",
|
||||
thumbUrl: pickThumb(a.previewImage),
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
total: json.numberOfResults ?? items.length,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Kategorie-Liste (statisch, aus den ambientCG-Material-Kategorien) ───────
|
||||
// Die häufigsten Material-Kategorien für den Filter. Die API kennt weitere; das
|
||||
// deckt die BIM-relevanten Oberflächen ab. Wert = ambientCG-Kategorie-Schlüssel.
|
||||
export const AMBIENT_CATEGORIES: string[] = [
|
||||
"Wood",
|
||||
"WoodFloor",
|
||||
"Concrete",
|
||||
"Bricks",
|
||||
"Plaster",
|
||||
"Tiles",
|
||||
"Marble",
|
||||
"Rock",
|
||||
"PavingStones",
|
||||
"Metal",
|
||||
"Ground",
|
||||
"Gravel",
|
||||
"Grass",
|
||||
"Fabric",
|
||||
"Leather",
|
||||
"Asphalt",
|
||||
"Terrazzo",
|
||||
"Wallpaper",
|
||||
"Roof",
|
||||
"OfficeCeiling",
|
||||
];
|
||||
|
||||
// ── Karten-Download + Entpacken ────────────────────────────────────────────
|
||||
|
||||
/** Zuordnung ambientCG-Dateinamen-Bestandteile → ComponentMaterial-Karten. */
|
||||
const MAP_MATCHERS: { kind: keyof ComponentMaterial; needles: string[] }[] = [
|
||||
{ kind: "color", needles: ["_color", "_col", "_diffuse", "_albedo"] },
|
||||
{ kind: "normal", needles: ["_normalgl", "_normal", "_nrm", "_nor"] },
|
||||
{ kind: "roughness", needles: ["_roughness", "_rough", "_rgh"] },
|
||||
{ kind: "metalness", needles: ["_metalness", "_metallic", "_metal"] },
|
||||
{ kind: "displacement", needles: ["_displacement", "_disp", "_height"] },
|
||||
{ kind: "ao", needles: ["_ambientocclusion", "_ao", "_occlusion"] },
|
||||
];
|
||||
|
||||
/** Ordnet einen Zip-Eintragsnamen einer Karten-Art zu (oder null). */
|
||||
function classifyMap(fileName: string): keyof ComponentMaterial | null {
|
||||
const lower = fileName.toLowerCase();
|
||||
if (!/\.(jpg|jpeg|png)$/.test(lower)) return null;
|
||||
// ambientCG liefert die Normal-Map in zwei Konventionen: DirectX (…NormalDX)
|
||||
// und OpenGL (…NormalGL). three.js erwartet OpenGL — die DX-Variante daher
|
||||
// NICHT als Normal-Karte übernehmen (sonst kippt die Tiefe je nach Zip-
|
||||
// Reihenfolge in die falsche Richtung).
|
||||
if (lower.includes("_normaldx")) return null;
|
||||
for (const m of MAP_MATCHERS) {
|
||||
if (m.needles.some((n) => lower.includes(n))) return m.kind;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Ergebnis von {@link fetchMaterialMaps}: Karten als Blob-URLs + Metadaten. */
|
||||
export interface FetchedMaterial {
|
||||
material: ComponentMaterial;
|
||||
/** Alle erzeugten Blob-URLs (zum Freigeben via `revokeMaterial`). */
|
||||
blobUrls: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt das 1K-JPG-Zip eines ambientCG-Materials (über den Proxy, da `/get`
|
||||
* keine CORS-Header sendet), entpackt es mit jszip und erzeugt je erkannter
|
||||
* Karte (color/normal/roughness/metalness/displacement/ao) eine Blob-URL. Das
|
||||
* Ergebnis ist ein `ComponentMaterial`, das `MaterialRuntime` direkt laden kann.
|
||||
*
|
||||
* `resolution` wählt das Auflösungs-Attribut (1K/2K/4K). 1K ist der sinnvolle
|
||||
* Default (schnell, für Echtzeit-3D ausreichend).
|
||||
*/
|
||||
export async function fetchMaterialMaps(
|
||||
id: string,
|
||||
resolution: AmbientResolution = "1K",
|
||||
sizeM = 1.0,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FetchedMaterial> {
|
||||
const file = `${id}_${resolution}-JPG.zip`;
|
||||
const url = proxyUrl(`/get?file=${encodeURIComponent(file)}`);
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`ambientCG-Download HTTP ${res.status}`);
|
||||
}
|
||||
const buf = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buf);
|
||||
|
||||
const material: ComponentMaterial = { libraryId: id, sizeM };
|
||||
const blobUrls: string[] = [];
|
||||
|
||||
// Alle Bild-Einträge durchgehen, je Karten-Art die erste Übereinstimmung
|
||||
// übernehmen (ambientCG liefert je Art genau eine Datei).
|
||||
const entries = Object.values(zip.files).filter((f) => !f.dir);
|
||||
for (const entry of entries) {
|
||||
const kind = classifyMap(entry.name);
|
||||
if (!kind || material[kind]) continue;
|
||||
const blob = await entry.async("blob");
|
||||
// Korrekten Bild-MIME setzen, damit der Browser die Blob-URL als Bild lädt.
|
||||
const ext = entry.name.toLowerCase().endsWith(".png")
|
||||
? "image/png"
|
||||
: "image/jpeg";
|
||||
const typed = blob.type ? blob : new Blob([blob], { type: ext });
|
||||
const objUrl = URL.createObjectURL(typed);
|
||||
(material as Record<string, unknown>)[kind] = objUrl;
|
||||
blobUrls.push(objUrl);
|
||||
}
|
||||
|
||||
if (blobUrls.length === 0) {
|
||||
throw new Error("Keine Textur-Karten im Zip gefunden");
|
||||
}
|
||||
return { material, blobUrls };
|
||||
}
|
||||
|
||||
/** Gibt die Blob-URLs eines heruntergeladenen Materials frei. */
|
||||
export function revokeMaterial(blobUrls: string[]): void {
|
||||
for (const u of blobUrls) URL.revokeObjectURL(u);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Auto-generated built-in material library (ambientCG, CC0 1.0).
|
||||
// Static asset paths under /public; loadable directly via three.js TextureLoader.
|
||||
// Source data mirrors public/assets/materials/manifest.json.
|
||||
|
||||
export type MaterialMapKind =
|
||||
| 'color'
|
||||
| 'normal'
|
||||
| 'roughness'
|
||||
| 'metalness'
|
||||
| 'displacement'
|
||||
| 'ao';
|
||||
|
||||
export interface MaterialAsset {
|
||||
/** ambientCG asset id, e.g. 'Concrete048'. */
|
||||
id: string;
|
||||
/** Localized display name (German). */
|
||||
name: string;
|
||||
/** ambientCG category key, e.g. 'Concrete'. */
|
||||
category: string;
|
||||
/** Absolute public paths to the available texture maps. */
|
||||
maps: Partial<Record<MaterialMapKind, string>>;
|
||||
}
|
||||
|
||||
export const MATERIAL_LIBRARY_SOURCE = "ambientCG (ambientcg.com)";
|
||||
export const MATERIAL_LIBRARY_LICENSE = "CC0 1.0 Universal (Public Domain)";
|
||||
export const MATERIAL_LIBRARY_RESOLUTION = "1K";
|
||||
|
||||
export const MATERIAL_LIBRARY: MaterialAsset[] = [
|
||||
{
|
||||
id: "Concrete048",
|
||||
name: "Beton",
|
||||
category: "Concrete",
|
||||
maps: {
|
||||
color: "/assets/materials/Concrete048/color.jpg",
|
||||
normal: "/assets/materials/Concrete048/normal.jpg",
|
||||
roughness: "/assets/materials/Concrete048/roughness.jpg",
|
||||
displacement: "/assets/materials/Concrete048/displacement.jpg",
|
||||
ao: "/assets/materials/Concrete048/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Plaster001",
|
||||
name: "Putz/Stuck",
|
||||
category: "Plaster",
|
||||
maps: {
|
||||
color: "/assets/materials/Plaster001/color.jpg",
|
||||
normal: "/assets/materials/Plaster001/normal.jpg",
|
||||
roughness: "/assets/materials/Plaster001/roughness.jpg",
|
||||
displacement: "/assets/materials/Plaster001/displacement.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Wood095",
|
||||
name: "Holz-Diele",
|
||||
category: "Wood",
|
||||
maps: {
|
||||
color: "/assets/materials/Wood095/color.jpg",
|
||||
normal: "/assets/materials/Wood095/normal.jpg",
|
||||
roughness: "/assets/materials/Wood095/roughness.jpg",
|
||||
displacement: "/assets/materials/Wood095/displacement.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "WoodFloor051",
|
||||
name: "Holz-Parkett",
|
||||
category: "WoodFloor",
|
||||
maps: {
|
||||
color: "/assets/materials/WoodFloor051/color.jpg",
|
||||
normal: "/assets/materials/WoodFloor051/normal.jpg",
|
||||
roughness: "/assets/materials/WoodFloor051/roughness.jpg",
|
||||
displacement: "/assets/materials/WoodFloor051/displacement.jpg",
|
||||
ao: "/assets/materials/WoodFloor051/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Bricks104",
|
||||
name: "Backstein",
|
||||
category: "Bricks",
|
||||
maps: {
|
||||
color: "/assets/materials/Bricks104/color.jpg",
|
||||
normal: "/assets/materials/Bricks104/normal.jpg",
|
||||
roughness: "/assets/materials/Bricks104/roughness.jpg",
|
||||
displacement: "/assets/materials/Bricks104/displacement.jpg",
|
||||
ao: "/assets/materials/Bricks104/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Tiles141",
|
||||
name: "Bodenfliesen",
|
||||
category: "Tiles",
|
||||
maps: {
|
||||
color: "/assets/materials/Tiles141/color.jpg",
|
||||
normal: "/assets/materials/Tiles141/normal.jpg",
|
||||
roughness: "/assets/materials/Tiles141/roughness.jpg",
|
||||
displacement: "/assets/materials/Tiles141/displacement.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Marble012",
|
||||
name: "Naturstein/Marmor",
|
||||
category: "Marble",
|
||||
maps: {
|
||||
color: "/assets/materials/Marble012/color.jpg",
|
||||
normal: "/assets/materials/Marble012/normal.jpg",
|
||||
roughness: "/assets/materials/Marble012/roughness.jpg",
|
||||
displacement: "/assets/materials/Marble012/displacement.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "PavingStones150",
|
||||
name: "Pflasterstein",
|
||||
category: "PavingStones",
|
||||
maps: {
|
||||
color: "/assets/materials/PavingStones150/color.jpg",
|
||||
normal: "/assets/materials/PavingStones150/normal.jpg",
|
||||
roughness: "/assets/materials/PavingStones150/roughness.jpg",
|
||||
displacement: "/assets/materials/PavingStones150/displacement.jpg",
|
||||
ao: "/assets/materials/PavingStones150/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Metal063",
|
||||
name: "Metall",
|
||||
category: "Metal",
|
||||
maps: {
|
||||
color: "/assets/materials/Metal063/color.jpg",
|
||||
normal: "/assets/materials/Metal063/normal.jpg",
|
||||
roughness: "/assets/materials/Metal063/roughness.jpg",
|
||||
metalness: "/assets/materials/Metal063/metalness.jpg",
|
||||
displacement: "/assets/materials/Metal063/displacement.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Gravel043",
|
||||
name: "Kies/Schotter",
|
||||
category: "Gravel",
|
||||
maps: {
|
||||
color: "/assets/materials/Gravel043/color.jpg",
|
||||
normal: "/assets/materials/Gravel043/normal.jpg",
|
||||
roughness: "/assets/materials/Gravel043/roughness.jpg",
|
||||
displacement: "/assets/materials/Gravel043/displacement.jpg",
|
||||
ao: "/assets/materials/Gravel043/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Grass005",
|
||||
name: "Gras",
|
||||
category: "Grass",
|
||||
maps: {
|
||||
color: "/assets/materials/Grass005/color.jpg",
|
||||
normal: "/assets/materials/Grass005/normal.jpg",
|
||||
roughness: "/assets/materials/Grass005/roughness.jpg",
|
||||
displacement: "/assets/materials/Grass005/displacement.jpg",
|
||||
ao: "/assets/materials/Grass005/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Ground103",
|
||||
name: "Boden",
|
||||
category: "Ground",
|
||||
maps: {
|
||||
color: "/assets/materials/Ground103/color.jpg",
|
||||
normal: "/assets/materials/Ground103/normal.jpg",
|
||||
roughness: "/assets/materials/Ground103/roughness.jpg",
|
||||
displacement: "/assets/materials/Ground103/displacement.jpg",
|
||||
ao: "/assets/materials/Ground103/ao.jpg",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default MATERIAL_LIBRARY;
|
||||
@@ -0,0 +1,169 @@
|
||||
// 3D-Material-Laufzeit: baut aus einem `ComponentMaterial` (Karten-URLs +
|
||||
// physische Kachelgröße) ein gecachtes three.js `MeshStandardMaterial`. Die
|
||||
// Texturen werden lazy über den `TextureLoader` geladen (asynchron; das
|
||||
// Material erscheint sofort, die Karten „poppen" nach dem Laden ein) und je
|
||||
// Karten-URL nur EINMAL dekodiert (geteilte Bilddaten). Die physische
|
||||
// Skalierung läuft über `texture.repeat`: ExtrudeGeometry erzeugt die Seiten-
|
||||
// wand-UVs bereits in WELT-Metern (U = Position entlang der Wand in Metern,
|
||||
// V = Höhe in Metern). Eine Kachel soll `sizeM` Meter messen → `repeat = 1/sizeM`
|
||||
// (uniform), unabhängig von der Flächengröße. Dadurch braucht es KEINE flächen-
|
||||
// spezifischen Material-Instanzen — alle Wandflächen eines Bauteils teilen sich
|
||||
// dasselbe Material; die Kacheln laufen über Flächengrenzen hinweg konsistent.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { ComponentMaterial } from "../model/types";
|
||||
import type { MaterialAsset } from "./library";
|
||||
|
||||
/** Default-Kachelgröße in Metern, falls `sizeM` fehlt. */
|
||||
export const DEFAULT_TILE_SIZE_M = 1.0;
|
||||
|
||||
/**
|
||||
* Cache + Loader für PBR-Materialien. Lebt so lange wie der Viewport-Aufbau
|
||||
* (eine Instanz je Szenen-Aufbau) und gibt im `dispose()` alle Texturen und
|
||||
* Materialien frei. Bilddaten werden je URL geteilt (mehrere Bauteile, die
|
||||
* dieselbe Karte nutzen, dekodieren sie nur einmal); die je Material geklonten
|
||||
* Textur-Objekte tragen ihre eigene `repeat`-Transform.
|
||||
*/
|
||||
export class MaterialRuntime {
|
||||
private readonly loader = new THREE.TextureLoader();
|
||||
/** Geteilte Quell-Texturen je URL (Bilddaten-Quelle für Klone). */
|
||||
private readonly sources = new Map<string, THREE.Texture>();
|
||||
/** Klone je Quell-URL — werden beim Bild-Load gesammelt geflaggt. */
|
||||
private readonly clonesBySource = new Map<string, Set<THREE.Texture>>();
|
||||
/** Fertige Materialien je Cache-Schlüssel (stabile Signatur). */
|
||||
private readonly materials = new Map<string, THREE.MeshStandardMaterial>();
|
||||
/** Alle erzeugten Textur-Klone (für dispose). */
|
||||
private readonly textures = new Set<THREE.Texture>();
|
||||
|
||||
/**
|
||||
* Lädt (oder liefert aus dem Cache) die Quell-Textur für `url`. Beim ersten
|
||||
* Aufruf wird asynchron geladen; sobald das Bild da ist, werden ALLE Klone
|
||||
* dieser Quelle als aktualisierungsbedürftig markiert (sonst zeigen sie kein
|
||||
* Bild bzw. der Renderer meldet „no image data"). `srgb` markiert die Farb-/
|
||||
* Albedo-Karte als sRGB (korrekte Farbe), alle anderen Karten sind Linear-Daten.
|
||||
*/
|
||||
private source(url: string, srgb: boolean): THREE.Texture {
|
||||
const key = `${srgb ? "s" : "l"}|${url}`;
|
||||
let tex = this.sources.get(key);
|
||||
if (!tex) {
|
||||
tex = this.loader.load(url, () => {
|
||||
// Bild geladen → alle bereits erzeugten Klone neu hochladen lassen.
|
||||
for (const clone of this.clonesBySource.get(key) ?? []) {
|
||||
clone.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
tex.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
|
||||
this.sources.set(key, tex);
|
||||
this.clonesBySource.set(key, new Set());
|
||||
}
|
||||
return tex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Material-Karte: klont die Quell-Textur (teilt deren Bilddaten via
|
||||
* `.image`/`.source`, kein erneutes Dekodieren), setzt RepeatWrapping und
|
||||
* `repeat = 1/sizeM` (physische Kachelung). Der Klon wird für dispose gemerkt.
|
||||
*/
|
||||
private map(url: string, srgb: boolean, sizeM: number): THREE.Texture {
|
||||
const key = `${srgb ? "s" : "l"}|${url}`;
|
||||
const src = this.source(url, srgb);
|
||||
const tex = src.clone();
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
const r = 1 / Math.max(sizeM, 0.001);
|
||||
tex.repeat.set(r, r);
|
||||
// Ist das Bild bereits geladen, sofort flaggen; sonst übernimmt das der
|
||||
// Source-onLoad (s. source()), der alle Klone dieser Quelle aktualisiert.
|
||||
// (three.js gibt für asynchron geladene Klone vor dem Bild-Load eine
|
||||
// harmlose „no image data"-Konsolenmeldung aus — die Karten erscheinen
|
||||
// korrekt, sobald das Bild da ist.)
|
||||
if (src.image) tex.needsUpdate = true;
|
||||
this.clonesBySource.get(key)?.add(tex);
|
||||
this.textures.add(tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
/** Stabile Cache-Signatur eines Materials (alle Karten-URLs + Größe). */
|
||||
private static keyOf(m: ComponentMaterial): string {
|
||||
return [
|
||||
m.color ?? "",
|
||||
m.normal ?? "",
|
||||
m.roughness ?? "",
|
||||
m.metalness ?? "",
|
||||
m.displacement ?? "",
|
||||
m.ao ?? "",
|
||||
m.sizeM ?? DEFAULT_TILE_SIZE_M,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert das (gecachte) `MeshStandardMaterial` für ein `ComponentMaterial`
|
||||
* oder null, wenn keinerlei Karte gesetzt ist (dann gilt das matte Default-
|
||||
* Verhalten). Das Material gilt für ALLE Flächen des Bauteils (geteilte
|
||||
* Kachel-Skalierung), da die UVs in Welt-Metern liegen.
|
||||
*/
|
||||
get(m: ComponentMaterial | undefined): THREE.MeshStandardMaterial | null {
|
||||
if (!m) return null;
|
||||
const hasAnyMap =
|
||||
m.color || m.normal || m.roughness || m.metalness || m.displacement || m.ao;
|
||||
if (!hasAnyMap) return null;
|
||||
|
||||
const key = MaterialRuntime.keyOf(m);
|
||||
const cached = this.materials.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const sizeM = m.sizeM ?? DEFAULT_TILE_SIZE_M;
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
// Weiß als Albedo-Basis, damit die Farb-Karte unverfälscht erscheint;
|
||||
// ohne Farb-Karte ein neutrales Hellgrau (sichtbares Volumen).
|
||||
color: m.color ? 0xffffff : 0xcccccc,
|
||||
roughness: 1,
|
||||
metalness: m.metalness ? 1 : 0,
|
||||
});
|
||||
if (m.color) mat.map = this.map(m.color, true, sizeM);
|
||||
if (m.normal) mat.normalMap = this.map(m.normal, false, sizeM);
|
||||
if (m.roughness) mat.roughnessMap = this.map(m.roughness, false, sizeM);
|
||||
if (m.metalness) mat.metalnessMap = this.map(m.metalness, false, sizeM);
|
||||
if (m.ao) mat.aoMap = this.map(m.ao, false, sizeM);
|
||||
if (m.displacement) {
|
||||
mat.displacementMap = this.map(m.displacement, false, sizeM);
|
||||
// Sehr dezent — eine echte Verschiebung braucht Tessellation; hier nur ein
|
||||
// Hauch, damit die Silhouette nicht aufreißt (Tiefe kommt aus normalMap).
|
||||
mat.displacementScale = 0.01;
|
||||
}
|
||||
mat.needsUpdate = true;
|
||||
|
||||
this.materials.set(key, mat);
|
||||
return mat;
|
||||
}
|
||||
|
||||
/** Gibt alle Texturen und Materialien frei. */
|
||||
dispose(): void {
|
||||
for (const tex of this.sources.values()) tex.dispose();
|
||||
this.sources.clear();
|
||||
for (const tex of this.textures) tex.dispose();
|
||||
this.textures.clear();
|
||||
this.clonesBySource.clear();
|
||||
for (const mat of this.materials.values()) mat.dispose();
|
||||
this.materials.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Baut aus einem Bibliotheks-Asset ein `ComponentMaterial` (Karten + Größe). */
|
||||
export function materialFromAsset(
|
||||
asset: MaterialAsset,
|
||||
sizeM = DEFAULT_TILE_SIZE_M,
|
||||
): ComponentMaterial {
|
||||
return {
|
||||
libraryId: asset.id,
|
||||
color: asset.maps.color,
|
||||
normal: asset.maps.normal,
|
||||
roughness: asset.maps.roughness,
|
||||
metalness: asset.maps.metalness,
|
||||
displacement: asset.maps.displacement,
|
||||
ao: asset.maps.ao,
|
||||
sizeM,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user