Skip to content

Commit 76ea788

Browse files
committed
Setup a basic viewer.
1 parent 1cd3caa commit 76ea788

File tree

6 files changed

+271
-14
lines changed

6 files changed

+271
-14
lines changed

source/titfront/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"react-feather": "^2.0.10",
2323
"react-icons": "^5.4.0",
2424
"tailwind-merge": "^3.0.1",
25+
"three": "^0.173.0",
2526
"zod": "^3.24.1"
2627
},
2728
"devDependencies": {
@@ -34,6 +35,7 @@
3435
"@types/node": "^22.10.10",
3536
"@types/react": "^19.0.8",
3637
"@types/react-dom": "^19.0.3",
38+
"@types/three": "^0.173.0",
3739
"@vitejs/plugin-react": "^4.3.4",
3840
"@vitest/coverage-v8": "3.0.4",
3941
"async-mutex": "^0.5.0",

source/titfront/src/components/App.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ import {
1616
import { BottomMenu, LeftMenu, MenuItem } from "./MainMenu";
1717
import { PythonShell } from "./PythonShell";
1818
import { TreeView, MockData } from "./TreeView";
19-
import { Viewer } from "./Viewer";
19+
import { ViewerComponent } from "./Viewer";
2020

2121
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2222

2323
export const App: FC = () => {
2424
const leftIconSize = 24;
2525
const bottomIconSize = 16;
2626
return (
27-
<div className="h-screen w-screen flex flex-row select-none text-sm">
27+
<div className="h-screen w-screen flex flex-row select-none text-sm transition-none">
2828
<LeftMenu>
2929
<MenuItem
3030
name="Configuration"
@@ -49,8 +49,8 @@ export const App: FC = () => {
4949
group={1}
5050
/>
5151
</LeftMenu>
52-
<div className="flex-1 flex flex-col">
53-
<Viewer />
52+
<div className="flex-grow flex flex-col">
53+
<ViewerComponent />
5454
<BottomMenu>
5555
<MenuItem
5656
name="Python shell"

source/titfront/src/components/Python.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,7 @@ export type PyRunCode = (expression: string, onResponse: PyCallback) => void;
6464
* @returns A function to run Python code.
6565
*/
6666
export function usePython(): PyRunCode {
67-
const runCode = useContext(PyConnectionContext);
68-
assert(runCode !== null, "runCode is null!");
69-
return runCode;
67+
return useContext(PyConnectionContext)!;
7068
}
7169

7270
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -116,8 +114,7 @@ export const PyConnectionProvider: FC<{ children: ReactNode }> = ({
116114
);
117115

118116
// Invoke the callback.
119-
const callback = pendingRequests.current.get(requestID);
120-
assert(callback !== undefined, `No callback for request ${requestID}`);
117+
const callback = pendingRequests.current.get(requestID)!;
121118
if (status === "success") {
122119
callback(result);
123120
} else {
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ *\
2+
* Part of the Tit Solver project, under the MIT License.
3+
* See /LICENSE.md for license information. SPDX-License-Identifier: MIT
4+
\* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */
5+
6+
import { FC, useEffect, useState } from "react";
7+
import { Euler, Quaternion, Vector3 } from "three";
8+
9+
import { useViewer } from "./Viewer";
10+
import { cn } from "../utils";
11+
12+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
13+
14+
type Axis = "x" | "y" | "z";
15+
type AxisSpec = {
16+
unit: Vector3;
17+
colorCn: string;
18+
darkerColorCn: string;
19+
borderColorCn: string;
20+
};
21+
const axes: Record<Axis, AxisSpec> = {
22+
x: {
23+
unit: new Vector3(+1, 0, 0),
24+
colorCn: "bg-red-500/50 hover:bg-red-500/75",
25+
darkerColorCn: "bg-red-500/25 hover:bg-red-500/50",
26+
borderColorCn: "border-red-500/50",
27+
},
28+
y: {
29+
unit: new Vector3(0, +1, 0),
30+
colorCn: "bg-green-500/50 hover:bg-green-500/75",
31+
darkerColorCn: "bg-green-500/25 hover:bg-green-500/50",
32+
borderColorCn: "border-green-500/50",
33+
},
34+
z: {
35+
unit: new Vector3(0, 0, -1),
36+
colorCn: "bg-blue-500/50 hover:bg-blue-500/75",
37+
darkerColorCn: "bg-blue-500/25 hover:bg-blue-500/50",
38+
borderColorCn: "border-blue-500/50",
39+
},
40+
};
41+
42+
export const Orientation: FC = () => {
43+
const viewer = useViewer();
44+
const [rotation, setRotation] = useState<Euler | null>(null);
45+
46+
useEffect(() => {
47+
const controls = viewer?.controls;
48+
if (!controls) return;
49+
const setRotationFromControls = () =>
50+
setRotation(
51+
new Euler(
52+
viewer.controls.getPolarAngle() - Math.PI / 2,
53+
viewer.controls.getAzimuthalAngle(),
54+
0
55+
)
56+
);
57+
setRotationFromControls();
58+
controls.addEventListener("change", setRotationFromControls);
59+
return () => {
60+
controls.removeEventListener("change", setRotationFromControls);
61+
};
62+
}, [viewer]);
63+
64+
if (!viewer || !rotation) return null;
65+
66+
const q = new Quaternion().setFromEuler(rotation);
67+
const OrientAxis = (axis: "x" | "y" | "z") => {
68+
const { unit, colorCn, darkerColorCn, borderColorCn } = axes[axis];
69+
const e = new Vector3().copy(unit).applyQuaternion(q);
70+
e.y *= -1;
71+
72+
const baseZ = 20;
73+
const diamPx = 30;
74+
const distPx = 45;
75+
const angleRad = Math.atan2(e.y, e.x);
76+
const angledDistPx = distPx * Math.hypot(e.x, e.y) - diamPx / 2;
77+
78+
const Circle = (sign: number) => (
79+
<button
80+
className={cn(
81+
"absolute flex items-center justify-center",
82+
"rounded-full",
83+
sign > 0
84+
? cn("text-gray-700 font-black", colorCn)
85+
: cn("border-4", darkerColorCn, borderColorCn)
86+
)}
87+
style={{
88+
width: diamPx,
89+
height: diamPx,
90+
zIndex: Math.round(baseZ * (1 - sign * e.z)),
91+
transform: `
92+
translate(-50%, -50%)
93+
translate(${sign * e.x * distPx}px, ${sign * e.y * distPx}px)
94+
`,
95+
}}
96+
>
97+
{sign > 0 ? axis.toUpperCase() : ""}
98+
</button>
99+
);
100+
101+
const Line = (sign: number) =>
102+
Math.abs(angledDistPx) > 2 && (
103+
<div
104+
className={cn("absolute", colorCn)}
105+
style={{
106+
width: angledDistPx,
107+
height: 4,
108+
zIndex: Math.round(baseZ * (1 - sign * 0.5 * e.z)),
109+
transform: `
110+
translate(-50%, -50%)
111+
rotate(${angleRad}rad)
112+
translate(${sign * 50}%, 0)
113+
`,
114+
}}
115+
/>
116+
);
117+
118+
return (
119+
<>
120+
{Circle(+1)}
121+
{Line(+1)}
122+
{Line(-1)}
123+
{Circle(-1)}
124+
</>
125+
);
126+
};
127+
128+
return (
129+
<div className="absolute top-20 right-20">
130+
{OrientAxis("x")}
131+
{OrientAxis("y")}
132+
{OrientAxis("z")}
133+
</div>
134+
);
135+
};
136+
137+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

source/titfront/src/components/Viewer.tsx

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,132 @@
33
* See /LICENSE.md for license information. SPDX-License-Identifier: MIT
44
\* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */
55

6-
import { FC } from "react";
6+
import * as THREE from "three";
7+
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
8+
import {
9+
FC,
10+
createContext,
11+
useContext,
12+
useEffect,
13+
useRef,
14+
useState,
15+
} from "react";
16+
17+
import { Orientation } from "./ViewOrientation";
18+
19+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
20+
21+
export class Viewer {
22+
readonly canvas: HTMLCanvasElement;
23+
readonly container: HTMLDivElement;
24+
readonly renderer: THREE.WebGLRenderer;
25+
readonly scene: THREE.Scene;
26+
readonly camera: THREE.PerspectiveCamera;
27+
readonly controls: OrbitControls;
28+
29+
constructor(canvas: HTMLCanvasElement) {
30+
this.canvas = canvas;
31+
this.container = canvas.parentElement! as HTMLDivElement;
32+
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
33+
this.renderer.setClearAlpha(0);
34+
this.renderer.setAnimationLoop(() => this.animate());
35+
36+
this.scene = new THREE.Scene();
37+
38+
// Set up the camera.
39+
this.camera = new THREE.PerspectiveCamera(
40+
75,
41+
this.container.clientWidth / this.container.clientHeight,
42+
0.1,
43+
1000
44+
);
45+
this.camera.position.set(0, 0, 5);
46+
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
47+
this.controls.enablePan = true;
48+
this.setControlsRotation(Math.PI / 3, Math.PI / 4);
49+
// TODO: This does not work properly.
50+
const resizeObserver = new ResizeObserver(() => {
51+
const width = 0.9 * this.container.clientWidth;
52+
const height = 0.9 * this.container.clientHeight;
53+
this.camera.aspect = width / height;
54+
this.camera.updateProjectionMatrix();
55+
this.renderer.setSize(width, height, false);
56+
});
57+
resizeObserver.observe(this.container, { box: "content-box" });
58+
59+
// Add a global lights.
60+
const ambientLight = new THREE.AmbientLight(0xeeeeee);
61+
this.scene.add(ambientLight);
62+
const light = new THREE.PointLight(0xffffff);
63+
light.distance = 1;
64+
light.position.set(10, 10, -10).normalize();
65+
this.scene.add(light);
66+
67+
// Add a test cube.
68+
const length = 4;
69+
const width = 2;
70+
const depth = 2;
71+
const geometry = new THREE.BoxGeometry(length, width, depth);
72+
const material = new THREE.MeshPhongMaterial({ color: 0x888888 });
73+
const cube = new THREE.Mesh(geometry, material);
74+
this.scene.add(cube);
75+
}
76+
77+
setControlsRotation(polar: number, azimuthal: number) {
78+
const radius = this.camera.position.length(); // Keep the distance the same
79+
this.camera.position.setFromSphericalCoords(radius, polar, azimuthal);
80+
this.controls.update();
81+
}
82+
83+
private animate() {
84+
this.controls.update();
85+
this.renderer.render(this.scene, this.camera);
86+
}
87+
}
788

889
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
990

10-
export const Viewer: FC = () => {
91+
interface ViewerProviderType {
92+
viewer: Viewer | null;
93+
setViewer: (viewer: Viewer | null) => void;
94+
}
95+
96+
const ViewerContext = createContext<ViewerProviderType | null>(null);
97+
98+
export const ViewerProvider: FC<{ children: React.ReactNode }> = ({
99+
children,
100+
}) => {
101+
const [viewer, setViewer] = useState<Viewer | null>(null);
102+
return (
103+
<ViewerContext.Provider value={{ viewer, setViewer }}>
104+
{children}
105+
</ViewerContext.Provider>
106+
);
107+
};
108+
109+
export function useViewer(): Viewer {
110+
return useContext(ViewerContext)!.viewer!;
111+
}
112+
113+
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
114+
115+
export const ViewerComponent: FC = () => {
116+
const { viewer, setViewer } = useContext(ViewerContext)!;
117+
const canvasRef = useRef<HTMLCanvasElement>(null);
118+
119+
useEffect(() => {
120+
if (canvasRef.current && !viewer) {
121+
setViewer(new Viewer(canvasRef.current));
122+
}
123+
return () => {
124+
if (viewer) setViewer(null);
125+
};
126+
}, [canvasRef, viewer, setViewer]);
127+
11128
return (
12-
<div className="size-full flex items-center justify-center bg-black">
13-
<span className="text-white">Viewer (placeholder)</span>
129+
<div className="size-full flex items-center justify-center bg-gradient-to-bl from-gray-700 to-gray-800">
130+
<Orientation />
131+
<canvas ref={canvasRef} />
14132
</div>
15133
);
16134
};

source/titfront/src/main.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@ import ReactDOM from "react-dom/client";
88

99
import { App } from "./components/App";
1010
import { PyConnectionProvider } from "./components/Python";
11+
import { ViewerProvider } from "./components/Viewer";
1112
import "./index.css";
1213

1314
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1415

1516
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
1617
<React.StrictMode>
1718
<PyConnectionProvider>
18-
<App />
19+
<ViewerProvider>
20+
<App />
21+
</ViewerProvider>
1922
</PyConnectionProvider>
2023
</React.StrictMode>
2124
);

0 commit comments

Comments
 (0)