Skip to content

Commit a40b03b

Browse files
feat(properties-panel): add shading section for mesh properties; include options for shadow casting and shading mode
1 parent f8fed33 commit a40b03b

File tree

7 files changed

+197
-59
lines changed

7 files changed

+197
-59
lines changed

src/features/properties-panel/components/properties-panel.tsx

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import React from 'react';
44
import { useActivePropertiesTab, usePropertiesPanelStore, PropertiesTab } from '@/stores/properties-panel-store';
55
import { InspectorPanel } from './tabs/inspector-panel';
6-
import { ScrollAreaHorizontal } from './scroll-area-horizontal';
76
import type { LucideIcon } from 'lucide-react';
87
import { Wrench, Box, Layers, Globe, Sliders, Camera, HardDrive } from 'lucide-react';
98

@@ -24,25 +23,24 @@ export const PropertiesPanel: React.FC = () => {
2423
const { setActiveTab } = usePropertiesPanelStore();
2524

2625
return (
27-
<div className="bg-black/40 backdrop-blur-md border border-white/10 rounded-lg shadow-lg shadow-black/30 w-64 h-full flex flex-col">
26+
<div className="bg-black/40 h-[60dvh] backdrop-blur-md border border-white/10 rounded-lg shadow-lg shadow-black/30 w-64 flex flex-col">
2827
<div className="border-b border-white/10">
29-
<ScrollAreaHorizontal>
30-
<div className="flex gap-1 px-2 py-1">
31-
{tabs.map(({ key, label, icon: Icon }) => (
32-
<button
33-
key={key}
34-
className={`px-2 py-1 rounded flex items-center justify-center ${active === key ? 'bg-white/10 text-white' : 'text-gray-300 hover:bg-white/5'}`}
35-
onClick={() => setActiveTab(key)}
36-
title={label}
37-
aria-label={label}
38-
>
39-
<Icon className="h-4 w-4" strokeWidth={1.75} />
40-
</button>
41-
))}
42-
</div>
43-
</ScrollAreaHorizontal>
28+
<div className="flex gap-1 px-2 py-1">
29+
{tabs.map(({ key, label, icon: Icon }) => (
30+
<button
31+
key={key}
32+
className={`px-2 py-1 rounded flex items-center justify-center ${active === key ? 'bg-white/10 text-white' : 'text-gray-300 hover:bg-white/5'}`}
33+
onClick={() => setActiveTab(key)}
34+
title={label}
35+
aria-label={label}
36+
>
37+
<Icon className="h-4 w-4" strokeWidth={1.75} />
38+
</button>
39+
))}
40+
</div>
41+
4442
</div>
45-
<div className="flex-1 min-h-0 h-[60dvh] overflow-auto">
43+
<div className="flex-1 min-h-0 h-[60dvh] overflow-auto">
4644
{active === 'inspector' && <InspectorPanel />}
4745
{active !== 'inspector' && (
4846
<div className="p-3 text-xs text-gray-500">

src/features/properties-panel/components/tabs/sections/object-data-section.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React from 'react';
44
import { useSceneStore } from '@/stores/scene-store';
55
import { useGeometryStore } from '@/stores/geometry-store';
66
import { MaterialSection } from './material-section';
7+
import { ShadingSection } from './shading-section';
78

89
type Props = { objectId: string };
910

@@ -25,7 +26,8 @@ export const ObjectDataSection: React.FC<Props> = ({ objectId }) => {
2526

2627
return (
2728
<div className="space-y-2">
28-
<MaterialSection materialId={mesh?.materialId} onAssignMaterial={assignMaterial} />
29+
<MaterialSection materialId={mesh?.materialId} onAssignMaterial={assignMaterial} />
30+
{mesh && <ShadingSection meshId={mesh.id} />}
2931
</div>
3032
);
3133
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"use client";
2+
3+
import React from 'react';
4+
import { useGeometryStore } from '@/stores/geometry-store';
5+
import Switch from '@/components/switch';
6+
import { useGeometryStore as useGeo } from '@/stores/geometry-store';
7+
8+
type Props = { meshId: string };
9+
10+
export const ShadingSection: React.FC<Props> = ({ meshId }) => {
11+
const geo = useGeometryStore();
12+
const geoStore = useGeo();
13+
const mesh = geo.meshes.get(meshId);
14+
if (!mesh) return null;
15+
16+
const update = (fn: (m: NonNullable<typeof mesh>) => void) => geo.updateMesh(meshId, (m) => fn(m as NonNullable<typeof mesh>));
17+
18+
return (
19+
<div className="bg-white/5 border border-white/10 rounded p-2 space-y-2">
20+
<div className="text-xs uppercase tracking-wide text-gray-400">Shading</div>
21+
<div className="flex items-center justify-between text-xs">
22+
<span className="text-gray-400">Cast Shadows</span>
23+
<Switch checked={!!mesh.castShadow} onCheckedChange={(v) => update((m) => { m.castShadow = v; })} />
24+
</div>
25+
<div className="flex items-center justify-between text-xs">
26+
<span className="text-gray-400">Receive Shadows</span>
27+
<Switch checked={!!mesh.receiveShadow} onCheckedChange={(v) => update((m) => { m.receiveShadow = v; })} />
28+
</div>
29+
<div className="text-xs text-gray-400">
30+
<div className="mb-1">Mode</div>
31+
<div className="flex gap-2">
32+
<label className="flex items-center gap-1">
33+
<input
34+
type="radio"
35+
name={`shading-${meshId}`}
36+
className="accent-white/70"
37+
checked={(mesh.shading ?? 'flat') === 'flat'}
38+
onChange={() => update((m) => { m.shading = 'flat'; })}
39+
/>
40+
Flat
41+
</label>
42+
<label className="flex items-center gap-1">
43+
<input
44+
type="radio"
45+
name={`shading-${meshId}`}
46+
className="accent-white/70"
47+
checked={(mesh.shading ?? 'flat') === 'smooth'}
48+
onChange={() => update((m) => { m.shading = 'smooth'; geoStore.recalculateNormals(meshId); })}
49+
/>
50+
Smooth
51+
</label>
52+
</div>
53+
</div>
54+
</div>
55+
);
56+
};
57+
58+
export default ShadingSection;

src/features/viewport/components/mesh-view.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ const MeshView: React.FC<Props> = ({ objectId, noTransform = false }) => {
2929

3030
const geo = new BufferGeometry();
3131
const vertexMap = new Map(mesh.vertices.map((v) => [v.id, v] as const));
32-
const positions: number[] = [];
33-
const normals: number[] = [];
32+
const positions: number[] = [];
33+
const normals: number[] = [];
3434

3535
mesh.faces.forEach((face) => {
3636
const tris = convertQuadToTriangles(face.vertexIds);
@@ -56,7 +56,13 @@ const MeshView: React.FC<Props> = ({ objectId, noTransform = false }) => {
5656
p2.y,
5757
p2.z
5858
);
59-
for (let i = 0; i < 3; i++) normals.push(faceNormal.x, faceNormal.y, faceNormal.z);
59+
const useSmooth = (mesh.shading ?? 'flat') === 'smooth';
60+
if (useSmooth) {
61+
const n0 = v0.normal; const n1 = v1.normal; const n2 = v2.normal;
62+
normals.push(n0.x, n0.y, n0.z, n1.x, n1.y, n1.z, n2.x, n2.y, n2.z);
63+
} else {
64+
for (let i = 0; i < 3; i++) normals.push(faceNormal.x, faceNormal.y, faceNormal.z);
65+
}
6066
});
6167
});
6268

@@ -89,7 +95,7 @@ const MeshView: React.FC<Props> = ({ objectId, noTransform = false }) => {
8995
emissive,
9096
wireframe: shading === 'wireframe',
9197
side: DoubleSide,
92-
flatShading: true,
98+
flatShading: (mesh.shading ?? 'flat') === 'flat',
9399
});
94100

95101
return { geom: geo, mat: material };
@@ -138,8 +144,8 @@ const MeshView: React.FC<Props> = ({ objectId, noTransform = false }) => {
138144
<mesh
139145
geometry={geomAndMat.geom}
140146
material={geomAndMat.mat as unknown as Material}
141-
castShadow={shading === 'material'}
142-
receiveShadow={shading === 'material'}
147+
castShadow={!!mesh.castShadow && shading === 'material'}
148+
receiveShadow={!!mesh.receiveShadow && shading === 'material'}
143149
// Disable raycast when locked so clicks pass through
144150
// In edit mode, disable raycast only for the specific object being edited
145151
raycast={raycastFn}

src/features/viewport/components/object-node.tsx

Lines changed: 101 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ const DirectionalLightNode: React.FC<{ color: Color; intensity: number }> = ({ c
2828
return <directionalLight ref={ref} color={color} intensity={intensity} />;
2929
};
3030

31+
const DirectionalLightBare: React.FC<{ color: Color; intensity: number }> = ({ color, intensity }) => {
32+
const ref = useRef<DirectionalLight>(null!);
33+
return <directionalLight ref={ref} color={color} intensity={intensity} />;
34+
};
35+
3136
const SpotLightNode: React.FC<{
3237
color: Color;
3338
intensity: number;
@@ -44,6 +49,20 @@ const SpotLightNode: React.FC<{
4449
);
4550
};
4651

52+
const SpotLightBare: React.FC<{
53+
color: Color;
54+
intensity: number;
55+
distance: number;
56+
angle: number;
57+
penumbra: number;
58+
decay: number;
59+
}> = ({ color, intensity, distance, angle, penumbra, decay }) => {
60+
const ref = useRef<SpotLight>(null!);
61+
return (
62+
<spotLight ref={ref} color={color} intensity={intensity} distance={distance} angle={angle} penumbra={penumbra} decay={decay} />
63+
);
64+
};
65+
4766
const PointLightNode: React.FC<{ color: Color; intensity: number; distance: number; decay: number }>
4867
= ({ color, intensity, distance, decay }) => {
4968
const ref = useRef<PointLight>(null!);
@@ -52,6 +71,12 @@ const PointLightNode: React.FC<{ color: Color; intensity: number; distance: numb
5271
return <pointLight ref={ref} color={color} intensity={intensity} distance={distance} decay={decay} />;
5372
};
5473

74+
const PointLightBare: React.FC<{ color: Color; intensity: number; distance: number; decay: number }>
75+
= ({ color, intensity, distance, decay }) => {
76+
const ref = useRef<PointLight>(null!);
77+
return <pointLight ref={ref} color={color} intensity={intensity} distance={distance} decay={decay} />;
78+
};
79+
5580
const RectAreaLightNode: React.FC<{ color: Color; intensity: number; width: number; height: number }>
5681
= ({ color, intensity, width, height }) => {
5782
const ref = useRef<RectAreaLight>(null!);
@@ -60,6 +85,12 @@ const RectAreaLightNode: React.FC<{ color: Color; intensity: number; width: numb
6085
return <rectAreaLight ref={ref} color={color} intensity={intensity} width={width} height={height} />;
6186
};
6287

88+
const RectAreaLightBare: React.FC<{ color: Color; intensity: number; width: number; height: number }>
89+
= ({ color, intensity, width, height }) => {
90+
const ref = useRef<RectAreaLight>(null!);
91+
return <rectAreaLight ref={ref} color={color} intensity={intensity} width={width} height={height} />;
92+
};
93+
6394
// Camera helper wrappers
6495
const PerspectiveCameraNode: React.FC<{ fov: number; near: number; far: number }>
6596
= ({ fov, near, far }) => {
@@ -69,6 +100,12 @@ const PerspectiveCameraNode: React.FC<{ fov: number; near: number; far: number }
69100
return <perspectiveCamera ref={ref} fov={fov} near={near} far={far} />;
70101
};
71102

103+
const PerspectiveCameraBare: React.FC<{ fov: number; near: number; far: number }>
104+
= ({ fov, near, far }) => {
105+
const ref = useRef<PerspectiveCamera>(null!);
106+
return <perspectiveCamera ref={ref} fov={fov} near={near} far={far} />;
107+
};
108+
72109
const OrthographicCameraNode: React.FC<{ left: number; right: number; top: number; bottom: number; near: number; far: number }>
73110
= ({ left, right, top, bottom, near, far }) => {
74111
const ref = useRef<OrthographicCamera>(null!);
@@ -77,6 +114,12 @@ const OrthographicCameraNode: React.FC<{ left: number; right: number; top: numbe
77114
return <orthographicCamera ref={ref} left={left} right={right} top={top} bottom={bottom} near={near} far={far} />;
78115
};
79116

117+
const OrthographicCameraBare: React.FC<{ left: number; right: number; top: number; bottom: number; near: number; far: number }>
118+
= ({ left, right, top, bottom, near, far }) => {
119+
const ref = useRef<OrthographicCamera>(null!);
120+
return <orthographicCamera ref={ref} left={left} right={right} top={top} bottom={bottom} near={near} far={far} />;
121+
};
122+
80123

81124
type Props = { objectId: string };
82125

@@ -105,63 +148,87 @@ const ObjectNode: React.FC<Props> = ({ objectId }) => {
105148
scale={[t.scale.x, t.scale.y, t.scale.z]}
106149
visible={obj.visible}
107150
>
108-
{obj.type === 'mesh' && <MeshView objectId={objectId} noTransform />}
151+
{obj.type === 'mesh' && <MeshView objectId={objectId} noTransform />}
109152
{obj.type === 'light' && obj.lightId && (() => {
110153
const light = scene.lights[obj.lightId!];
111154
if (!light) return null;
112155
const color = new Color(light.color.x, light.color.y, light.color.z);
113-
const active = shading === 'material';
156+
const isMaterial = (shading as unknown as string) === 'material';
114157
switch (light.type) {
115158
case 'directional':
116-
return <DirectionalLightNode color={color} intensity={active ? light.intensity : 0} />;
159+
return isMaterial
160+
? <DirectionalLightBare color={color} intensity={light.intensity} />
161+
: <DirectionalLightNode color={color} intensity={0} />;
117162
case 'spot':
118163
return (
119-
<SpotLightNode
120-
color={color}
121-
intensity={active ? light.intensity : 0}
122-
distance={light.distance ?? 0}
123-
angle={light.angle ?? Math.PI / 6}
124-
penumbra={light.penumbra ?? 0}
125-
decay={light.decay ?? 2}
126-
/>
164+
isMaterial ? (
165+
<SpotLightBare
166+
color={color}
167+
intensity={light.intensity}
168+
distance={light.distance ?? 0}
169+
angle={light.angle ?? Math.PI / 6}
170+
penumbra={light.penumbra ?? 0}
171+
decay={light.decay ?? 2}
172+
/>
173+
) : (
174+
<SpotLightNode
175+
color={color}
176+
intensity={0}
177+
distance={light.distance ?? 0}
178+
angle={light.angle ?? Math.PI / 6}
179+
penumbra={light.penumbra ?? 0}
180+
decay={light.decay ?? 2}
181+
/>
182+
)
127183
);
128184
case 'rectarea':
129185
return (
130-
<RectAreaLightNode
131-
color={color}
132-
intensity={active ? light.intensity : 0}
133-
width={light.width ?? 1}
134-
height={light.height ?? 1}
135-
/>
186+
isMaterial ? (
187+
<RectAreaLightBare color={color} intensity={light.intensity} width={light.width ?? 1} height={light.height ?? 1} />
188+
) : (
189+
<RectAreaLightNode color={color} intensity={0} width={light.width ?? 1} height={light.height ?? 1} />
190+
)
136191
);
137192
case 'point':
138193
default:
139194
return (
140-
<PointLightNode
141-
color={color}
142-
intensity={active ? light.intensity : 0}
143-
distance={light.distance ?? 0}
144-
decay={light.decay ?? 2}
145-
/>
195+
isMaterial ? (
196+
<PointLightBare color={color} intensity={light.intensity} distance={light.distance ?? 0} decay={light.decay ?? 2} />
197+
) : (
198+
<PointLightNode color={color} intensity={0} distance={light.distance ?? 0} decay={light.decay ?? 2} />
199+
)
146200
);
147201
}
148202
})()}
149203
{obj.type === 'camera' && obj.cameraId && (() => {
150204
const camRes = scene.cameras[obj.cameraId!];
151205
if (!camRes) return null;
206+
const isMaterial = (shading as unknown as string) === 'material';
152207
if (camRes.type === 'perspective') {
153-
return <PerspectiveCameraNode fov={camRes.fov ?? 50} near={camRes.near} far={camRes.far} />;
208+
return isMaterial
209+
? <PerspectiveCameraBare fov={camRes.fov ?? 50} near={camRes.near} far={camRes.far} />
210+
: <PerspectiveCameraNode fov={camRes.fov ?? 50} near={camRes.near} far={camRes.far} />;
154211
}
155-
return (
156-
<OrthographicCameraNode
157-
left={camRes.left ?? -1}
158-
right={camRes.right ?? 1}
159-
top={camRes.top ?? 1}
160-
bottom={camRes.bottom ?? -1}
161-
near={camRes.near}
162-
far={camRes.far}
163-
/>
164-
);
212+
return isMaterial
213+
? (
214+
<OrthographicCameraBare
215+
left={camRes.left ?? -1}
216+
right={camRes.right ?? 1}
217+
top={camRes.top ?? 1}
218+
bottom={camRes.bottom ?? -1}
219+
near={camRes.near}
220+
far={camRes.far}
221+
/>
222+
) : (
223+
<OrthographicCameraNode
224+
left={camRes.left ?? -1}
225+
right={camRes.right ?? 1}
226+
top={camRes.top ?? 1}
227+
bottom={camRes.bottom ?? -1}
228+
near={camRes.near}
229+
far={camRes.far}
230+
/>
231+
);
165232
})()}
166233
{obj.children.map((cid) => (
167234
<ObjectNode key={cid} objectId={cid} />

src/types/geometry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export interface Mesh {
4646
locked: boolean;
4747
// Reference to material resource in geometry-store.materials
4848
materialId?: string;
49+
// Rendering flags
50+
castShadow?: boolean;
51+
receiveShadow?: boolean;
52+
shading?: 'flat' | 'smooth';
4953
}
5054

5155
export interface Transform {

0 commit comments

Comments
 (0)