This commit is contained in:
@@ -8,7 +8,8 @@
|
|||||||
"Bash(DATABASE_URL=\"file:./dev.db\" APP_SECRET=\"dev-only-change-me-dev-only-change-me-dev-only-change-me\" npm run build)",
|
"Bash(DATABASE_URL=\"file:./dev.db\" APP_SECRET=\"dev-only-change-me-dev-only-change-me-dev-only-change-me\" npm run build)",
|
||||||
"Bash(npx tsc *)",
|
"Bash(npx tsc *)",
|
||||||
"Bash(npx next *)",
|
"Bash(npx next *)",
|
||||||
"Bash(npm run *)"
|
"Bash(npm run *)",
|
||||||
|
"Bash(node scripts/copy-viewer-assets.mjs)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ data/
|
|||||||
# uploads (dev)
|
# uploads (dev)
|
||||||
uploads/
|
uploads/
|
||||||
|
|
||||||
|
# generated at build time by scripts/copy-viewer-assets.mjs
|
||||||
|
public/vendor/
|
||||||
|
|
||||||
# editor
|
# editor
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -17,6 +18,17 @@ import {
|
|||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
||||||
|
|
||||||
|
// three.js + occt wasm are heavy — keep them out of the page's initial bundle
|
||||||
|
// and out of SSR. The viewer only loads when the user clicks "Show 3D".
|
||||||
|
const StepViewer = dynamic(() => import("@/components/StepViewer"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="relative w-full h-[480px] rounded-lg border border-slate-200 bg-slate-50 flex items-center justify-center text-sm text-slate-500">
|
||||||
|
Loading viewer…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
interface FileView {
|
interface FileView {
|
||||||
id: string;
|
id: string;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
@@ -195,6 +207,10 @@ export default function PartDetailClient({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{part.stepFile ? (
|
||||||
|
<StepViewerSection fileId={part.stepFile.id} fileName={part.stepFile.originalName} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<OperationsSection
|
<OperationsSection
|
||||||
partId={part.id}
|
partId={part.id}
|
||||||
operations={operations}
|
operations={operations}
|
||||||
@@ -658,6 +674,58 @@ function OperationModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------- 3D viewer ------------------------------------------------------
|
||||||
|
|
||||||
|
function StepViewerSection({ fileId, fileName }: { fileId: string; fileName: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [edges, setEdges] = useState(true);
|
||||||
|
// The viewer fetches via `/api/v1/files/:id/download`, which streams the
|
||||||
|
// stored STEP bytes with correct auth (admin session cookie is already set).
|
||||||
|
const url = `/api/v1/files/${fileId}/download`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-lg font-semibold">3D viewer</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{open ? (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-slate-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={edges}
|
||||||
|
onChange={(e) => setEdges(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Show edges
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<Button variant={open ? "secondary" : "primary"} size="sm" onClick={() => setOpen((v) => !v)}>
|
||||||
|
{open ? "Hide" : "Show 3D"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{open ? (
|
||||||
|
<Card>
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="mb-2 text-xs text-slate-500 truncate" title={fileName}>
|
||||||
|
Viewing: <code className="font-mono text-slate-700">{fileName}</code>
|
||||||
|
</div>
|
||||||
|
<StepViewer key={`${fileId}-${edges ? "e" : "ne"}`} url={url} showEdges={edges} />
|
||||||
|
<p className="mt-2 text-[11px] text-slate-500">
|
||||||
|
Drag to rotate · scroll to zoom · right-drag to pan. Parsing and rendering happens
|
||||||
|
in your browser — nothing is uploaded.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Loads the STEP file into an interactive 3D view. Click <span className="font-medium">Show 3D</span>{" "}
|
||||||
|
to start — the viewer downloads ~2 MB of OpenCascade WASM on first use.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -------- Files ----------------------------------------------------------
|
// -------- Files ----------------------------------------------------------
|
||||||
|
|
||||||
function FileSlot({
|
function FileSlot({
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// In-browser STEP viewer. Uses `occt-import-js` (OpenCascade compiled to
|
||||||
|
// WASM) to triangulate STEP/STP into mesh buffers, then renders them with
|
||||||
|
// three.js and an OrbitControls camera.
|
||||||
|
//
|
||||||
|
// The WASM blob is served from /vendor/occt-import-js.wasm — copied there
|
||||||
|
// by scripts/copy-viewer-assets.mjs during predev/prebuild.
|
||||||
|
//
|
||||||
|
// This component is strictly client-side: both `three` and `occt-import-js`
|
||||||
|
// reach for `window`/`WebGL`, so we never import them at module top-level in
|
||||||
|
// server components. All imports inside here are fine because the file is
|
||||||
|
// marked "use client" and only ever mounted below a client boundary.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Fully qualified URL that returns the STEP bytes (e.g. /api/v1/files/:id/download). */
|
||||||
|
url: string;
|
||||||
|
/** Optional class name for the outer wrapper. */
|
||||||
|
className?: string;
|
||||||
|
/** Show mesh edges for readability. Defaults to true. Disable on very complex assemblies. */
|
||||||
|
showEdges?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewerState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "loading"; message: string }
|
||||||
|
| { kind: "ready"; triangleCount: number; meshCount: number }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
export default function StepViewer({ url, className, showEdges = true }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [state, setState] = useState<ViewerState>({ kind: "idle" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hostNullable = containerRef.current;
|
||||||
|
if (!hostNullable) return;
|
||||||
|
// Capture in a non-nullable local so the async closure below retains the
|
||||||
|
// narrowing (TS flow analysis doesn't carry narrowing across async fns).
|
||||||
|
const host: HTMLDivElement = hostNullable;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let cleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
setState({ kind: "loading", message: "Initialising viewer…" });
|
||||||
|
try {
|
||||||
|
// Dynamically import the heavy bits — keeps initial JS small and
|
||||||
|
// means nothing in here ever runs on the server.
|
||||||
|
const [{ default: occtFactory }, THREE, { OrbitControls }] =
|
||||||
|
await Promise.all([
|
||||||
|
import("occt-import-js"),
|
||||||
|
import("three"),
|
||||||
|
import("three/examples/jsm/controls/OrbitControls.js"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
setState({ kind: "loading", message: "Fetching model…" });
|
||||||
|
|
||||||
|
const response = await fetch(url, { cache: "force-cache" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Download failed (${response.status})`);
|
||||||
|
}
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setState({ kind: "loading", message: "Parsing STEP file…" });
|
||||||
|
|
||||||
|
const occt = await occtFactory({
|
||||||
|
locateFile: (file) => `/vendor/${file}`,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const fileBuffer = new Uint8Array(arrayBuffer);
|
||||||
|
const result = occt.ReadStepFile(fileBuffer, null);
|
||||||
|
if (!result.success || result.meshes.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
result.success
|
||||||
|
? "No geometry found in this STEP file."
|
||||||
|
: "OpenCascade could not parse this STEP file.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
setState({ kind: "loading", message: "Building scene…" });
|
||||||
|
|
||||||
|
// --- three.js scene -----------------------------------------------
|
||||||
|
const width = host.clientWidth || 600;
|
||||||
|
const height = host.clientHeight || 400;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio || 1);
|
||||||
|
renderer.setSize(width, height, false);
|
||||||
|
renderer.setClearColor(0xf8fafc); // slate-50
|
||||||
|
host.appendChild(renderer.domElement);
|
||||||
|
renderer.domElement.style.display = "block";
|
||||||
|
renderer.domElement.style.width = "100%";
|
||||||
|
renderer.domElement.style.height = "100%";
|
||||||
|
renderer.domElement.style.touchAction = "none";
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
|
||||||
|
const ambient = new THREE.AmbientLight(0xffffff, 0.55);
|
||||||
|
scene.add(ambient);
|
||||||
|
const key = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||||
|
key.position.set(1, 1.5, 1);
|
||||||
|
scene.add(key);
|
||||||
|
const fill = new THREE.DirectionalLight(0xffffff, 0.35);
|
||||||
|
fill.position.set(-1, -0.5, -1);
|
||||||
|
scene.add(fill);
|
||||||
|
|
||||||
|
const group = new THREE.Group();
|
||||||
|
let totalTris = 0;
|
||||||
|
const disposables: Array<{ dispose: () => void }> = [];
|
||||||
|
|
||||||
|
for (const m of result.meshes) {
|
||||||
|
const built = buildMesh(THREE, m, showEdges);
|
||||||
|
if (!built) continue;
|
||||||
|
group.add(built.mesh);
|
||||||
|
if (built.edges) group.add(built.edges);
|
||||||
|
totalTris += built.triangleCount;
|
||||||
|
disposables.push(...built.disposables);
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.add(group);
|
||||||
|
|
||||||
|
// Center and frame.
|
||||||
|
const bbox = new THREE.Box3().setFromObject(group);
|
||||||
|
const size = new THREE.Vector3();
|
||||||
|
const center = new THREE.Vector3();
|
||||||
|
bbox.getSize(size);
|
||||||
|
bbox.getCenter(center);
|
||||||
|
group.position.sub(center); // recentre at origin
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, width / height, 0.01, 1e7);
|
||||||
|
const radius = Math.max(size.x, size.y, size.z, 1);
|
||||||
|
const dist = radius * 2.4;
|
||||||
|
camera.position.set(dist, dist * 0.8, dist);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
camera.near = radius / 1000;
|
||||||
|
camera.far = radius * 1000;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
controls.target.set(0, 0, 0);
|
||||||
|
controls.update();
|
||||||
|
|
||||||
|
// Render loop.
|
||||||
|
let rafId = 0;
|
||||||
|
const render = () => {
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
rafId = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
|
||||||
|
// Resize.
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const w = host.clientWidth;
|
||||||
|
const h = host.clientHeight;
|
||||||
|
if (w === 0 || h === 0) return;
|
||||||
|
renderer.setSize(w, h, false);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
});
|
||||||
|
ro.observe(host);
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
ro.disconnect();
|
||||||
|
controls.dispose();
|
||||||
|
for (const d of disposables) {
|
||||||
|
try {
|
||||||
|
d.dispose();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderer.dispose();
|
||||||
|
if (renderer.domElement.parentNode === host) {
|
||||||
|
host.removeChild(renderer.domElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
cleanup();
|
||||||
|
cleanup = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({
|
||||||
|
kind: "ready",
|
||||||
|
triangleCount: totalTris,
|
||||||
|
meshCount: result.meshes.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown viewer error";
|
||||||
|
setState({ kind: "error", message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (cleanup) cleanup();
|
||||||
|
};
|
||||||
|
// Reload whenever the target file changes.
|
||||||
|
}, [url, showEdges]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className ?? "relative w-full h-[480px] rounded-lg border border-slate-200 bg-slate-50 overflow-hidden"}>
|
||||||
|
<div ref={containerRef} className="absolute inset-0" />
|
||||||
|
{state.kind !== "ready" ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="rounded-md bg-white/80 backdrop-blur px-3 py-2 text-sm text-slate-600 border border-slate-200">
|
||||||
|
{state.kind === "error" ? (
|
||||||
|
<span className="text-red-600">Viewer error: {state.message}</span>
|
||||||
|
) : state.kind === "loading" ? (
|
||||||
|
<span>{state.message}</span>
|
||||||
|
) : (
|
||||||
|
<span>Preparing viewer…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="absolute bottom-2 right-2 rounded-md bg-white/80 backdrop-blur px-2 py-1 text-xs text-slate-500 border border-slate-200 pointer-events-none">
|
||||||
|
{state.meshCount} mesh{state.meshCount === 1 ? "" : "es"} · {state.triangleCount.toLocaleString()} tris
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type * as THREENS from "three";
|
||||||
|
import type { OcctMesh } from "occt-import-js";
|
||||||
|
|
||||||
|
interface BuiltMesh {
|
||||||
|
mesh: THREENS.Mesh;
|
||||||
|
edges: THREENS.Group | null;
|
||||||
|
triangleCount: number;
|
||||||
|
disposables: Array<{ dispose: () => void }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapted from occt-import-js/examples/three_viewer.html. Handles the
|
||||||
|
* per-BRep-face colouring scheme OCCT emits: a single indexed buffer
|
||||||
|
* geometry with one material per face group.
|
||||||
|
*/
|
||||||
|
function buildMesh(
|
||||||
|
THREE: typeof THREENS,
|
||||||
|
source: OcctMesh,
|
||||||
|
showEdges: boolean,
|
||||||
|
): BuiltMesh | null {
|
||||||
|
const positionArr = source.attributes.position?.array;
|
||||||
|
const indexArr = source.index?.array;
|
||||||
|
if (!positionArr || !indexArr || positionArr.length === 0 || indexArr.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute(
|
||||||
|
"position",
|
||||||
|
new THREE.Float32BufferAttribute(
|
||||||
|
positionArr instanceof Float32Array ? positionArr : Float32Array.from(positionArr as ArrayLike<number>),
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (source.attributes.normal?.array) {
|
||||||
|
const normalArr = source.attributes.normal.array;
|
||||||
|
geometry.setAttribute(
|
||||||
|
"normal",
|
||||||
|
new THREE.Float32BufferAttribute(
|
||||||
|
normalArr instanceof Float32Array ? normalArr : Float32Array.from(normalArr as ArrayLike<number>),
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fall back to computed normals if the importer didn't supply any.
|
||||||
|
}
|
||||||
|
geometry.name = source.name ?? "occt-mesh";
|
||||||
|
|
||||||
|
const indexTyped =
|
||||||
|
indexArr instanceof Uint32Array ? indexArr : Uint32Array.from(indexArr as ArrayLike<number>);
|
||||||
|
geometry.setIndex(new THREE.BufferAttribute(indexTyped, 1));
|
||||||
|
if (!source.attributes.normal?.array) {
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
}
|
||||||
|
|
||||||
|
const disposables: Array<{ dispose: () => void }> = [geometry];
|
||||||
|
|
||||||
|
const defaultColor = source.color
|
||||||
|
? new THREE.Color(source.color[0], source.color[1], source.color[2])
|
||||||
|
: new THREE.Color(0xb0b8c1); // slate-400-ish
|
||||||
|
const defaultMaterial = new THREE.MeshPhongMaterial({
|
||||||
|
color: defaultColor,
|
||||||
|
specular: 0x101010,
|
||||||
|
shininess: 30,
|
||||||
|
flatShading: false,
|
||||||
|
});
|
||||||
|
disposables.push(defaultMaterial);
|
||||||
|
|
||||||
|
const materials: THREENS.Material[] = [defaultMaterial];
|
||||||
|
const outlineMaterial = new THREE.LineBasicMaterial({ color: 0x334155 });
|
||||||
|
disposables.push(outlineMaterial);
|
||||||
|
|
||||||
|
const edges = showEdges ? new THREE.Group() : null;
|
||||||
|
|
||||||
|
const triangleCount = indexTyped.length / 3;
|
||||||
|
|
||||||
|
if (source.brep_faces && source.brep_faces.length > 0) {
|
||||||
|
for (const face of source.brep_faces) {
|
||||||
|
const c = face.color
|
||||||
|
? new THREE.Color(face.color[0], face.color[1], face.color[2])
|
||||||
|
: defaultMaterial.color;
|
||||||
|
const mat = new THREE.MeshPhongMaterial({ color: c, specular: 0x101010, shininess: 30 });
|
||||||
|
materials.push(mat);
|
||||||
|
disposables.push(mat);
|
||||||
|
}
|
||||||
|
|
||||||
|
let triangleIndex = 0;
|
||||||
|
let faceGroupIndex = 0;
|
||||||
|
while (triangleIndex < triangleCount) {
|
||||||
|
const firstIndex = triangleIndex;
|
||||||
|
let lastIndex: number;
|
||||||
|
let materialIndex: number;
|
||||||
|
if (faceGroupIndex >= source.brep_faces.length) {
|
||||||
|
lastIndex = triangleCount;
|
||||||
|
materialIndex = 0;
|
||||||
|
} else if (triangleIndex < source.brep_faces[faceGroupIndex].first) {
|
||||||
|
lastIndex = source.brep_faces[faceGroupIndex].first;
|
||||||
|
materialIndex = 0;
|
||||||
|
} else {
|
||||||
|
lastIndex = source.brep_faces[faceGroupIndex].last + 1;
|
||||||
|
materialIndex = faceGroupIndex + 1;
|
||||||
|
faceGroupIndex++;
|
||||||
|
}
|
||||||
|
geometry.addGroup(firstIndex * 3, (lastIndex - firstIndex) * 3, materialIndex);
|
||||||
|
if (edges) {
|
||||||
|
const inner = new THREE.BufferGeometry();
|
||||||
|
inner.setAttribute("position", geometry.attributes.position);
|
||||||
|
if (geometry.attributes.normal) {
|
||||||
|
inner.setAttribute("normal", geometry.attributes.normal);
|
||||||
|
}
|
||||||
|
inner.setIndex(new THREE.BufferAttribute(indexTyped.slice(firstIndex * 3, lastIndex * 3), 1));
|
||||||
|
const edgesGeom = new THREE.EdgesGeometry(inner, 30);
|
||||||
|
edges.add(new THREE.LineSegments(edgesGeom, outlineMaterial));
|
||||||
|
disposables.push(inner, edgesGeom);
|
||||||
|
}
|
||||||
|
triangleIndex = lastIndex;
|
||||||
|
}
|
||||||
|
} else if (edges) {
|
||||||
|
const edgesGeom = new THREE.EdgesGeometry(geometry, 30);
|
||||||
|
edges.add(new THREE.LineSegments(edgesGeom, outlineMaterial));
|
||||||
|
disposables.push(edgesGeom);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mesh = new THREE.Mesh(
|
||||||
|
geometry,
|
||||||
|
materials.length > 1 ? materials : materials[0],
|
||||||
|
);
|
||||||
|
mesh.name = source.name ?? "occt-mesh";
|
||||||
|
if (edges) edges.renderOrder = mesh.renderOrder + 1;
|
||||||
|
|
||||||
|
return { mesh, edges, triangleCount, disposables };
|
||||||
|
}
|
||||||
Generated
+72
@@ -11,10 +11,12 @@
|
|||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"occt-import-js": "^0.0.23",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"three": "^0.184.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/three": "^0.184.0",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-next": "^15.1.0",
|
"eslint-config-next": "^15.1.0",
|
||||||
"postcss": "^8.5.0",
|
"postcss": "^8.5.0",
|
||||||
@@ -47,6 +50,13 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
@@ -1833,6 +1843,13 @@
|
|||||||
"tailwindcss": "4.2.2"
|
"tailwindcss": "4.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -1912,6 +1929,35 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.184.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
|
||||||
|
"integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"fflate": "~0.8.2",
|
||||||
|
"meshoptimizer": "~1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
|
||||||
@@ -3860,6 +3906,13 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -5187,6 +5240,13 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
@@ -5504,6 +5564,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/occt-import-js": {
|
||||||
|
"version": "0.0.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/occt-import-js/-/occt-import-js-0.0.23.tgz",
|
||||||
|
"integrity": "sha512-RFfYQXYFX5C1mB1Aywm0ShcUKzXOr/VzTnlzhBSDJOR6YCAPt1HYCzeXWg1vwwjn/cUxwqRNhhtf1dlewoZYCQ==",
|
||||||
|
"license": "LGPL-2.1"
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -6508,6 +6574,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.184.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||||
|
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"predev": "node scripts/copy-viewer-assets.mjs",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"prebuild": "node scripts/copy-viewer-assets.mjs",
|
||||||
"build": "prisma generate && next build",
|
"build": "prisma generate && next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
@@ -18,10 +20,12 @@
|
|||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"occt-import-js": "^0.0.23",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"three": "^0.184.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -32,6 +36,7 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/three": "^0.184.0",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-next": "^15.1.0",
|
"eslint-config-next": "^15.1.0",
|
||||||
"postcss": "^8.5.0",
|
"postcss": "^8.5.0",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Copies vendor assets that need to ship in `public/` so they can be fetched
|
||||||
|
// at runtime (wasm, etc). Run via npm pre-hooks — see package.json.
|
||||||
|
import { copyFile, mkdir, access } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = join(__dirname, "..");
|
||||||
|
|
||||||
|
const copies = [
|
||||||
|
{
|
||||||
|
from: "node_modules/occt-import-js/dist/occt-import-js.wasm",
|
||||||
|
to: "public/vendor/occt-import-js.wasm",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const { from, to } of copies) {
|
||||||
|
const src = join(ROOT, from);
|
||||||
|
const dest = join(ROOT, to);
|
||||||
|
try {
|
||||||
|
await access(src);
|
||||||
|
} catch {
|
||||||
|
console.warn(`[copy-viewer-assets] missing: ${from} — did npm install run?`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await mkdir(dirname(dest), { recursive: true });
|
||||||
|
await copyFile(src, dest);
|
||||||
|
console.log(`[copy-viewer-assets] ${from} → ${to}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("[copy-viewer-assets] failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
Vendored
+45
@@ -0,0 +1,45 @@
|
|||||||
|
// Minimal ambient types for the `occt-import-js` WASM loader. The package
|
||||||
|
// ships no .d.ts of its own. Types follow the shape documented in the project
|
||||||
|
// README and the `three_viewer.html` example.
|
||||||
|
|
||||||
|
declare module "occt-import-js" {
|
||||||
|
export interface OcctArrayAttribute {
|
||||||
|
array: number[] | Float32Array | Uint32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcctBrepFace {
|
||||||
|
first: number;
|
||||||
|
last: number;
|
||||||
|
color?: [number, number, number] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcctMesh {
|
||||||
|
name?: string;
|
||||||
|
color?: [number, number, number] | null;
|
||||||
|
brep_faces?: OcctBrepFace[];
|
||||||
|
attributes: {
|
||||||
|
position: OcctArrayAttribute;
|
||||||
|
normal?: OcctArrayAttribute;
|
||||||
|
};
|
||||||
|
index: OcctArrayAttribute;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcctResult {
|
||||||
|
success: boolean;
|
||||||
|
meshes: OcctMesh[];
|
||||||
|
root?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcctModule {
|
||||||
|
ReadStepFile: (buffer: Uint8Array, params: unknown) => OcctResult;
|
||||||
|
ReadBrepFile?: (buffer: Uint8Array, params: unknown) => OcctResult;
|
||||||
|
ReadIgesFile?: (buffer: Uint8Array, params: unknown) => OcctResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcctInitOptions {
|
||||||
|
locateFile?: (filename: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const factory: (options?: OcctInitOptions) => Promise<OcctModule>;
|
||||||
|
export default factory;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user