Skip to content

Commit c45abcd

Browse files
committed
feat: Add material preview
1 parent 7f55483 commit c45abcd

File tree

2 files changed

+202
-86
lines changed

2 files changed

+202
-86
lines changed
Lines changed: 90 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,90 @@
1-
import { ipcRenderer } from "electron";
2-
import { basename, join, dirname } from "path/posix";
3-
import { pathExists, readJSON, writeJSON } from "fs-extra";
4-
5-
import { ReactNode } from "react";
6-
7-
import { FaRegClone } from "react-icons/fa6";
8-
import { GiMaterialsScience } from "react-icons/gi";
9-
10-
import { Tools } from "babylonjs";
11-
12-
import { UniqueNumber } from "../../../../tools/tools";
13-
14-
import { showAlert, showPrompt } from "../../../../ui/dialog";
15-
import { ContextMenuItem } from "../../../../ui/shadcn/ui/context-menu";
16-
17-
import { openMaterialViewer } from "../viewers/material-viewer";
18-
19-
import { AssetsBrowserItem } from "./item";
20-
21-
export class AssetBrowserMaterialItem extends AssetsBrowserItem {
22-
/**
23-
* @override
24-
*/
25-
protected getIcon(): ReactNode {
26-
return <GiMaterialsScience size="64px" />;
27-
}
28-
29-
/**
30-
* @override
31-
*/
32-
protected async onDoubleClick(): Promise<void> {
33-
const data = await readJSON(this.props.absolutePath);
34-
if (data.customType === "BABYLON.NodeMaterial") {
35-
ipcRenderer.send("window:open", "build/src/editor/windows/nme", {
36-
filePath: this.props.absolutePath,
37-
});
38-
} else {
39-
openMaterialViewer(this.props.editor, this.props.absolutePath);
40-
}
41-
}
42-
43-
/**
44-
* Returns the context menu content for the current item.
45-
* To be overriden by the specialized items implementations.
46-
*/
47-
protected getContextMenuContent(): ReactNode {
48-
return (
49-
<>
50-
<ContextMenuItem className="flex items-center gap-2" onClick={() => this._handleClone()}>
51-
<FaRegClone className="w-4 h-4" /> Clone...
52-
</ContextMenuItem>
53-
</>
54-
);
55-
}
56-
57-
private async _handleClone(): Promise<unknown> {
58-
const data = await readJSON(this.props.absolutePath);
59-
data.id = Tools.RandomId();
60-
data.uniqueId = UniqueNumber.Get();
61-
62-
let name = await showPrompt("Enter the name for the cloned material", undefined, basename(this.props.absolutePath).replace(".material", ""));
63-
64-
if (!name) {
65-
return;
66-
}
67-
68-
if (!name.endsWith(".material")) {
69-
name += ".material";
70-
}
71-
72-
const absoluteDestination = join(dirname(this.props.absolutePath), name);
73-
74-
if (await pathExists(absoluteDestination)) {
75-
return showAlert("Can't clone material", `A material with name ("${name}") already exists in the current folder.`);
76-
}
77-
78-
await writeJSON(absoluteDestination, data, {
79-
spaces: "\t",
80-
encoding: "utf-8",
81-
});
82-
83-
this.props.editor.layout.assets.refresh();
84-
this.props.editor.layout.assets.setSelectedFile(absoluteDestination);
85-
}
86-
}
1+
import { ipcRenderer } from "electron";
2+
import { basename, join, dirname } from "path/posix";
3+
import { pathExists, readJSON, writeJSON } from "fs-extra";
4+
5+
import { ReactNode } from "react";
6+
7+
import { FaRegClone } from "react-icons/fa6";
8+
9+
import { Tools } from "babylonjs";
10+
11+
import { UniqueNumber } from "../../../../tools/tools";
12+
13+
import { showAlert, showPrompt } from "../../../../ui/dialog";
14+
import { ContextMenuItem } from "../../../../ui/shadcn/ui/context-menu";
15+
16+
import { openMaterialViewer } from "../viewers/material-viewer";
17+
import { MaterialThumbnailRenderer } from "../renderers/material-thumbnail";
18+
19+
import { AssetsBrowserItem } from "./item";
20+
21+
export class AssetBrowserMaterialItem extends AssetsBrowserItem {
22+
/**
23+
* @override
24+
*/
25+
protected getIcon(): ReactNode {
26+
return (
27+
<div className="w-full h-full pointer-events-none">
28+
<MaterialThumbnailRenderer absolutePath={this.props.absolutePath} />
29+
</div>
30+
);
31+
}
32+
33+
/**
34+
* @override
35+
*/
36+
protected async onDoubleClick(): Promise<void> {
37+
const data = await readJSON(this.props.absolutePath);
38+
if (data.customType === "BABYLON.NodeMaterial") {
39+
ipcRenderer.send("window:open", "build/src/editor/windows/nme", {
40+
filePath: this.props.absolutePath,
41+
});
42+
} else {
43+
openMaterialViewer(this.props.editor, this.props.absolutePath);
44+
}
45+
}
46+
47+
/**
48+
* Returns the context menu content for the current item.
49+
* To be overriden by the specialized items implementations.
50+
*/
51+
protected getContextMenuContent(): ReactNode {
52+
return (
53+
<>
54+
<ContextMenuItem className="flex items-center gap-2" onClick={() => this._handleClone()}>
55+
<FaRegClone className="w-4 h-4" /> Clone...
56+
</ContextMenuItem>
57+
</>
58+
);
59+
}
60+
61+
private async _handleClone(): Promise<unknown> {
62+
const data = await readJSON(this.props.absolutePath);
63+
data.id = Tools.RandomId();
64+
data.uniqueId = UniqueNumber.Get();
65+
66+
let name = await showPrompt("Enter the name for the cloned material", undefined, basename(this.props.absolutePath).replace(".material", ""));
67+
68+
if (!name) {
69+
return;
70+
}
71+
72+
if (!name.endsWith(".material")) {
73+
name += ".material";
74+
}
75+
76+
const absoluteDestination = join(dirname(this.props.absolutePath), name);
77+
78+
if (await pathExists(absoluteDestination)) {
79+
return showAlert("Can't clone material", `A material with name ("${name}") already exists in the current folder.`);
80+
}
81+
82+
await writeJSON(absoluteDestination, data, {
83+
spaces: "\t",
84+
encoding: "utf-8",
85+
});
86+
87+
this.props.editor.layout.assets.refresh();
88+
this.props.editor.layout.assets.setSelectedFile(absoluteDestination);
89+
}
90+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useEffect, useRef, useState } from "react";
2+
import { Engine, Scene, CreateSphere, Vector3, DirectionalLight, HemisphericLight, UniversalCamera, Material, CubeTexture, Color3 } from "babylonjs";
3+
4+
import { GiMaterialsScience } from "react-icons/gi";
5+
6+
import { projectConfiguration } from "../../../../project/configuration";
7+
8+
export interface IMaterialThumbnailRendererProps {
9+
/**
10+
* The absolute path to the material file.
11+
*/
12+
absolutePath: string;
13+
/**
14+
* The width of the thumbnail.
15+
*/
16+
width?: number;
17+
/**
18+
* The height of the thumbnail.
19+
*/
20+
height?: number;
21+
/**
22+
* Optional environment texture to use.
23+
*/
24+
environmentTexture?: CubeTexture;
25+
}
26+
27+
export function MaterialThumbnailRenderer(props: IMaterialThumbnailRendererProps) {
28+
const canvasRef = useRef<HTMLCanvasElement>(null);
29+
const { width = 120, height = 120 } = props;
30+
const [loadError, setLoadError] = useState(false);
31+
32+
useEffect(() => {
33+
if (!projectConfiguration.path || !canvasRef.current) {
34+
return;
35+
}
36+
37+
// Create engine with appropriate settings for thumbnails
38+
const engine = new Engine(canvasRef.current, true, {
39+
antialias: true,
40+
audioEngine: false,
41+
adaptToDeviceRatio: true,
42+
preserveDrawingBuffer: true,
43+
premultipliedAlpha: false,
44+
});
45+
46+
// Create scene
47+
const scene = new Scene(engine);
48+
scene.clearColor.set(0, 0, 0, 0); // Set alpha to 0 for transparency
49+
50+
const camera = new UniversalCamera("UniversalCamera", new Vector3(0, 10, 30), scene);
51+
camera.fov = 0.4;
52+
camera.minZ = 0.1;
53+
54+
const dirLight = new DirectionalLight("dirLight", new Vector3(4, -4, -4), scene);
55+
dirLight.intensity = 1;
56+
dirLight.position = new Vector3(100, 100, 100);
57+
dirLight.diffuse = new Color3(1, 1, 1);
58+
dirLight.specular = new Color3(1, 1, 1);
59+
60+
const hemiLight = new HemisphericLight("hemiLight", new Vector3(0, 1, 0), scene);
61+
hemiLight.intensity = 0.2;
62+
hemiLight.diffuse = new Color3(1, 1, 1);
63+
hemiLight.groundColor = new Color3(1, 1, 1);
64+
65+
const sphere = CreateSphere("sphere", { diameter: 10, segments: 32 }, scene);
66+
sphere.position.y = 5;
67+
68+
camera.setTarget(sphere.position);
69+
70+
if (props.environmentTexture) {
71+
scene.environmentTexture = props.environmentTexture;
72+
}
73+
74+
// Load and apply material
75+
import("fs-extra").then(({ readJSON }) => {
76+
readJSON(props.absolutePath)
77+
.then((data) => {
78+
try {
79+
const rootUrl = projectConfiguration.path ? projectConfiguration.path.substring(0, projectConfiguration.path.lastIndexOf("/") + 1) : "";
80+
81+
const materialData = Material.Parse(data, scene, rootUrl);
82+
sphere.material = materialData;
83+
} catch (e) {
84+
console.error("Failed to parse material:", e);
85+
setLoadError(true);
86+
}
87+
})
88+
.catch((e) => {
89+
console.error("Failed to read material file:", e);
90+
setLoadError(true);
91+
});
92+
});
93+
94+
// Render loop
95+
engine.runRenderLoop(() => {
96+
scene.render();
97+
});
98+
99+
// Cleanup
100+
return () => {
101+
engine.stopRenderLoop();
102+
scene.dispose();
103+
engine.dispose();
104+
};
105+
}, [props.absolutePath]);
106+
107+
if (loadError) {
108+
return <GiMaterialsScience size="64px" />;
109+
}
110+
111+
return <canvas ref={canvasRef} width={width} height={height} className="w-full h-full object-contain rounded-md" />;
112+
}

0 commit comments

Comments
 (0)