diff --git a/.gitignore b/.gitignore index 286ef636b9..12f26d9126 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.vscode node_modules .turbo *.log @@ -34,4 +35,15 @@ packages/database/supabase/seed.sql .vercel .env*.local -.react-router \ No newline at end of file +.react-router + +# OpenCode +.opencode/sessions/ +.opencode/logs/ + +# Rust +**/target/ +**/*.rs.bk +**/pkg/ + +packages/cad-engine diff --git a/CLAUDE.md b/CLAUDE.md index 4cd5e30b1b..566ffe2e9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,3 +25,15 @@ Rules for updating/writing to the cache: - ALWAYS update the cache after a commit. - NEVER update the cache about staged/uncommitted code. - NEVER rebuild the database to test changes. Wait for the user to do that. + + +## Workflow Rules + +- ALWAYS check in with me before making any major changes. +- Ask clarifying questions if uncertain - never make assumptions. +- Make small, incremental changes - never large sweeping changes. +- Always update CHANGELOG.md when making changes. +- Always write tests for new code. +- Always run tests before committing. +- Never commit directly to main - always create a new branch. +- Always create a PR for changes and ask for review before merging. diff --git a/apps/assembly/.dockerignore b/apps/assembly/.dockerignore new file mode 100644 index 0000000000..a7a94e9802 --- /dev/null +++ b/apps/assembly/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.env* +!.env.example diff --git a/apps/assembly/.gitignore b/apps/assembly/.gitignore new file mode 100644 index 0000000000..f9f1240cbc --- /dev/null +++ b/apps/assembly/.gitignore @@ -0,0 +1,6 @@ +node_modules +.env +.env.local +build +.turbo +.react-router diff --git a/apps/assembly/Dockerfile b/apps/assembly/Dockerfile new file mode 100644 index 0000000000..bc4178d1dd --- /dev/null +++ b/apps/assembly/Dockerfile @@ -0,0 +1,31 @@ +FROM node:20 AS deps +WORKDIR /repo +# Install specific npm version +RUN npm install -g npm@10.8.2 +# Copy root manifests for workspaces +COPY package.json package-lock.json turbo.json ./ +# Copy only what we need to install and build +COPY apps ./apps +COPY packages ./packages +# Install all workspaces (dev deps are needed to build) +RUN npm install --legacy-peer-deps + +FROM deps AS build +# Build only Assembly and its deps +RUN npx turbo run build --filter=./apps/assembly + +FROM node:20 +WORKDIR /repo +# Install specific npm version +RUN npm install -g npm@10.8.2 +ENV NODE_ENV=production +ENV PORT=3002 +# Install curl for health checks +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +# Production needs built app, node_modules, and packages (for workspace deps) +COPY --from=deps /repo/node_modules ./node_modules +COPY --from=deps /repo/packages ./packages +COPY --from=build /repo/apps/assembly ./apps/assembly +EXPOSE 3002 +WORKDIR /repo/apps/assembly +CMD ["npm","run","start"] diff --git a/apps/assembly/README.md b/apps/assembly/README.md new file mode 100644 index 0000000000..63ec908cf1 --- /dev/null +++ b/apps/assembly/README.md @@ -0,0 +1,115 @@ +# Assembly Work Instructions + +Automatic generation of assembly/disassembly work instructions from CAD files. Upload a STEP file, get an animated step-by-step assembly plan with collision-validated motion paths. + +## How It Works + +``` +STEP file upload + | + v +[step-parser-occ] ── Python + OpenCascade ── extracts assembly tree + GLB mesh + | + v +[assembly-simulate] ── Rust (parry3d) ── finds disassembly sequence + animation paths + | + v +[project editor] ── React + xeokit ── 3D viewer with animated work instructions +``` + +### 1. Parsing (`step-parser-occ` job) + +- Reads STEP AP203/AP214 files using OpenCascade (via `cadquery`) +- Builds an assembly tree: assemblies contain sub-assemblies and parts +- Tessellates each part into triangle meshes and exports a single GLB file +- Stores the assembly tree (JSON) and GLB (S3) in the project record + +### 2. Simulation (`assembly-simulate` job) + +The Rust simulator at `packages/cad-rust/` finds how to take the assembly apart, then reverses it into assembly order. It runs a 10-step pipeline: + +1. **Contact graph** -- sweep-and-prune broad phase + parry3d narrow phase to find which parts touch +2. **Classification** -- labels parts as structural, fastener, panel, or standard based on geometry +3. **Dependency graph** -- fasteners go after their neighbors, structural parts go before panels +4. **Blocking matrix** -- swept-AABB pre-filter to check which parts block which in 6 axis directions +5. **Disassembly loop** -- iteratively removes unblocked parts, evaluating collision-free removal paths +6. **Path evaluation** -- discrete CCD sampling with binary search refinement to find exact clearance distance +7. **Reversal** -- flips disassembly order into assembly order +8. **Identical groups** -- merges repeated parts into groups (e.g. "8x M6 bolt") +9. **Subassemblies** -- detects sub-assemblies that can be pre-assembled offline +10. **Animation keyframes** -- generates time-stamped 4x4 transform keyframes for each step + +Each step includes: part IDs, direction, travel distance, duration, and animation keyframes. + +### 3. Viewer (`apps/assembly/app/components/Viewer/`) + +- **XeokitCanvas** -- loads the GLB model into a xeokit 3D scene +- **useAnimationPlayback** -- interpolates keyframes and applies positional offsets to animate parts +- **useXeokit** -- manages the xeokit viewer lifecycle, highlighting, and camera + +## Project Structure + +``` +apps/assembly/ + app/ + routes/x+/ + _index.tsx # Dashboard -- project list, upload STEP + projects.$id.edit # Editor -- work instruction editing + 3D viewer + projects.$id.prep # Prep view + projects.new # New project wizard + settings.* # Tool library, torque specs, associations + components/ + Viewer/ # xeokit 3D viewer + animation engine + WorkInstructions/ # Step editor, panels, export modal + Home/ # Dashboard cards + +packages/cad-rust/ + cad-common/ # Shared types (AssemblyNode, AssemblyStep, etc.) + cad-simulator/ # Assembly-by-disassembly solver (parry3d collision) + cad-server/ # Axum HTTP server (/health, /parse, /simulate) + cad-parser/ # Rust STEP parser (truck-stepio, limited) + cad-wasm/ # WASM build for browser-side use + +packages/jobs/trigger/ + step-parser-occ.ts # Trigger.dev job: STEP → assembly tree + GLB + assembly-simulate.ts # Trigger.dev job: tree + GLB → simulation result +``` + +## Running Locally + +### Frontend + +```bash +cd apps/assembly +pnpm dev # starts React Router dev server +``` + +### Rust CAD Server + +```bash +cd packages/cad-rust +cargo build --release +./target/release/cad-server # listens on :8080 +``` + +The `assembly-simulate` job calls `CAD_SERVER_URL` (default `http://localhost:8080`). + +### Running Simulation Jobs + +Jobs run via [Trigger.dev](https://trigger.dev). With the dev CLI running (`npx trigger dev`), uploading a STEP file triggers the parse job, which auto-chains to the simulation job. + +To re-run simulation on an existing project, use the "Re-run Simulation" button in the project editor. + +## Key Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CAD_SERVER_URL` | `http://localhost:8080` | Rust simulation server URL | +| `PORT` | `8080` | Port for the Rust CAD server | + +## Tests + +```bash +cd packages/cad-rust +cargo test # runs 31 tests (simulator, parser, common) +``` diff --git a/apps/assembly/app/components/Home/NewProjectCard.tsx b/apps/assembly/app/components/Home/NewProjectCard.tsx new file mode 100644 index 0000000000..90f5af12d7 --- /dev/null +++ b/apps/assembly/app/components/Home/NewProjectCard.tsx @@ -0,0 +1,22 @@ +import { BsPlus } from "react-icons/bs"; + +interface NewProjectCardProps { + onClick: () => void; +} + +export function NewProjectCard({ onClick }: NewProjectCardProps) { + return ( + + ); +} diff --git a/apps/assembly/app/components/Home/NewProjectModal.tsx b/apps/assembly/app/components/Home/NewProjectModal.tsx new file mode 100644 index 0000000000..c5b7e65261 --- /dev/null +++ b/apps/assembly/app/components/Home/NewProjectModal.tsx @@ -0,0 +1,406 @@ +import { + Button, + Input, + Label, + Modal, + ModalBody, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTitle +} from "@carbon/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useDropzone } from "react-dropzone"; +import { + BsCheck2Circle, + BsCloudUpload, + BsExclamationTriangle, + BsFileEarmarkCode, + BsX +} from "react-icons/bs"; +import { useFetcher } from "react-router"; +import { path } from "~/utils/path"; + +interface NewProjectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +type PipelineStage = + | "idle" + | "uploading" + | "parsing" + | "simulating" + | "done" + | "error"; + +interface ProjectStatus { + status: string; + parsingProgress: number | null; + parsingError: string | null; + simulationStatus: string | null; + simulationError: string | null; +} + +function getPipelineStage(data: ProjectStatus | null): PipelineStage { + if (!data) return "uploading"; + if (data.parsingError || data.simulationError) return "error"; + if (data.status === "preprocessing" || data.status === "parsing") + return "parsing"; + if (data.status === "simulating") return "simulating"; + if (data.status === "editing" && data.simulationStatus === "completed") + return "done"; + // If editing but simulation hasn't run yet, still consider done + // (in case simulation was skipped or chaining hasn't kicked in yet) + if (data.status === "editing") return "done"; + if (data.status === "failed") return "error"; + return "parsing"; +} + +function getErrorMessage(data: ProjectStatus | null): string { + if (!data) return "Something went wrong"; + return data.parsingError || data.simulationError || "Something went wrong"; +} + +export function NewProjectModal({ open, onOpenChange }: NewProjectModalProps) { + const [selectedFile, setSelectedFile] = useState(null); + const [projectName, setProjectName] = useState(""); + const [projectId, setProjectId] = useState(null); + const [stage, setStage] = useState("idle"); + + const submitFetcher = useFetcher(); + const pollFetcher = useFetcher(); + const pollTimerRef = useRef | null>(null); + + const isSubmitting = submitFetcher.state !== "idle"; + + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + setSelectedFile(acceptedFiles[0]); + } + }, []); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + "application/step": [".step", ".stp", ".STEP", ".STP"] + }, + maxFiles: 1 + }); + + // Handle submit response — extract projectId + useEffect(() => { + if (submitFetcher.data && typeof submitFetcher.data === "object") { + const data = submitFetcher.data as { projectId?: string; error?: string }; + if (data.projectId) { + setProjectId(data.projectId); + setStage("parsing"); + } else if (data.error) { + setStage("error"); + } + } + }, [submitFetcher.data]); + + // Poll for project status once we have a projectId + const pollFetcherLoadRef = useRef(pollFetcher.load); + pollFetcherLoadRef.current = pollFetcher.load; + + useEffect(() => { + if (!projectId || stage === "done" || stage === "error") { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + return; + } + + const poll = () => { + pollFetcherLoadRef.current(`/x/api/project-status/${projectId}`); + }; + + // Poll immediately, then every 2s + poll(); + pollTimerRef.current = setInterval(poll, 2000); + + return () => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }; + }, [projectId, stage]); + + // Update stage from poll data + useEffect(() => { + if (pollFetcher.data) { + const newStage = getPipelineStage(pollFetcher.data); + setStage(newStage); + } + }, [pollFetcher.data]); + + const handleSubmit = () => { + if (!selectedFile || !projectName.trim()) return; + + setStage("uploading"); + + const formData = new FormData(); + formData.set("intent", "createProject"); + formData.set("name", projectName.trim()); + formData.set("file", selectedFile); + + submitFetcher.submit(formData, { + method: "POST", + encType: "multipart/form-data" + }); + }; + + const handleReset = () => { + setSelectedFile(null); + setProjectName(""); + setProjectId(null); + setStage("idle"); + }; + + const handleClose = () => { + if (stage === "done") { + // Reset for next time + handleReset(); + } + onOpenChange(false); + }; + + const showUploadForm = stage === "idle"; + + return ( + + + {showUploadForm ? ( + <> + + New Project + + Upload a STEP file to create assembly instructions with + automated disassembly simulation. + + + + +
+ {/* Project Name */} +
+ + setProjectName(e.target.value)} + autoFocus + /> +
+ + {/* File Upload */} +
+ +
+ + {selectedFile ? ( +
+ +
+

{selectedFile.name}

+

+ {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +

+
+ +
+ ) : ( + <> + +

+ {isDragActive + ? "Drop file here" + : "Drop STEP file here or click to browse"} +

+

+ Supports .step and .stp files +

+ + )} +
+
+
+
+ + + + + + + ) : ( + <> + + + {stage === "done" + ? "Project Ready!" + : stage === "error" + ? "Something went wrong" + : "Setting up your project..."} + + + {stage === "done" + ? "Your assembly instructions are ready to edit." + : stage === "error" + ? "There was a problem processing your file." + : "This usually takes 1-3 minutes depending on model complexity."} + + + + +
+ + + + +
+ + {stage === "error" && ( +
+ {getErrorMessage(pollFetcher.data ?? null)} +
+ )} +
+ + + {stage === "error" && ( + + )} + {stage === "done" && projectId && ( + + )} + + + )} +
+
+ ); +} + +function PipelineStep({ + label, + detail, + status +}: { + label: string; + detail?: string; + status: "pending" | "active" | "done" | "error"; +}) { + return ( +
+
+ {status === "done" && ( + + )} + {status === "active" && ( +
+ )} + {status === "pending" && ( +
+ )} + {status === "error" && ( + + )} +
+ + {label} + {detail && ( + ({detail}) + )} + +
+ ); +} diff --git a/apps/assembly/app/components/Home/ProjectCard.tsx b/apps/assembly/app/components/Home/ProjectCard.tsx new file mode 100644 index 0000000000..6146ef9a47 --- /dev/null +++ b/apps/assembly/app/components/Home/ProjectCard.tsx @@ -0,0 +1,76 @@ +import { Card } from "@carbon/react"; +import { BsFolder2Open } from "react-icons/bs"; +import { Link } from "react-router"; +import { path } from "~/utils/path"; + +interface ProjectCardProps { + project: { + id: string; + name: string; + description?: string | null; + status: string; + thumbnailPath?: string | null; + updatedAt: string; + }; +} + +const statusStyles: Record = { + published: "bg-green-100 text-green-700", + editing: "bg-blue-100 text-blue-700", + simulating: "bg-violet-100 text-violet-700", + preprocessing: "bg-yellow-100 text-yellow-700", + parsing: "bg-yellow-100 text-yellow-700", + failed: "bg-red-100 text-red-700" +}; + +function timeAgo(dateStr: string): string { + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return new Date(dateStr).toLocaleDateString(); +} + +export function ProjectCard({ project }: ProjectCardProps) { + return ( + + +
+ {project.thumbnailPath ? ( + {project.name} + ) : ( + + )} +
+

+ {project.name} +

+ {project.description && ( +

+ {project.description} +

+ )} +
+ + {project.status} + + + {timeAgo(project.updatedAt)} + +
+
+ + ); +} diff --git a/apps/assembly/app/components/Layout/Topbar.tsx b/apps/assembly/app/components/Layout/Topbar.tsx new file mode 100644 index 0000000000..4ba495aef4 --- /dev/null +++ b/apps/assembly/app/components/Layout/Topbar.tsx @@ -0,0 +1,46 @@ +import { Button } from "@carbon/react"; +import { BsGear, BsHammer } from "react-icons/bs"; +import { Link, useLoaderData } from "react-router"; +import { path } from "~/utils/path"; + +export function Topbar() { + const { user, company } = useLoaderData<{ + user: { firstName: string; lastName: string; email: string } | null; + company: { name: string } | null; + }>(); + + const initials = user + ? `${user.firstName?.[0] ?? ""}${user.lastName?.[0] ?? ""}` + : "?"; + + return ( +
+ + + Smithy + {company?.name && ( + <> + / + + {company.name} + + + )} + + +
+ +
+ {initials} +
+
+
+ ); +} diff --git a/apps/assembly/app/components/Layout/index.ts b/apps/assembly/app/components/Layout/index.ts new file mode 100644 index 0000000000..d6f8f82956 --- /dev/null +++ b/apps/assembly/app/components/Layout/index.ts @@ -0,0 +1 @@ +export { Topbar } from "./Topbar"; diff --git a/apps/assembly/app/components/Viewer/XeokitCanvas.tsx b/apps/assembly/app/components/Viewer/XeokitCanvas.tsx new file mode 100644 index 0000000000..4dba85b410 --- /dev/null +++ b/apps/assembly/app/components/Viewer/XeokitCanvas.tsx @@ -0,0 +1,471 @@ +import { useEffect, useRef, useState } from "react"; +import type { CameraState } from "~/types/assembly.types"; + +// Dynamic import types - these will be loaded client-side only +type XeokitViewer = import("@xeokit/xeokit-sdk").Viewer; +type XeokitXKTLoaderPlugin = import("@xeokit/xeokit-sdk").XKTLoaderPlugin; +type XeokitGLTFLoaderPlugin = import("@xeokit/xeokit-sdk").GLTFLoaderPlugin; +type XeokitNavCubePlugin = import("@xeokit/xeokit-sdk").NavCubePlugin; +type XeokitSectionPlanesPlugin = + import("@xeokit/xeokit-sdk").SectionPlanesPlugin; +type XeokitDistanceMeasurementsPlugin = + import("@xeokit/xeokit-sdk").DistanceMeasurementsPlugin; +type XeokitAnnotationsPlugin = import("@xeokit/xeokit-sdk").AnnotationsPlugin; + +export interface XeokitCanvasProps { + canvasId?: string; + navCubeCanvasId?: string; + modelUrl?: string; + modelFormat?: "xkt" | "gltf"; + onViewerReady?: (viewer: Viewer) => void; + onModelLoaded?: () => void; + onPartSelected?: (partId: string | null, partName: string | null) => void; + highlightedPartIds?: string[]; + hiddenPartIds?: string[]; + className?: string; +} + +export function XeokitCanvas({ + canvasId = "xeokit-canvas", + navCubeCanvasId = "navCube-canvas", + modelUrl, + modelFormat = "gltf", + onViewerReady, + onModelLoaded, + onPartSelected, + highlightedPartIds = [], + hiddenPartIds = [], + className +}: XeokitCanvasProps) { + const viewerRef = useRef(null); + const xktLoaderRef = useRef(null); + const gltfLoaderRef = useRef(null); + const navCubeRef = useRef(null); + const sectionPlanesRef = useRef(null); + const measurementsRef = useRef(null); + const annotationsRef = useRef(null); + const [isClient, setIsClient] = useState(false); + const [isViewerReady, setIsViewerReady] = useState(false); + const [isModelLoaded, setIsModelLoaded] = useState(false); + + // Use refs for callbacks to avoid infinite re-render loops + const onModelLoadedRef = useRef(onModelLoaded); + onModelLoadedRef.current = onModelLoaded; + const onViewerReadyRef = useRef(onViewerReady); + onViewerReadyRef.current = onViewerReady; + const onPartSelectedRef = useRef(onPartSelected); + onPartSelectedRef.current = onPartSelected; + + // Check if we're on the client + useEffect(() => { + setIsClient(true); + }, []); + + // Initialize viewer - only on client + useEffect(() => { + if (!isClient) return; + if (viewerRef.current) return; + + // Track if effect was cleaned up (for React Strict Mode double-mount) + let cancelled = false; + + // Dynamically import xeokit-sdk only on client + import("@xeokit/xeokit-sdk").then((xeokit) => { + // Bail out if effect was cleaned up during async import + if (cancelled) { + console.log("[VIEWER] Skipping setup - effect was cleaned up"); + return; + } + const { + Viewer, + NavCubePlugin, + SectionPlanesPlugin, + DistanceMeasurementsPlugin, + AnnotationsPlugin, + XKTLoaderPlugin, + GLTFLoaderPlugin + } = xeokit; + + const viewer = new Viewer({ + canvasId, + transparent: false, + // Quality settings + antialias: true, // Smooth jagged edges + logarithmicDepthBufferEnabled: true, // Better depth precision for large models + pbrEnabled: true, // Physically-based rendering + preserveDrawingBuffer: true, // Required for screenshots + entityOffsetsEnabled: true // Required for animation (entity.offset = [dx, dy, dz]) + }); + + // Set dark background + viewer.scene.canvas.canvas.style.background = "#1a1a2e"; + + // Fix zoom-out clipping: extend camera far plane + viewer.scene.camera.perspective.far = 100000; // Large far plane to prevent model disappearing + viewer.scene.camera.perspective.near = 0.1; // Small near plane for close-up views + + // Enable SAO (Scalable Ambient Occlusion) for depth/shadow effects + viewer.scene.sao.enabled = true; + viewer.scene.sao.intensity = 0.15; // Subtle shadows (reduced for cleaner look) + viewer.scene.sao.bias = 0.5; + viewer.scene.sao.scale = 1000; + viewer.scene.sao.kernelRadius = 100; + + // Better gamma correction for color accuracy + viewer.scene.gammaOutput = true; + viewer.scene.gammaFactor = 2.2; + + // Autodesk-like metallic/shiny appearance + // Configure default material for imported models + viewer.scene.pbrEnabled = true; + + // Configure highlight material (when parts are selected) + viewer.scene.highlightMaterial.fill = true; + viewer.scene.highlightMaterial.fillColor = [0.5, 0.7, 1.0]; + viewer.scene.highlightMaterial.fillAlpha = 0.3; + viewer.scene.highlightMaterial.edges = true; + viewer.scene.highlightMaterial.edgeColor = [0.3, 0.5, 1.0]; + viewer.scene.highlightMaterial.edgeAlpha = 1.0; + viewer.scene.highlightMaterial.edgeWidth = 2; + + // Configure edge material for better visibility + viewer.scene.edgeMaterial.edgeColor = [0.2, 0.2, 0.2]; + viewer.scene.edgeMaterial.edgeAlpha = 0.3; + viewer.scene.edgeMaterial.edgeWidth = 1; + + // Add better lighting for metallic shine effect (Autodesk-like) + // xeokit uses scene.lights array - clear and add custom lights + try { + // Clear default lights + const lightIds = Object.keys(viewer.scene.lights); + lightIds.forEach((id) => { + if (viewer.scene.lights[id]) { + viewer.scene.lights[id].destroy(); + } + }); + + // Add custom lighting setup using xeokit's Light classes + const { DirLight, AmbientLight } = xeokit; + + // Key light - main illumination + new DirLight(viewer.scene, { + id: "keyLight", + dir: [0.8, -0.6, -0.8], + color: [1.0, 1.0, 0.95], + intensity: 1.0, + space: "world" + }); + + // Fill light - softer from opposite side + new DirLight(viewer.scene, { + id: "fillLight", + dir: [-0.8, -0.4, 0.4], + color: [0.9, 0.95, 1.0], + intensity: 0.6, + space: "world" + }); + + // Rim light - highlights edges + new DirLight(viewer.scene, { + id: "rimLight", + dir: [-0.2, -0.8, 0.5], + color: [1.0, 1.0, 1.0], + intensity: 0.4, + space: "world" + }); + + // Ambient light + new AmbientLight(viewer.scene, { + id: "ambientLight", + color: [0.9, 0.9, 1.0], + intensity: 0.3 + }); + } catch (lightErr) { + console.warn( + "[VIEWER] Custom lighting setup failed, using defaults:", + lightErr + ); + } + + // NavCube - view orientation widget (like BuildOS) + navCubeRef.current = new NavCubePlugin(viewer, { + canvasId: navCubeCanvasId, + visible: true, + cameraFly: true, + cameraFlyDuration: 0.5, + fitVisible: true, + synchProjection: true + }); + + // Section planes for cutting views + sectionPlanesRef.current = new SectionPlanesPlugin(viewer, { + overviewVisible: false + }); + + // Distance measurements + measurementsRef.current = new DistanceMeasurementsPlugin(viewer, { + defaultVisible: true, + defaultOriginVisible: true, + defaultTargetVisible: true, + defaultWireVisible: true, + defaultAxisVisible: true + }); + + // Annotations for callouts + annotationsRef.current = new AnnotationsPlugin(viewer, { + markerHTML: + "
", + labelHTML: + "
{{title}}
" + }); + + // XKT loader for converted STEP files + xktLoaderRef.current = new XKTLoaderPlugin(viewer); + + // GLTF loader for standard 3D models + gltfLoaderRef.current = new GLTFLoaderPlugin(viewer); + + // Click handler for part selection + viewer.scene.input.on("mouseclicked", (coords: number[]) => { + const hit = viewer.scene.pick({ + canvasPos: coords, + pickSurface: true + }); + + if (hit && hit.entity) { + const entityId = hit.entity.id; + console.log("[VIEWER] Clicked entity:", entityId); + onPartSelectedRef.current?.(entityId, entityId); + } else { + console.log("[VIEWER] Clicked empty space"); + onPartSelectedRef.current?.(null, null); + } + }); + + viewerRef.current = viewer; + setIsViewerReady(true); + onViewerReadyRef.current?.(viewer); + }); + + return () => { + cancelled = true; + if (viewerRef.current) { + viewerRef.current.destroy(); + viewerRef.current = null; + setIsViewerReady(false); + setIsModelLoaded(false); + } + }; + // Note: Callbacks use refs to avoid re-creating viewer on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isClient, canvasId, navCubeCanvasId]); + + // Load model when URL changes or viewer becomes ready + useEffect(() => { + if (!modelUrl || !isViewerReady || !viewerRef.current) return; + + const viewer = viewerRef.current; + let cancelled = false; + setIsModelLoaded(false); + + // Clear existing models + const existingModels = Object.keys(viewer.scene.models); + existingModels.forEach((modelId) => { + viewer.scene.models[modelId]?.destroy(); + }); + + // Load new model + let sceneModel: ReturnType | null = null; + + if (modelFormat === "xkt" && xktLoaderRef.current) { + sceneModel = xktLoaderRef.current.load({ + id: "assembly", + src: modelUrl, + edges: true + }); + } else if (modelFormat === "gltf" && gltfLoaderRef.current) { + sceneModel = gltfLoaderRef.current.load({ + id: "assembly", + src: modelUrl, + edges: true + }); + } + + // Wait for model to finish loading + if (sceneModel) { + console.log("[VIEWER] Loading model from:", modelUrl); + + sceneModel.on("loaded", () => { + // Bail out if effect was cleaned up during load + if (cancelled) { + console.log( + "[VIEWER] Model loaded but effect was cleaned up - skipping" + ); + return; + } + const entityCount = Object.keys(viewer.scene.objects).length; + console.log("[VIEWER] Model loaded with", entityCount, "entities"); + console.log( + "[VIEWER] Entity IDs:", + Object.keys(viewer.scene.objects).slice(0, 10) + ); + setIsModelLoaded(true); + onModelLoadedRef.current?.(); + // Fit to view after load + viewer.cameraFlight.flyTo({ + aabb: viewer.scene.aabb, + duration: 0.5 + }); + }); + + sceneModel.on("error", (err: unknown) => { + console.error("[VIEWER] Model load error:", err); + }); + } + + return () => { + cancelled = true; + // Destroy the model when effect cleans up + if (sceneModel) { + try { + sceneModel.destroy(); + } catch { + // Ignore errors during cleanup + } + } + }; + // Note: onModelLoaded is intentionally NOT in deps to prevent infinite loops. + // The callback is captured at effect creation time. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modelUrl, modelFormat, isViewerReady]); + + // Update highlighted parts + useEffect(() => { + if (!isModelLoaded || !viewerRef.current) return; + const viewer = viewerRef.current; + + // Reset all highlights + viewer.scene.setObjectsHighlighted(viewer.scene.objectIds, false); + + // Apply new highlights + if (highlightedPartIds.length > 0) { + viewer.scene.setObjectsHighlighted(highlightedPartIds, true); + } + }, [highlightedPartIds, isModelLoaded]); + + // Update hidden parts + const prevHiddenRef = useRef([]); + useEffect(() => { + if (!isModelLoaded || !viewerRef.current) return; + const viewer = viewerRef.current; + + // Only update visibility for parts that changed (avoid showing ALL then hiding) + const nowHidden = new Set(hiddenPartIds); + + // Show parts that were hidden but are now visible + const toShow = prevHiddenRef.current.filter((id) => !nowHidden.has(id)); + if (toShow.length > 0) { + viewer.scene.setObjectsVisible(toShow, true); + } + + // Hide parts that are now hidden + if (hiddenPartIds.length > 0) { + viewer.scene.setObjectsVisible(hiddenPartIds, false); + } + + prevHiddenRef.current = hiddenPartIds; + }, [hiddenPartIds, isModelLoaded]); + + return ( +
+ {/* Main 3D canvas */} + + + {/* NavCube canvas - positioned top-right like BuildOS */} + +
+ ); +} + +// Utility functions for viewer control +export function flyToViewpoint( + viewer: XeokitViewer, + viewpoint: CameraState, + duration = 0.5 +) { + viewer.cameraFlight.flyTo({ + eye: [viewpoint.eye.x, viewpoint.eye.y, viewpoint.eye.z], + look: [viewpoint.center.x, viewpoint.center.y, viewpoint.center.z], + up: [viewpoint.up.x, viewpoint.up.y, viewpoint.up.z], + duration + }); +} + +export function flyToEntity( + viewer: XeokitViewer, + entityId: string, + duration = 0.5 +) { + const entity = viewer.scene.objects[entityId]; + if (entity) { + viewer.cameraFlight.flyTo({ + aabb: entity.aabb, + duration + }); + } +} + +export function setViewPreset( + viewer: Viewer, + preset: "front" | "back" | "top" | "bottom" | "left" | "right" | "iso" +) { + const aabb = viewer.scene.aabb; + const center = [ + (aabb[0] + aabb[3]) / 2, + (aabb[1] + aabb[4]) / 2, + (aabb[2] + aabb[5]) / 2 + ]; + const size = Math.max( + aabb[3] - aabb[0], + aabb[4] - aabb[1], + aabb[5] - aabb[2] + ); + const distance = size * 2; + + const presets: Record = { + front: { eye: [center[0], center[1], center[2] + distance], up: [0, 1, 0] }, + back: { eye: [center[0], center[1], center[2] - distance], up: [0, 1, 0] }, + top: { eye: [center[0], center[1] + distance, center[2]], up: [0, 0, -1] }, + bottom: { + eye: [center[0], center[1] - distance, center[2]], + up: [0, 0, 1] + }, + left: { eye: [center[0] - distance, center[1], center[2]], up: [0, 1, 0] }, + right: { eye: [center[0] + distance, center[1], center[2]], up: [0, 1, 0] }, + iso: { + eye: [ + center[0] + distance * 0.7, + center[1] + distance * 0.7, + center[2] + distance * 0.7 + ], + up: [0, 1, 0] + } + }; + + const { eye, up } = presets[preset]; + + viewer.cameraFlight.flyTo({ + eye, + look: center, + up, + duration: 0.5 + }); +} diff --git a/apps/assembly/app/components/Viewer/index.ts b/apps/assembly/app/components/Viewer/index.ts new file mode 100644 index 0000000000..f53213465d --- /dev/null +++ b/apps/assembly/app/components/Viewer/index.ts @@ -0,0 +1,11 @@ +export type { AnimationPlaybackState } from "./useAnimationPlayback"; +export { useAnimationPlayback } from "./useAnimationPlayback"; +export type { UseXeokitOptions } from "./useXeokit"; +export { useXeokit } from "./useXeokit"; +export type { XeokitCanvasProps } from "./XeokitCanvas"; +export { + flyToEntity, + flyToViewpoint, + setViewPreset, + XeokitCanvas +} from "./XeokitCanvas"; diff --git a/apps/assembly/app/components/Viewer/useAnimationPlayback.ts b/apps/assembly/app/components/Viewer/useAnimationPlayback.ts new file mode 100644 index 0000000000..5144823852 --- /dev/null +++ b/apps/assembly/app/components/Viewer/useAnimationPlayback.ts @@ -0,0 +1,348 @@ +/** + * Animation playback engine for assembly steps. + * + * Drives a requestAnimationFrame loop that interpolates per-step keyframes + * and applies entity offsets in the xeokit viewer. + * + * Visual behaviour: + * - Completed steps (0 .. current-1): parts visible, offset [0,0,0] + * - Current step: parts visible, offset interpolated from start → [0,0,0] + * - Future steps (current+1 .. end): parts hidden + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { AssemblyStep } from "~/types/assembly.types"; + +type Viewer = import("@xeokit/xeokit-sdk").Viewer; + +// xeokit's TS definitions are incomplete — offset and visible exist at runtime +interface XeokitEntity { + offset: number[]; + visible: boolean; +} + +type Offset3 = [number, number, number]; + +interface StepKeyframeLike { + partId: string; + timestamp: number; + position: { + x: number; + y: number; + z: number; + }; +} + +export interface UseAnimationPlaybackOptions { + /** xeokit Viewer instance (null until ready). */ + viewer: Viewer | null; + /** Whether the 3D model has finished loading. */ + isModelLoaded?: boolean; + /** All assembly steps in order. */ + steps: AssemblyStep[]; + /** Currently selected step index (from manual navigation). */ + selectedStepIndex: number; + /** Called when playback advances to a new step. */ + onStepChange: (index: number) => void; + /** Fallback duration per step in ms (used when step.duration is 0). */ + defaultStepDurationMs?: number; +} + +export interface AnimationPlaybackState { + /** Whether the RAF loop is running. */ + isPlaying: boolean; + /** 0-1 progress within the current step. */ + stepProgress: number; + /** Part IDs that should be hidden (future steps). */ + hiddenPartIds: string[]; + /** Start playback from the current selectedStepIndex. */ + play: () => void; + /** Pause playback (keeps current offsets). */ + pause: () => void; + /** Stop playback and reset all offsets to zero. */ + stop: () => void; +} + +/** + * Safely get a xeokit entity with offset/visible support. + * xeokit entity IDs are UUIDs that match the partIds from the database. + */ +function getEntity(viewer: Viewer | null, partId: string): XeokitEntity | null { + if (!viewer?.scene?.objects) return null; + // Direct lookup - partId IS the entity ID (both are UUIDs) + return (viewer.scene.objects[partId] as unknown as XeokitEntity) ?? null; +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** Evaluate a part's sampled offset curve at normalized time t. */ +function evaluatePartOffset( + partKeyframes: StepKeyframeLike[], + t: number +): Offset3 { + const clamped = Math.max(0, Math.min(1, t)); + if (partKeyframes.length === 0) return [0, 0, 0]; + if (partKeyframes.length === 1) { + const kf = partKeyframes[0]; + return [ + kf.position.x * (1 - clamped), + kf.position.y * (1 - clamped), + kf.position.z * (1 - clamped) + ]; + } + + const sorted = [...partKeyframes].sort((a, b) => a.timestamp - b.timestamp); + + const first = sorted[0]; + if (clamped <= first.timestamp) { + return [first.position.x, first.position.y, first.position.z]; + } + + const last = sorted[sorted.length - 1]; + if (clamped >= last.timestamp) { + return [last.position.x, last.position.y, last.position.z]; + } + + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i]; + const b = sorted[i + 1]; + if (clamped >= a.timestamp && clamped <= b.timestamp) { + const span = Math.max(b.timestamp - a.timestamp, 1.0e-6); + const localT = (clamped - a.timestamp) / span; + return [ + lerp(a.position.x, b.position.x, localT), + lerp(a.position.y, b.position.y, localT), + lerp(a.position.z, b.position.z, localT) + ]; + } + } + + return [last.position.x, last.position.y, last.position.z]; +} + +export function useAnimationPlayback({ + viewer, + isModelLoaded = false, + steps, + selectedStepIndex, + onStepChange, + defaultStepDurationMs = 1500 +}: UseAnimationPlaybackOptions): AnimationPlaybackState { + const [isPlaying, setIsPlaying] = useState(false); + const [stepProgress, setStepProgress] = useState(0); + const [hiddenPartIds, setHiddenPartIds] = useState([]); + + const rafRef = useRef(null); + const startTimeRef = useRef(0); + const playingStepRef = useRef(selectedStepIndex); + const lastProgressRef = useRef(0); + + // ── Helpers ────────────────────────────────────────────────────────── + + /** Collect all partIds from steps[start..end). */ + const collectPartIds = useCallback( + (start: number, end: number) => { + const ids: string[] = []; + for (let i = start; i < end && i < steps.length; i++) { + ids.push(...steps[i].partIds); + } + return ids; + }, + [steps] + ); + + /** Reset every entity offset to [0,0,0]. */ + const resetAllOffsets = useCallback(() => { + for (const step of steps) { + for (const partId of step.partIds) { + const entity = getEntity(viewer, partId); + if (entity) entity.offset = [0, 0, 0]; + } + } + }, [viewer, steps]); + + /** Apply the "assembly so far" snapshot: steps 0..stepIdx complete, rest hidden. */ + const applySnapshot = useCallback( + (stepIdx: number) => { + resetAllOffsets(); + setHiddenPartIds(collectPartIds(stepIdx + 1, steps.length)); + setStepProgress(1); + }, + [resetAllOffsets, collectPartIds, steps.length] + ); + + /** + * Interpolate offsets for the active step. + * Only touches entity.offset (no React state changes) → safe inside RAF. + */ + const interpolateStep = useCallback( + (stepIdx: number, t: number) => { + const step = steps[stepIdx]; + if (!step) return; + + const keyframes = step.animationData?.keyframes; + + for (const partId of step.partIds) { + const entity = getEntity(viewer, partId); + if (!entity) continue; + + // Find this part's keyframes (there may be one partId or many) + const partKfs = keyframes?.filter((kf) => kf.partId === partId); + const offset = partKfs?.length + ? evaluatePartOffset(partKfs, t) + : [0, 0, 0]; + entity.offset = offset; + } + }, + [viewer, steps] + ); + + // ── RAF loop ───────────────────────────────────────────────────────── + + const tick = useCallback( + (timestamp: number) => { + if (!startTimeRef.current) startTimeRef.current = timestamp; + + const step = steps[playingStepRef.current]; + const duration = step?.duration || defaultStepDurationMs; + const elapsed = timestamp - startTimeRef.current; + const progress = Math.min(elapsed / duration, 1); + + // Apply interpolation (no React re-render) + interpolateStep(playingStepRef.current, progress); + + // Throttle React state update to ~20 fps to avoid excessive re-renders + if ( + Math.abs(progress - lastProgressRef.current) > 0.05 || + progress >= 1 + ) { + lastProgressRef.current = progress; + setStepProgress(progress); + } + + if (progress >= 1) { + // Snap completed step's parts to rest + if (step) { + for (const partId of step.partIds) { + const entity = getEntity(viewer, partId); + if (entity) entity.offset = [0, 0, 0]; + } + } + + // Advance to next step + if (playingStepRef.current < steps.length - 1) { + playingStepRef.current += 1; + startTimeRef.current = 0; // will be set on next tick + onStepChange(playingStepRef.current); + + // Reveal the next step's parts + setHiddenPartIds( + collectPartIds(playingStepRef.current + 1, steps.length) + ); + + rafRef.current = requestAnimationFrame(tick); + } else { + // All steps done + setIsPlaying(false); + setStepProgress(1); + rafRef.current = null; + } + } else { + rafRef.current = requestAnimationFrame(tick); + } + }, + [ + steps, + defaultStepDurationMs, + interpolateStep, + viewer, + onStepChange, + collectPartIds + ] + ); + + // ── Controls ───────────────────────────────────────────────────────── + + const play = useCallback(() => { + if (!viewer || steps.length === 0) return; + + // Wait for model to load + if (!isModelLoaded) { + console.warn("[ANIM] Cannot play - model not loaded yet"); + return; + } + + playingStepRef.current = selectedStepIndex; + startTimeRef.current = 0; + lastProgressRef.current = 0; + + // Hide future parts and reveal current step + setHiddenPartIds(collectPartIds(selectedStepIndex + 1, steps.length)); + + // Set current step's parts to their start offset + const step = steps[selectedStepIndex]; + if (step) { + const kfs = step.animationData?.keyframes; + for (const partId of step.partIds) { + const entity = getEntity(viewer, partId); + if (!entity) continue; + entity.visible = true; + const partKfs = kfs?.filter((kf) => kf.partId === partId) ?? []; + entity.offset = evaluatePartOffset(partKfs, 0); + } + } + + setIsPlaying(true); + rafRef.current = requestAnimationFrame(tick); + }, [viewer, isModelLoaded, steps, selectedStepIndex, collectPartIds, tick]); + + const pause = useCallback(() => { + setIsPlaying(false); + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }, []); + + const stop = useCallback(() => { + pause(); + resetAllOffsets(); + setStepProgress(0); + applySnapshot(selectedStepIndex); + }, [pause, resetAllOffsets, selectedStepIndex, applySnapshot]); + + // ── Sync with manual step navigation ───────────────────────────────── + + useEffect(() => { + // Only apply snapshot when viewer is ready AND has objects loaded + if ( + !isPlaying && + viewer?.scene?.objects && + Object.keys(viewer.scene.objects).length > 0 + ) { + applySnapshot(selectedStepIndex); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedStepIndex, viewer]); + + // ── Cleanup ────────────────────────────────────────────────────────── + + useEffect(() => { + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + }; + }, []); + + return { + isPlaying, + stepProgress, + hiddenPartIds, + play, + pause, + stop + }; +} diff --git a/apps/assembly/app/components/Viewer/useXeokit.ts b/apps/assembly/app/components/Viewer/useXeokit.ts new file mode 100644 index 0000000000..445897490c --- /dev/null +++ b/apps/assembly/app/components/Viewer/useXeokit.ts @@ -0,0 +1,245 @@ +import { useCallback, useRef, useState } from "react"; + +// Type-only import for xeokit Viewer +type Viewer = import("@xeokit/xeokit-sdk").Viewer; + +import type { CameraState, ViewerState } from "~/types/assembly.types"; +import { flyToEntity, flyToViewpoint, setViewPreset } from "./XeokitCanvas"; + +export interface UseXeokitOptions { + onPartSelected?: (partId: string | null, partName: string | null) => void; +} + +export function useXeokit(options: UseXeokitOptions = {}) { + const viewerRef = useRef(null); + const [isReady, setIsReady] = useState(false); + const [isModelLoaded, setIsModelLoaded] = useState(false); + const [selectedPartId, setSelectedPartId] = useState(null); + const [viewerState, setViewerState] = useState({ + selectedStepId: null, + highlightedPartIds: [], + hiddenPartIds: [], + explodedView: false, + explodeFactor: 1, + isPlaying: false, + playbackProgress: 0, + viewMode: "edit" + }); + + const handleViewerReady = useCallback((viewer: Viewer) => { + viewerRef.current = viewer; + setIsReady(true); + setIsModelLoaded(false); // Reset model loaded when viewer changes + }, []); + + const handleModelLoaded = useCallback(() => { + setIsModelLoaded(true); + console.log("[useXeokit] Model loaded, entities ready"); + }, []); + + const handlePartSelected = useCallback( + (partId: string | null, partName: string | null) => { + setSelectedPartId(partId); + options.onPartSelected?.(partId, partName); + }, + [options] + ); + + // Camera controls + const flyTo = useCallback((viewpoint: CameraState, duration = 0.5) => { + if (viewerRef.current) { + flyToViewpoint(viewerRef.current, viewpoint, duration); + } + }, []); + + const flyToObject = useCallback((entityId: string, duration = 0.5) => { + if (viewerRef.current) { + flyToEntity(viewerRef.current, entityId, duration); + } + }, []); + + const setView = useCallback( + ( + preset: "front" | "back" | "top" | "bottom" | "left" | "right" | "iso" + ) => { + if (viewerRef.current) { + setViewPreset(viewerRef.current, preset); + } + }, + [] + ); + + const fitToView = useCallback((duration = 0.5) => { + if (viewerRef.current) { + viewerRef.current.cameraFlight.flyTo({ + aabb: viewerRef.current.scene.aabb, + duration + }); + } + }, []); + + // Part visibility + const highlightParts = useCallback((partIds: string[]) => { + setViewerState((prev) => ({ + ...prev, + highlightedPartIds: partIds + })); + }, []); + + const hideParts = useCallback((partIds: string[]) => { + setViewerState((prev) => ({ + ...prev, + hiddenPartIds: partIds + })); + }, []); + + const showAllParts = useCallback(() => { + setViewerState((prev) => ({ + ...prev, + hiddenPartIds: [] + })); + }, []); + + const clearHighlights = useCallback(() => { + setViewerState((prev) => ({ + ...prev, + highlightedPartIds: [] + })); + }, []); + + // Exploded view + const setExplodedView = useCallback( + (enabled: boolean, factor: number = 1.5) => { + if (!viewerRef.current) return; + + setViewerState((prev) => ({ + ...prev, + explodedView: enabled, + explodeFactor: factor + })); + + // Note: xeokit doesn't have built-in exploded view + // This would need custom implementation to translate parts outward from center + // For now, this is a placeholder for the state + }, + [] + ); + + // Step navigation + const goToStep = useCallback( + ( + stepId: string, + partIds: string[], + cameraState?: CameraState, + duration = 0.5 + ) => { + setViewerState((prev) => ({ + ...prev, + selectedStepId: stepId, + highlightedPartIds: partIds + })); + + if (cameraState && viewerRef.current) { + flyToViewpoint(viewerRef.current, cameraState, duration); + } else if (partIds.length > 0 && viewerRef.current) { + // Fly to first part in the step + flyToEntity(viewerRef.current, partIds[0], duration); + } + }, + [] + ); + + // Playback controls + const play = useCallback(() => { + setViewerState((prev) => ({ + ...prev, + isPlaying: true + })); + }, []); + + const pause = useCallback(() => { + setViewerState((prev) => ({ + ...prev, + isPlaying: false + })); + }, []); + + const setPlaybackProgress = useCallback((progress: number) => { + setViewerState((prev) => ({ + ...prev, + playbackProgress: Math.max(0, Math.min(1, progress)) + })); + }, []); + + // View mode + const setViewMode = useCallback((mode: "edit" | "preview") => { + setViewerState((prev) => ({ + ...prev, + viewMode: mode + })); + }, []); + + // Get current camera state + const getCameraState = useCallback((): CameraState | null => { + if (!viewerRef.current) return null; + + const camera = viewerRef.current.camera; + return { + eye: { x: camera.eye[0], y: camera.eye[1], z: camera.eye[2] }, + center: { x: camera.look[0], y: camera.look[1], z: camera.look[2] }, + up: { x: camera.up[0], y: camera.up[1], z: camera.up[2] } + }; + }, []); + + // Screenshot + const takeScreenshot = useCallback((): string | null => { + if (!viewerRef.current) return null; + + const canvas = viewerRef.current.scene.canvas.canvas; + return canvas.toDataURL("image/png"); + }, []); + + return { + // Refs + viewer: viewerRef.current, + isReady, + isModelLoaded, + selectedPartId, + viewerState, + + // Event handlers for XeokitCanvas + handleViewerReady, + handleModelLoaded, + handlePartSelected, + + // Camera controls + flyTo, + flyToObject, + setView, + fitToView, + getCameraState, + + // Part visibility + highlightParts, + hideParts, + showAllParts, + clearHighlights, + + // Exploded view + setExplodedView, + + // Step navigation + goToStep, + + // Playback + play, + pause, + setPlaybackProgress, + + // View mode + setViewMode, + + // Utils + takeScreenshot + }; +} diff --git a/apps/assembly/app/components/WorkInstructions/CenterViewer/PlaybackControls.tsx b/apps/assembly/app/components/WorkInstructions/CenterViewer/PlaybackControls.tsx new file mode 100644 index 0000000000..0916ccb684 --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/CenterViewer/PlaybackControls.tsx @@ -0,0 +1,175 @@ +import { cn } from "@carbon/react"; +import { + BsPauseFill, + BsPlayFill, + BsSkipBackwardFill, + BsSkipEndFill, + BsSkipForwardFill, + BsSkipStartFill +} from "react-icons/bs"; +import type { AssemblyStep } from "~/types/assembly.types"; + +export interface PlaybackControlsProps { + steps: AssemblyStep[]; + currentStep?: AssemblyStep; + selectedStepIndex: number; + isPlaying: boolean; + onPlay: () => void; + onPause: () => void; + onSkipToStart: () => void; + onSkipToEnd: () => void; + onPrevious: () => void; + onNext: () => void; + onStepSelect: (index: number) => void; +} + +export function PlaybackControls({ + steps, + currentStep, + selectedStepIndex, + isPlaying, + onPlay, + onPause, + onSkipToStart, + onSkipToEnd, + onPrevious, + onNext, + onStepSelect +}: PlaybackControlsProps) { + const atStart = selectedStepIndex === 0; + const atEnd = selectedStepIndex === steps.length - 1; + const stepLabel = currentStep + ? currentStep.title || currentStep.partNames.join(", ") + : ""; + + return ( +
+ {/* Step info */} +
+ + {selectedStepIndex + 1}/{steps.length} + + {stepLabel && ( + + {stepLabel} + + )} +
+ + {/* Transport controls */} +
+ + + + + +
+ + {/* Timeline */} +
+ {/* Progress */} +
+ + {/* Step Markers */} +
+ {steps.map((_, index) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/assembly/app/components/WorkInstructions/CenterViewer/ViewerToolbar.tsx b/apps/assembly/app/components/WorkInstructions/CenterViewer/ViewerToolbar.tsx new file mode 100644 index 0000000000..456820daaa --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/CenterViewer/ViewerToolbar.tsx @@ -0,0 +1,161 @@ +import { cn } from "@carbon/react"; +import type { ReactNode } from "react"; +import { + BsArrowsAngleExpand, + BsChevronBarContract, + BsChevronBarExpand, + BsChevronBarUp, + BsDiamond, + BsFullscreen, + BsRulers, + BsScissors, + BsSquare, + BsSquareFill +} from "react-icons/bs"; + +export interface ViewerToolbarProps { + onFitToView: () => void; + onSetView: ( + preset: "front" | "back" | "top" | "bottom" | "left" | "right" | "iso" + ) => void; + onToggleExploded: () => void; + isExploded: boolean; + onToggleSectionPlane?: () => void; + isSectionPlaneActive?: boolean; + onToggleMeasure?: () => void; + isMeasureActive?: boolean; +} + +interface ToolbarButton { + id: string; + label: string; + icon: ReactNode; + onClick: () => void; + isActive?: boolean; +} + +export function ViewerToolbar({ + onFitToView, + onSetView, + onToggleExploded, + isExploded, + onToggleSectionPlane, + isSectionPlaneActive, + onToggleMeasure, + isMeasureActive +}: ViewerToolbarProps) { + const viewButtons: ToolbarButton[] = [ + { + id: "front", + label: "Front", + icon: , + onClick: () => onSetView("front") + }, + { + id: "back", + label: "Back", + icon: , + onClick: () => onSetView("back") + }, + { + id: "top", + label: "Top", + icon: , + onClick: () => onSetView("top") + }, + { + id: "left", + label: "Left", + icon: , + onClick: () => onSetView("left") + }, + { + id: "right", + label: "Right", + icon: , + onClick: () => onSetView("right") + }, + { + id: "iso", + label: "Iso", + icon: , + onClick: () => onSetView("iso") + } + ]; + + const toolButtons: ToolbarButton[] = [ + { id: "fit", label: "Fit", icon: , onClick: onFitToView }, + { + id: "explode", + label: "Explode", + icon: , + onClick: onToggleExploded, + isActive: isExploded + }, + ...(onToggleSectionPlane + ? [ + { + id: "section", + label: "Section", + icon: , + onClick: onToggleSectionPlane, + isActive: isSectionPlaneActive + } + ] + : []), + ...(onToggleMeasure + ? [ + { + id: "measure", + label: "Measure", + icon: , + onClick: onToggleMeasure, + isActive: isMeasureActive + } + ] + : []) + ]; + + return ( +
+
+ {/* View Presets */} + {viewButtons.map((button) => ( + + ))} + + {/* Divider */} +
+ + {/* Tools */} + {toolButtons.map((button) => ( + + ))} +
+
+ ); +} diff --git a/apps/assembly/app/components/WorkInstructions/ExportModal.tsx b/apps/assembly/app/components/WorkInstructions/ExportModal.tsx new file mode 100644 index 0000000000..a1a5e11bb5 --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/ExportModal.tsx @@ -0,0 +1,390 @@ +import { + Button, + Input, + Label, + Modal, + ModalBody, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@carbon/react"; +import { useState } from "react"; +import { + BsClipboard, + BsDownload, + BsEnvelope, + BsFilePdf, + BsLink45Deg, + BsPhone, + BsPlayCircle, + BsQrCode, + BsShare +} from "react-icons/bs"; +import { useFetcher } from "react-router"; + +interface ShareLink { + id: string; + token: string; + expiresAt: string | null; + password: string | null; + allowDownload: boolean; + createdAt: string; +} + +interface ExportModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectId: string; + shareLinks: ShareLink[]; +} + +type ExportTab = "video" | "pdf" | "share" | "mobile"; + +export function ExportModal({ + open, + onOpenChange, + projectId, + shareLinks +}: ExportModalProps) { + const [activeTab, setActiveTab] = useState("share"); + const [showShareForm, setShowShareForm] = useState(false); + const fetcher = useFetcher(); + const isSubmitting = fetcher.state !== "idle"; + + const baseUrl = + typeof window !== "undefined" + ? window.location.origin + : "https://assembly.carbon.ms"; + + const tabs: { id: ExportTab; label: string; icon: React.ReactNode }[] = [ + { + id: "share", + label: "Share", + icon: + }, + { + id: "video", + label: "Video", + icon: + }, + { + id: "pdf", + label: "PDF", + icon: + }, + { + id: "mobile", + label: "Mobile", + icon: + } + ]; + + const handleCreateShareLink = (formData: FormData) => { + formData.set("intent", "createShareLink"); + fetcher.submit(formData, { method: "post" }); + setShowShareForm(false); + }; + + const handleExportVideo = (formData: FormData) => { + formData.set("intent", "exportVideo"); + fetcher.submit(formData, { method: "post" }); + }; + + const handleExportPdf = () => { + const formData = new FormData(); + formData.set("intent", "exportPdf"); + fetcher.submit(formData, { method: "post" }); + }; + + return ( + + + + Export & Share + + Share your assembly instructions or export them in different + formats. + + + + + {/* Tab Navigation */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Share Tab */} + {activeTab === "share" && ( +
+

+ Create shareable links for operators to view instructions on any + device. +

+ + {/* Existing Links */} + {shareLinks.length > 0 && ( +
+ {shareLinks.map((link) => ( +
+
+ +
+ + {baseUrl}/share/{link.token.slice(0, 8)}... + +

+ {link.expiresAt + ? `Expires: ${new Date(link.expiresAt).toLocaleDateString()}` + : "No expiration"} + {link.password && " | Password protected"} +

+
+
+
+ + +
+
+ ))} +
+ )} + + {/* Create New Link */} + {showShareForm ? ( + setShowShareForm(false)} + isSubmitting={isSubmitting} + /> + ) : ( + + )} +
+ )} + + {/* Video Tab */} + {activeTab === "video" && ( +
+

+ Generate a video of the full assembly sequence with all + animations and annotations. +

+ +
+ )} + + {/* PDF Tab */} + {activeTab === "pdf" && ( +
+

+ Generate a printable PDF document with step-by-step instructions + and images. +

+ +
+ )} + + {/* Mobile Tab */} + {activeTab === "mobile" && ( +
+

+ Operators can view instructions on mobile devices using share + links. The viewer is optimized for touch screens. +

+
+
+ + Scan QR code on printed labels +
+
+ + Email links to team members +
+
+ + Responsive viewer for all screen sizes +
+
+
+ )} +
+ + + + +
+
+ ); +} + +function ShareLinkForm({ + onSubmit, + onCancel, + isSubmitting +}: { + onSubmit: (data: FormData) => void; + onCancel: () => void; + isSubmitting: boolean; +}) { + return ( +
{ + e.preventDefault(); + onSubmit(new FormData(e.currentTarget)); + }} + className="p-4 border rounded-lg space-y-4" + > +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ ); +} + +function VideoExportForm({ + onSubmit, + isSubmitting +}: { + onSubmit: (data: FormData) => void; + isSubmitting: boolean; +}) { + return ( +
{ + e.preventDefault(); + onSubmit(new FormData(e.currentTarget)); + }} + className="space-y-4" + > +
+
+ + +
+
+ + +
+
+ + +
+ ); +} diff --git a/apps/assembly/app/components/WorkInstructions/FloatingLeftSidebar.tsx b/apps/assembly/app/components/WorkInstructions/FloatingLeftSidebar.tsx new file mode 100644 index 0000000000..700b4599ec --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/FloatingLeftSidebar.tsx @@ -0,0 +1,138 @@ +import { cn } from "@carbon/react"; +import { useState } from "react"; +import { + BsChevronLeft, + BsChevronRight, + BsDiagram3, + BsListOl +} from "react-icons/bs"; +import type { AssemblyStep, AssemblyTreeNode } from "~/types/assembly.types"; +import { ComponentTree } from "./LeftPanel/ComponentTree"; +import { GeometriesList } from "./LeftPanel/GeometriesList"; +import { StepTree } from "./LeftPanel/StepTree"; + +export interface FloatingLeftSidebarProps { + steps: AssemblyStep[]; + assemblyTree: AssemblyTreeNode; + selectedStepIndex: number; + onStepSelect: (index: number) => void; + onStepsReorder?: (fromIndex: number, toIndex: number) => void; + onNodeSelect?: (nodeId: string) => void; + selectedNodeId?: string | null; +} + +export function FloatingLeftSidebar({ + steps, + assemblyTree, + selectedStepIndex, + onStepSelect, + onStepsReorder, + onNodeSelect, + selectedNodeId +}: FloatingLeftSidebarProps) { + const [isCollapsed, setIsCollapsed] = useState(false); + const [activeTab, setActiveTab] = useState<"model" | "instructions">( + "instructions" + ); + + const geometryCounts = countGeometries(assemblyTree); + + return ( +
+ {/* Sidebar panel */} +
+ {/* Tab Headers */} +
+ + +
+ + {/* Tab Content */} +
+ {activeTab === "model" ? ( + + ) : ( + + )} +
+ + {/* Geometries Section (always visible at bottom) */} + +
+ + {/* Toggle button — always visible */} + +
+ ); +} + +function countGeometries( + node: AssemblyTreeNode, + counts: Record = {} +): Record { + if (node.type === "part") { + const name = node.name || node.originalName; + counts[name] = (counts[name] || 0) + (node.quantity || 1); + } + + if (node.children) { + for (const child of node.children) { + countGeometries(child, counts); + } + } + + return counts; +} diff --git a/apps/assembly/app/components/WorkInstructions/LeftPanel/ComponentTree.tsx b/apps/assembly/app/components/WorkInstructions/LeftPanel/ComponentTree.tsx new file mode 100644 index 0000000000..04afbb51b6 --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/LeftPanel/ComponentTree.tsx @@ -0,0 +1,143 @@ +import { cn } from "@carbon/react"; +import { useEffect, useState } from "react"; +import { + BsBox, + BsChevronDown, + BsChevronRight, + BsCollection +} from "react-icons/bs"; +import type { AssemblyTreeNode } from "~/types/assembly.types"; + +/** Check if a node or any of its descendants has the given ID */ +function containsNodeId(node: AssemblyTreeNode, targetId: string): boolean { + if (node.id === targetId) return true; + if (node.children) { + for (const child of node.children) { + if (containsNodeId(child, targetId)) return true; + } + } + return false; +} + +export interface ComponentTreeProps { + tree: AssemblyTreeNode; + onNodeSelect?: (nodeId: string) => void; + selectedNodeId?: string | null; +} + +export function ComponentTree({ + tree, + onNodeSelect, + selectedNodeId +}: ComponentTreeProps) { + return ( +
+
+ +
+
+ ); +} + +interface ComponentTreeNodeProps { + node: AssemblyTreeNode; + depth: number; + onNodeSelect?: (nodeId: string) => void; + selectedNodeId?: string | null; +} + +function ComponentTreeNode({ + node, + depth, + onNodeSelect, + selectedNodeId +}: ComponentTreeNodeProps) { + const [isExpanded, setIsExpanded] = useState(depth < 2); // Auto-expand first 2 levels + const hasChildren = node.children && node.children.length > 0; + const isSelected = selectedNodeId === node.id; + // Show folder icon only for assemblies with multiple children + const showAsFolderIcon = + node.type === "assembly" && node.children && node.children.length > 1; + + // Auto-expand when a descendant is selected (e.g., from viewer click) + useEffect(() => { + if (selectedNodeId && hasChildren && !isSelected) { + // Check if selected node is a descendant of this node + const hasSelectedDescendant = node.children?.some((child) => + containsNodeId(child, selectedNodeId) + ); + if (hasSelectedDescendant) { + setIsExpanded(true); + } + } + }, [selectedNodeId, hasChildren, isSelected, node.children]); + + return ( +
+ + + {/* Children */} + {hasChildren && isExpanded && ( +
+ {node.children!.map((child) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/assembly/app/components/WorkInstructions/LeftPanel/GeometriesList.tsx b/apps/assembly/app/components/WorkInstructions/LeftPanel/GeometriesList.tsx new file mode 100644 index 0000000000..c0b1291df5 --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/LeftPanel/GeometriesList.tsx @@ -0,0 +1,61 @@ +import { cn } from "@carbon/react"; + +export interface GeometriesListProps { + geometries: Record; + onGeometrySelect?: (name: string) => void; + selectedGeometry?: string | null; +} + +export function GeometriesList({ + geometries, + onGeometrySelect, + selectedGeometry +}: GeometriesListProps) { + const sortedGeometries = Object.entries(geometries).sort( + ([, a], [, b]) => b - a + ); + const totalCount = sortedGeometries.reduce( + (sum, [, count]) => sum + count, + 0 + ); + + if (sortedGeometries.length === 0) { + return null; + } + + return ( +
+ {/* Header */} +
+ + Geometries + + + {sortedGeometries.length} components · {totalCount} total + +
+ + {/* Geometry list */} +
+ {sortedGeometries.map(([name, count]) => ( + + ))} +
+
+ ); +} diff --git a/apps/assembly/app/components/WorkInstructions/LeftPanel/StepTree.tsx b/apps/assembly/app/components/WorkInstructions/LeftPanel/StepTree.tsx new file mode 100644 index 0000000000..41e0ca0393 --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/LeftPanel/StepTree.tsx @@ -0,0 +1,238 @@ +import { cn } from "@carbon/react"; +import { useState } from "react"; +import { BsChevronDown, BsChevronRight } from "react-icons/bs"; +import type { AssemblyStep } from "~/types/assembly.types"; + +export interface StepTreeProps { + steps: AssemblyStep[]; + selectedStepIndex: number; + onStepSelect: (index: number) => void; + onStepsReorder?: (fromIndex: number, toIndex: number) => void; +} + +export function StepTree({ + steps, + selectedStepIndex, + onStepSelect, + onStepsReorder +}: StepTreeProps) { + // Group steps by their parent (for hierarchical display) + const groupedSteps = groupStepsByHierarchy(steps); + + return ( +
+
+ {groupedSteps.map((group, groupIndex) => ( + + ))} +
+
+ ); +} + +interface StepGroupData { + groupId: string | null; + groupLabel?: string; + steps: { step: AssemblyStep; index: number }[]; + children: StepGroupData[]; +} + +function StepGroup({ + group, + steps, + selectedStepIndex, + onStepSelect, + depth = 0 +}: { + group: StepGroupData; + steps: AssemblyStep[]; + selectedStepIndex: number; + onStepSelect: (index: number) => void; + depth?: number; +}) { + const [isExpanded, setIsExpanded] = useState(true); + const hasChildren = group.children.length > 0; + + return ( +
+ {/* Group header (if has label) */} + {group.groupLabel && ( + + )} + + {/* Steps in this group */} + {(isExpanded || !group.groupLabel) && ( + <> + {group.steps.map(({ step, index }) => ( + onStepSelect(index)} + depth={depth} + /> + ))} + + {/* Nested groups */} + {group.children.map((childGroup, childIndex) => ( + + ))} + + )} +
+ ); +} + +interface StepTreeItemProps { + step: AssemblyStep; + index: number; + isSelected: boolean; + onSelect: () => void; + depth: number; +} + +function StepTreeItem({ + step, + index, + isSelected, + onSelect, + depth +}: StepTreeItemProps) { + return ( + + ); +} + +// Helper to format part names +function formatPartNames(partNames: string[]): string { + if (partNames.length === 0) return ""; + if (partNames.length === 1) return partNames[0]; + if (partNames.length === 2) return partNames.join(", "); + return `${partNames[0]}, ${partNames[1]} +${partNames.length - 2} more`; +} + +// Helper to group steps into hierarchy +function groupStepsByHierarchy(steps: AssemblyStep[]): StepGroupData[] { + // For now, just create a flat list + // In a full implementation, this would parse stepNumber (1.1, 1.2.1, etc.) + // and create nested groups + + const rootGroup: StepGroupData = { + groupId: null, + steps: steps.map((step, index) => ({ step, index })), + children: [] + }; + + // Group by groupLabel if present + const groupedByLabel = new Map(); + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const label = step.groupLabel; + + if (label) { + if (!groupedByLabel.has(label)) { + groupedByLabel.set(label, { + groupId: label, + groupLabel: label, + steps: [], + children: [] + }); + } + groupedByLabel.get(label)!.steps.push({ step, index: i }); + } + } + + // If we have groups, return them + if (groupedByLabel.size > 0) { + // Include ungrouped steps first + const ungrouped = steps + .map((step, index) => ({ step, index })) + .filter(({ step }) => !step.groupLabel); + + const result: StepGroupData[] = []; + + if (ungrouped.length > 0) { + result.push({ + groupId: null, + steps: ungrouped, + children: [] + }); + } + + for (const group of groupedByLabel.values()) { + result.push(group); + } + + return result; + } + + // Otherwise return flat list + return [rootGroup]; +} diff --git a/apps/assembly/app/components/WorkInstructions/LeftPanel/index.tsx b/apps/assembly/app/components/WorkInstructions/LeftPanel/index.tsx new file mode 100644 index 0000000000..cb6775f45f --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/LeftPanel/index.tsx @@ -0,0 +1,99 @@ +import { cn } from "@carbon/react"; +import { BsDiagram3, BsListOl } from "react-icons/bs"; +import type { AssemblyStep, AssemblyTreeNode } from "~/types/assembly.types"; +import { ComponentTree } from "./ComponentTree"; +import { GeometriesList } from "./GeometriesList"; +import { StepTree } from "./StepTree"; + +export interface LeftPanelProps { + steps: AssemblyStep[]; + assemblyTree: AssemblyTreeNode; + selectedStepIndex: number; + onStepSelect: (index: number) => void; + activeTab: "model" | "instructions"; + onTabChange: (tab: "model" | "instructions") => void; + onStepsReorder?: (fromIndex: number, toIndex: number) => void; +} + +export function LeftPanel({ + steps, + assemblyTree, + selectedStepIndex, + onStepSelect, + activeTab, + onTabChange, + onStepsReorder +}: LeftPanelProps) { + // Count geometries from tree + const geometryCounts = countGeometries(assemblyTree); + + return ( +
+ {/* Tab Headers */} +
+ + +
+ + {/* Tab Content */} +
+ {activeTab === "model" ? ( + + ) : ( + + )} +
+ + {/* Geometries Section (always visible at bottom) */} + +
+ ); +} + +// Helper function to count part geometries +function countGeometries( + node: AssemblyTreeNode, + counts: Record = {} +): Record { + if (node.type === "part") { + const name = node.name || node.originalName; + counts[name] = (counts[name] || 0) + (node.quantity || 1); + } + + if (node.children) { + for (const child of node.children) { + countGeometries(child, counts); + } + } + + return counts; +} diff --git a/apps/assembly/app/components/WorkInstructions/RightPanel/MediaTab.tsx b/apps/assembly/app/components/WorkInstructions/RightPanel/MediaTab.tsx new file mode 100644 index 0000000000..cb04e4997e --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/RightPanel/MediaTab.tsx @@ -0,0 +1,172 @@ +import { cn } from "@carbon/react"; +import { useCallback, useRef, useState } from "react"; +import { BsCloudUpload, BsImage, BsX } from "react-icons/bs"; +import type { AssemblyStep, StepMedia } from "~/types/assembly.types"; + +export interface MediaTabProps { + step?: AssemblyStep; + onStepUpdate?: (field: keyof AssemblyStep, value: unknown) => void; + onUploadMedia?: (file: File) => Promise; +} + +export function MediaTab({ step, onStepUpdate, onUploadMedia }: MediaTabProps) { + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + + const mediaIds = step?.mediaIds || []; + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + + if (onUploadMedia) { + setIsUploading(true); + try { + for (const file of files) { + await onUploadMedia(file); + } + } finally { + setIsUploading(false); + } + } + }, + [onUploadMedia] + ); + + const handleFileSelect = useCallback( + async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + if (onUploadMedia) { + setIsUploading(true); + try { + for (const file of files) { + await onUploadMedia(file); + } + } finally { + setIsUploading(false); + } + } + }, + [onUploadMedia] + ); + + const handleRemoveMedia = useCallback( + (mediaId: string) => { + onStepUpdate?.( + "mediaIds", + mediaIds.filter((id) => id !== mediaId) + ); + }, + [mediaIds, onStepUpdate] + ); + + if (!step) { + return ( +
+ Select a step to manage media +
+ ); + } + + return ( +
+

+ Step Media ({mediaIds.length}) +

+ + {/* Upload Area */} +
fileInputRef.current?.click()} + className={cn( + "border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors mb-4", + isDragging + ? "border-primary bg-primary/10" + : "border-border hover:border-muted-foreground" + )} + > + + + {isUploading ? ( +
+
+
Uploading...
+
+ ) : ( + <> + +
+ Drop files here or click to upload +
+
+ Images, videos, or PDFs +
+ + )} +
+ + {/* Media Grid */} + {mediaIds.length > 0 ? ( +
+
+ {mediaIds.map((mediaId) => ( +
+ {/* Placeholder - in real implementation, fetch media details */} +
+ +
+ + {/* Remove button */} + + + {/* Media ID label */} +
+ {mediaId} +
+
+ ))} +
+
+ ) : ( +
+ No media attached to this step +
+ )} +
+ ); +} diff --git a/apps/assembly/app/components/WorkInstructions/RightPanel/NotesTab.tsx b/apps/assembly/app/components/WorkInstructions/RightPanel/NotesTab.tsx new file mode 100644 index 0000000000..e7a76295bf --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/RightPanel/NotesTab.tsx @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from "react"; +import type { AssemblyStep } from "~/types/assembly.types"; + +export interface NotesTabProps { + step?: AssemblyStep; + onStepUpdate?: (field: keyof AssemblyStep, value: unknown) => void; +} + +export function NotesTab({ step, onStepUpdate }: NotesTabProps) { + const [localNotes, setLocalNotes] = useState(step?.notes || ""); + + // Sync local state when step changes + useEffect(() => { + setLocalNotes(step?.notes || ""); + }, [step?.notes]); + + const handleNotesChange = useCallback((value: string) => { + setLocalNotes(value); + }, []); + + const handleNotesBlur = useCallback(() => { + onStepUpdate?.("notes", localNotes); + }, [localNotes, onStepUpdate]); + + if (!step) { + return ( +
+ Select a step to add notes +
+ ); + } + + return ( +
+

+ Step Notes +

+ +

+ Add specific notes for this assembly step. These will be shown to + operators during assembly. +

+ +