More fixes for STL viewer

This commit is contained in:
hzrd149 2023-12-24 11:53:18 -06:00
parent a7f56df8d0
commit cd05507da8
11 changed files with 449 additions and 73 deletions

View File

@ -0,0 +1,12 @@
import { Button, ButtonProps } from "@chakra-ui/react";
import { ChevronLeftIcon } from "./icons";
import { useNavigate } from "react-router-dom";
export default function BackButton({ ...props }: Omit<ButtonProps, "onClick" | "children">) {
const navigate = useNavigate();
return (
<Button leftIcon={<ChevronLeftIcon />} {...props} onClick={() => navigate(-1)}>
Back
</Button>
);
}

View File

@ -1,68 +1,69 @@
import { useMemo, useRef, useState } from "react";
import { forwardRef } from "react";
import { Box, BoxProps } from "@chakra-ui/react";
import { Color, Fog, PCFSoftShadowMap } from "three";
import { Canvas, ThreeElements, useFrame, useLoader } from "@react-three/fiber";
import { Color, Fog, Vector3 } from "three";
import { Canvas, useLoader } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader";
// http://cdn.thingiverse.com/assets/3c/fe/bc/3b/9e/3dae334c-589e-4d59-97e6-3578426751d1.stl
const STLViewer = forwardRef<HTMLCanvasElement, Omit<BoxProps, "children"> & { url: string }>(
({ url, ...props }, ref) => {
const geometry = useLoader(STLLoader, url);
function Cube(props: ThreeElements["mesh"]) {
const ref = useRef<THREE.Mesh>(null!);
const [hovered, hover] = useState(false);
const [clicked, click] = useState(false);
useFrame((state, delta) => (ref.current.rotation.x += delta));
if (!geometry.boundingBox) geometry.computeBoundingBox();
if (!geometry.boundingSphere) geometry.computeBoundingSphere();
return (
<mesh
{...props}
ref={ref}
scale={clicked ? 1.5 : 1}
onClick={(event) => click(!clicked)}
onPointerOver={(event) => hover(true)}
onPointerOut={(event) => hover(false)}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={hovered ? "hotpink" : "orange"} />
</mesh>
);
}
const objectScale = 2 / geometry.boundingSphere!.radius;
const bb = geometry.boundingBox!;
const center = bb.getCenter(new Vector3()).multiplyScalar(objectScale);
export default function STLViewer({ url, ...props }: Omit<BoxProps, "children"> & { url: string }) {
const geometry = useLoader(STLLoader, url);
return (
<Box {...props} position="relative">
<Canvas
shadows
gl={{
antialias: true,
shadowMapEnabled: true,
preserveDrawingBuffer: true,
}}
style={{ width: "100%", height: "100%" }}
scene={{ background: new Color(0xa0a0a0), fog: new Fog(0xa0a0a0, 4, 20) }}
camera={{ position: [-2, 2, -2.5] }}
ref={ref}
>
<OrbitControls enableDamping enablePan enableRotate enableZoom />
<hemisphereLight color={0xffffff} groundColor={0x444444} intensity={3} position={[0, 20, 0]} />
<ambientLight color={0xffffff} intensity={0.5} />
<directionalLight color={0xffffff} position={[-5, 15, 10]} castShadow>
<orthographicCamera attach="shadow-camera" args={[-2, 2, 2, -2]} />
</directionalLight>
<mesh
rotation={[-Math.PI / 2, 0, 0]}
receiveShadow
position={[0, ((bb.min.z - bb.max.z) / 2) * objectScale, 0]}
>
<planeGeometry args={[40, 40]} />
<meshPhongMaterial color={0xbbbbbb} depthWrite={false} />
</mesh>
<gridHelper
args={[40, 40, 0x000000, 0x000000]}
material-opacity={0.2}
material-transparent={true}
position={[0, ((bb.min.z - bb.max.z) / 2) * objectScale, 0]}
/>
<mesh
geometry={geometry}
scale={objectScale}
rotation={[Math.PI * -0.5, 0, 0]}
castShadow
receiveShadow
position={[-center.x, -center.z, center.y]}
>
<meshPhongMaterial color={0x1a5fb4} shininess={60} flatShading />
</mesh>
</Canvas>
</Box>
);
},
);
const radius = useMemo(() => {
geometry.computeBoundingSphere();
return geometry.boundingSphere?.radius ?? 10;
}, [geometry]);
return (
<Box {...props} position="relative">
<Canvas
shadows
gl={{
antialias: true,
shadowMapEnabled: true,
}}
style={{ width: "100%", height: "100%" }}
scene={{ background: new Color(0xa0a0a0), fog: new Fog(0xa0a0a0, 4, 20) }}
camera={{ position: [-2, 2, -2.5] }}
>
<OrbitControls enableDamping enablePan enableRotate enableZoom />
<hemisphereLight color={0xffffff} groundColor={0x444444} intensity={3} position={[0, 20, 0]} />
<ambientLight color={0xffffff} intensity={0.5} />
<directionalLight color={0xffffff} position={[-5, 15, 10]} castShadow>
<orthographicCamera attach="shadow-camera" args={[-2, 2, 2, -2]} />
</directionalLight>
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[40, 40]} />
<meshPhongMaterial color={0xbbbbbb} depthWrite={false} />
</mesh>
<gridHelper args={[40, 40, 0x000000, 0x000000]} material-opacity={0.2} material-transparent={true} />
<mesh geometry={geometry} scale={2 / radius} rotation={[Math.PI * 1.5, 0, 0]} castShadow receiveShadow>
<meshPhongMaterial color={0x1a5fb4} shininess={60} flatShading />
</mesh>
</Canvas>
</Box>
);
}
export default STLViewer;

View File

@ -4,3 +4,19 @@ export function unique<T>(arr: T[]): T[] {
export function random<T>(arr: T[]): T {
return arr[Math.round(Math.random() * (arr.length - 1))];
}
// copied from https://stackoverflow.com/a/55200387
const byteToHex: string[] = [];
for (let n = 0; n <= 0xff; ++n) {
const hexOctet = n.toString(16).padStart(2, "0");
byteToHex.push(hexOctet);
}
export function arrayBufferToHex(arrayBuffer: ArrayBufferLike) {
const buff = new Uint8Array(arrayBuffer);
const hexOctets = []; // new Array(buff.length) is even faster (preallocates necessary array size), then use hexOctets[i] instead of .push()
for (let i = 0; i < buff.length; ++i) hexOctets.push(byteToHex[buff[i]]);
return hexOctets.join("");
}

View File

@ -0,0 +1,15 @@
import { useEffect, useState } from "react";
export default function useObjectURL(object?: File | Blob | null) {
const [url, setUrl] = useState<string>();
useEffect(() => {
if (object) {
const u = URL.createObjectURL(object);
setUrl(u);
return () => URL.revokeObjectURL(u);
}
}, [object]);
return url;
}

View File

@ -2,7 +2,7 @@ import { useCallback, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
type Actions<T> = {
setValue: (v: T | ((v: T | undefined) => T)) => void;
setValue: (v: T | ((v: T | undefined) => T), replace?: boolean) => void;
clearValue: () => void;
};
@ -21,7 +21,7 @@ export default function useRouteStateValue<T extends unknown>(key: string, fallb
valueRef.current = stateRef.current[key] ?? fallback;
const setValue = useCallback(
(valueOrSetter: T | ((v: T) => T)) => {
(valueOrSetter: T | ((v: T) => T), replace = true) => {
const newState = { ...stateRef.current };
if (typeof valueOrSetter === "function") {
// @ts-ignore
@ -29,7 +29,7 @@ export default function useRouteStateValue<T extends unknown>(key: string, fallb
} else newState[key] = valueOrSetter;
if (stateRef.current[key] !== newState[key]) {
navigate(".", { state: newState, replace: true });
navigate(".", { state: newState, replace });
}
},
[key],

View File

@ -1,10 +0,0 @@
import STLViewer from "../../components/stl-viewer";
import VerticalPageLayout from "../../components/vertical-page-layout";
export default function ThingUploadView() {
return (
<VerticalPageLayout>
<STLViewer aspectRatio={16 / 9} url="https://tonybox.net/objects/keystone/keystone.stl" />
</VerticalPageLayout>
);
}

View File

@ -0,0 +1,43 @@
import { useState } from "react";
import { Button, ButtonGroup, Flex, Heading, Image, Text } from "@chakra-ui/react";
import useObjectURL from "../../../hooks/use-object-url";
import BackButton from "../../../components/back-button";
export default function ConfirmStep({
screenshot,
name,
hash,
summary,
onConfirm,
}: {
screenshot: Blob;
name: string;
summary: string;
hash: string;
onConfirm: () => Promise<void>;
}) {
const [loading, setLoading] = useState(false);
const objectURL = useObjectURL(screenshot);
const confirm = async () => {
setLoading(true);
await onConfirm();
setLoading(false);
};
return (
<Flex gap="2" direction="column" maxW="40rem" w="full" mx="auto">
<Image src={objectURL} maxW="2xl" />
<Heading size="md">{name}</Heading>
<Text>File Hash: {hash}</Text>
<Text whiteSpace="pre-line">{summary}</Text>
<ButtonGroup ml="auto">
<BackButton />
<Button onClick={confirm} isLoading={loading} colorScheme="primary">
Upload
</Button>
</ButtonGroup>
</Flex>
);
}

View File

@ -0,0 +1,46 @@
import { Button, ButtonGroup, Flex, FormControl, FormLabel, Input, Textarea } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import BackButton from "../../../components/back-button";
type FormValues = {
name: string;
summary: string;
};
export default function DetailsStep({ onSubmit }: { onSubmit: (values: FormValues) => void }) {
const { register, getValues, setValue, handleSubmit, watch } = useForm<FormValues>({
defaultValues: {
name: "",
summary: "",
},
mode: "all",
});
const submit = handleSubmit(onSubmit);
return (
<Flex as="form" onSubmit={submit} gap="2" direction="column" maxW="40rem" w="full" mx="auto">
<FormControl isRequired>
<FormLabel>Name</FormLabel>
<Input {...register("name", { required: true })} placeholder="Thing name" autoComplete="off" />
</FormControl>
<FormControl isRequired>
<FormLabel>Summary</FormLabel>
<Textarea
rows={3}
isRequired
placeholder="A short summary of the thing"
autoComplete="off"
{...register("summary", { required: true })}
/>
</FormControl>
<ButtonGroup ml="auto">
<BackButton />
<Button type="submit" colorScheme="primary">
Next
</Button>
</ButtonGroup>
</Flex>
);
}

View File

@ -0,0 +1,112 @@
import { useState } from "react";
import {
Box,
Flex,
Step,
StepDescription,
StepIcon,
StepIndicator,
StepNumber,
StepSeparator,
StepStatus,
StepTitle,
Stepper,
useToast,
} from "@chakra-ui/react";
import VerticalPageLayout from "../../../components/vertical-page-layout";
import { useSigningContext } from "../../../providers/signing-provider";
import { useNavigate } from "react-router-dom";
import useRouteStateValue from "../../../hooks/use-route-state-value";
import SelectFileStep from "./select-file-step";
import DetailsStep from "./details-step";
import PreviewStep from "./preview-step";
import ConfirmStep from "./confirm-step";
const steps = [{ title: "Select File" }, { title: "Details" }, { title: "Upload" }];
export default function ThingUploadView() {
const navigate = useNavigate();
const toast = useToast();
const { requestSignature } = useSigningContext();
const step = useRouteStateValue("step", 0);
const [file, setFile] = useState<Blob>();
const [fileURL, setFileURL] = useState<string>();
const [hash, setHash] = useState<string>();
const [name, setName] = useState<string>();
const [summary, setSummary] = useState<string>();
const [screenshot, setScreenshot] = useState<Blob>();
const upload = async () => {
try {
// const signed = await requestSignature(getDraft());
// const pub = new NostrPublishAction("Post", clientRelaysService.getWriteUrls(), signed);
} catch (e) {
if (e instanceof Error) {
toast({ description: e.message, status: "error" });
}
}
};
const renderContent = () => {
switch (step.value) {
case 0:
return (
<SelectFileStep
onSubmit={(values) => {
setFile(values.file);
if (values.fileURL) setFileURL(values.fileURL);
setHash(values.hash);
step.setValue(1, false);
}}
/>
);
case 1:
return (
<DetailsStep
onSubmit={(values) => {
setName(values.name);
setSummary(values.summary);
step.setValue(2, false);
}}
/>
);
case 2:
return (
<PreviewStep
file={file!}
onSubmit={(values) => {
setScreenshot(values.screenshot);
step.setValue(3, false);
}}
/>
);
case 3:
return <ConfirmStep name={name!} hash={hash!} summary={summary!} screenshot={screenshot!} onConfirm={upload} />;
}
};
return (
<VerticalPageLayout>
<Stepper index={step.value}>
{steps.map((step, index) => (
<Step key={index}>
<StepIndicator>
<StepStatus complete={<StepIcon />} incomplete={<StepNumber />} active={<StepNumber />} />
</StepIndicator>
<Box flexShrink="0">
<StepTitle>{step.title}</StepTitle>
{/* {step.description && <StepDescription>{step.description}</StepDescription>} */}
</Box>
<StepSeparator />
</Step>
))}
</Stepper>
{renderContent()}
</VerticalPageLayout>
);
}

View File

@ -0,0 +1,54 @@
import { useRef } from "react";
import { Button, ButtonGroup, Flex, useToast } from "@chakra-ui/react";
import STLViewer from "../../../components/stl-viewer";
import useObjectURL from "../../../hooks/use-object-url";
import BackButton from "../../../components/back-button";
type FormValues = {
screenshot: Blob;
};
function canvasToBlob(canvas: HTMLCanvasElement, type?: string): Promise<Blob> {
return new Promise((res, rej) => {
canvas.toBlob((blob) => {
if (blob) res(blob);
else rej(new Error("Failed to get blob"));
}, type);
});
}
export default function PreviewStep({ file, onSubmit }: { file: Blob; onSubmit: (values: FormValues) => void }) {
const toast = useToast();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const takeScreenshot = async () => {
if (!canvasRef.current) return;
try {
const blob = await canvasToBlob(canvasRef.current, "image/jpeg");
onSubmit({ screenshot: blob });
} catch (e) {
if (e instanceof Error) {
toast({ description: e.message, status: "error" });
}
}
};
const previewURL = useObjectURL(file);
return (
<Flex gap="2" direction="column">
{previewURL && (
<>
<STLViewer aspectRatio={16 / 10} url={previewURL} ref={canvasRef} />
<ButtonGroup ml="auto">
<BackButton />
<Button onClick={takeScreenshot} colorScheme="primary">
Take Screenshot
</Button>
</ButtonGroup>
</>
)}
</Flex>
);
}

View File

@ -0,0 +1,87 @@
import { ChangeEventHandler, useCallback } from "react";
import { Button, ButtonGroup, Divider, Flex, FormControl, FormLabel, Heading, Input, useToast } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import useObjectURL from "../../../hooks/use-object-url";
import { arrayBufferToHex } from "../../../helpers/array";
import BackButton from "../../../components/back-button";
type FormValues = {
file: Blob;
fileURL?: string;
hash: string;
};
// example file https://tonybox.net/objects/keystone/keystone.stl
export default function SelectFileStep({ onSubmit }: { onSubmit: (values: FormValues) => void }) {
const toast = useToast();
const { register, getValues, setValue, handleSubmit, watch, resetField } = useForm<FormValues>({
defaultValues: {
fileURL: "",
},
mode: "all",
});
watch("file");
watch("fileURL");
const handleFileChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => {
const file = e.target.files?.[0];
if (file) setValue("file", file);
else resetField("file");
},
[setValue, resetField],
);
const submit = handleSubmit(async (values) => {
try {
let file: Blob | undefined = values.file;
if (!file && values.fileURL) file = await fetch(values.fileURL).then((res) => res.blob());
if (!file) throw new Error("Cant access file");
// get file hash
const buffer = await file.arrayBuffer();
const hash = await window.crypto.subtle.digest("SHA-256", buffer);
onSubmit({ hash: arrayBufferToHex(hash), file, fileURL: values.fileURL });
// const signed = await requestSignature(getDraft());
// const pub = new NostrPublishAction("Post", clientRelaysService.getWriteUrls(), signed);
} catch (e) {
if (e instanceof Error) {
toast({ description: e.message, status: "error" });
}
}
});
const fileObjectURL = useObjectURL(getValues().file);
const stlURL = fileObjectURL || getValues().fileURL;
let step = 0;
if (getValues().file) step = 1;
return (
<Flex as="form" onSubmit={submit} direction="column" gap="2" maxW="40rem" w="full" mx="auto">
<Heading size="md">Upload File</Heading>
<Input type="file" accept="model/stl" placeholder="Select STL file" onChange={handleFileChange} isDisabled />
<Flex gap="4" alignItems="center" my="4">
<Divider />
<Heading size="sm">OR</Heading>
<Divider />
</Flex>
<Heading size="md">Use Remote File</Heading>
<Input
type="url"
{...register("fileURL", { validate: (str) => str && str.endsWith(".stl"), required: true })}
placeholder="https://example.com/files/things/cube.stl"
onChange={handleFileChange}
autoComplete="off"
/>
<ButtonGroup ml="auto">
<BackButton />
<Button type="submit" colorScheme="primary">
Next
</Button>
</ButtonGroup>
</Flex>
);
}