From ebb29a86e7cc1cf125383cada2cb82a835ed115a Mon Sep 17 00:00:00 2001 From: Gaurav Bhatt Date: Wed, 14 Jan 2026 22:36:20 +0530 Subject: [PATCH 01/25] -adeed initial changes --- CLAUDE.md | 12 + apps/erp/app/components/AssemblyMetadata.tsx | 240 ++++++++++++ apps/erp/app/components/CadModel.tsx | 6 +- apps/erp/app/components/index.ts | 2 + .../ui/Jobs/GenerateFromAssemblyModal.tsx | 109 ++++++ .../production/ui/Jobs/JobMakeMethodTools.tsx | 56 ++- .../app/modules/production/ui/Jobs/index.ts | 2 + .../routes/api+/job.generate-from-assembly.ts | 97 +++++ apps/erp/app/routes/api+/model.upload.ts | 11 + .../routes/x+/job+/$jobId.make.$methodId.tsx | 75 ++-- .../routes/x+/part+/$itemId.view.details.tsx | 19 +- .../x+/production+/scrap-reasons.new.tsx | 2 +- packages/database/src/swagger-docs-schema.ts | 352 +++++++++++++++++- packages/database/src/types.ts | 269 ++++--------- .../database/supabase/functions/lib/types.ts | 269 ++++--------- ...0120000_model-upload-assembly-metadata.sql | 16 + ...01_update-parts-view-assembly-metadata.sql | 82 ++++ ..._add-assembly-metadata-to-part-details.sql | 115 ++++++ ...60114120000_assembly-work-instructions.sql | 18 + ...0114120001_jobs-view-assembly-metadata.sql | 44 +++ .../jobs/trigger/assembly-to-operations.ts | 286 ++++++++++++++ packages/jobs/trigger/step-parser.ts | 287 ++++++++++++++ 22 files changed, 1931 insertions(+), 438 deletions(-) create mode 100644 apps/erp/app/components/AssemblyMetadata.tsx create mode 100644 apps/erp/app/modules/production/ui/Jobs/GenerateFromAssemblyModal.tsx create mode 100644 apps/erp/app/routes/api+/job.generate-from-assembly.ts create mode 100644 packages/database/supabase/migrations/20260110120000_model-upload-assembly-metadata.sql create mode 100644 packages/database/supabase/migrations/20260110120001_update-parts-view-assembly-metadata.sql create mode 100644 packages/database/supabase/migrations/20260110120002_add-assembly-metadata-to-part-details.sql create mode 100644 packages/database/supabase/migrations/20260114120000_assembly-work-instructions.sql create mode 100644 packages/database/supabase/migrations/20260114120001_jobs-view-assembly-metadata.sql create mode 100644 packages/jobs/trigger/assembly-to-operations.ts create mode 100644 packages/jobs/trigger/step-parser.ts 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/erp/app/components/AssemblyMetadata.tsx b/apps/erp/app/components/AssemblyMetadata.tsx new file mode 100644 index 0000000000..1ed29a1532 --- /dev/null +++ b/apps/erp/app/components/AssemblyMetadata.tsx @@ -0,0 +1,240 @@ +import { + Badge, + Card, + CardContent, + CardHeader, + CardTitle, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + cn, + Spinner +} from "@carbon/react"; +import { useState } from "react"; +import { + LuBox, + LuChevronRight, + LuCircleAlert, + LuPackage +} from "react-icons/lu"; + +/** + * Assembly node structure from parsed STEP file + */ +interface AssemblyNode { + id: string; + name: string; + partNumber?: string; + quantity: number; + children: AssemblyNode[]; +} + +interface AssemblyMetadataType { + isAssembly: boolean; + partCount: number; + hierarchy: AssemblyNode[]; + rootName?: string; +} + +type ParsingStatus = "pending" | "processing" | "completed" | "failed" | null; + +interface AssemblyMetadataProps { + parsingStatus: ParsingStatus; + assemblyMetadata: AssemblyMetadataType | null; + parsingError?: string | null; + className?: string; +} + +/** + * Recursive component to render assembly tree nodes + */ +function AssemblyTreeNode({ + node, + depth = 0 +}: { + node: AssemblyNode; + depth?: number; +}) { + const [isOpen, setIsOpen] = useState(depth < 2); // Auto-expand first 2 levels + const hasChildren = node.children.length > 0; + + return ( +
0 && "ml-4")}> + {hasChildren ? ( + + + + + {node.name} + {node.quantity > 1 && ( + + x{node.quantity} + + )} + + + {node.children.map((child, index) => ( + + ))} + + + ) : ( +
+ {/* Spacer for alignment */} + + {node.name} + {node.partNumber && ( + + {node.partNumber} + + )} + {node.quantity > 1 && ( + + x{node.quantity} + + )} +
+ )} +
+ ); +} + +/** + * Status badge for parsing status + */ +function ParsingStatusBadge({ status }: { status: ParsingStatus }) { + switch (status) { + case "pending": + return ( + + Pending + + ); + case "processing": + return ( + + + Parsing... + + ); + case "completed": + return ( + + Parsed + + ); + case "failed": + return ( + + + Failed + + ); + default: + return null; + } +} + +/** + * Panel to display assembly metadata from parsed STEP files + */ +export default function AssemblyMetadata({ + parsingStatus, + assemblyMetadata, + parsingError, + className +}: AssemblyMetadataProps) { + // Don't show anything if there's no parsing status (not a STEP file or not parsed) + if (!parsingStatus) { + return null; + } + + return ( + + +
+ + Assembly Structure + + +
+
+ + {parsingStatus === "pending" && ( +

+ Waiting to parse assembly structure... +

+ )} + + {parsingStatus === "processing" && ( +
+ + Analyzing STEP file... +
+ )} + + {parsingStatus === "failed" && ( +
+

Failed to parse assembly structure.

+ {parsingError && ( +

+ {parsingError} +

+ )} +
+ )} + + {parsingStatus === "completed" && assemblyMetadata && ( +
+
+
+ + Type: + + {assemblyMetadata.isAssembly ? "Assembly" : "Part"} + +
+
+ + Parts: + + {assemblyMetadata.partCount} + +
+
+ + {assemblyMetadata.isAssembly && + assemblyMetadata.hierarchy.length > 0 && ( +
+ {assemblyMetadata.hierarchy.map((node, index) => ( + + ))} +
+ )} + + {!assemblyMetadata.isAssembly && ( +

+ This is a single part, not an assembly. +

+ )} +
+ )} +
+
+ ); +} diff --git a/apps/erp/app/components/CadModel.tsx b/apps/erp/app/components/CadModel.tsx index ed44776d18..e7a9551dcb 100644 --- a/apps/erp/app/components/CadModel.tsx +++ b/apps/erp/app/components/CadModel.tsx @@ -37,6 +37,8 @@ type CadModelProps = { uploadClassName?: string; viewerClassName?: string; isReadOnly?: boolean; + /** When true, each part in the assembly gets a unique color */ + colorByPart?: boolean; }; const CadModel = ({ @@ -45,7 +47,8 @@ const CadModel = ({ modelPath, title, uploadClassName, - viewerClassName + viewerClassName, + colorByPart }: CadModelProps) => { const { company: { id: companyId } @@ -127,6 +130,7 @@ const CadModel = ({ url={modelPath ? getPrivateUrl(modelPath) : null} mode={mode} className={viewerClassName} + colorByPart={colorByPart} /> ) : ( void; + jobId: string; + modelUploadId: string; + assemblyMetadata: AssemblyMetadata; +} + +export function GenerateFromAssemblyModal({ + isOpen, + onClose, + jobId, + modelUploadId, + assemblyMetadata +}: GenerateFromAssemblyModalProps) { + const fetcher = useFetcher(); + const [processId, setProcessId] = useState(""); + + const isSubmitting = fetcher.state !== "idle"; + + const handleSubmit = () => { + if (!processId) { + toast.error("Please select a process"); + return; + } + + const formData = new FormData(); + formData.append("jobId", jobId); + formData.append("modelUploadId", modelUploadId); + formData.append("processId", processId); + + fetcher.submit(formData, { + method: "POST", + action: "/api/job/generate-from-assembly" + }); + + toast.success("Work instructions generation started"); + onClose(); + }; + + return ( + + + + Generate Work Instructions from Assembly + + + +
+ This will create job operations and steps based on the assembly + structure of the CAD model. +
+
+
+ Assembly:{" "} + {assemblyMetadata.rootName ?? "Unknown"} +
+
+ Total Parts: {assemblyMetadata.partCount} +
+
+ setProcessId(value?.value ?? "")} + /> +
+
+ + + + +
+
+ ); +} diff --git a/apps/erp/app/modules/production/ui/Jobs/JobMakeMethodTools.tsx b/apps/erp/app/modules/production/ui/Jobs/JobMakeMethodTools.tsx index 190a9f74b0..f42e6bb4c9 100644 --- a/apps/erp/app/modules/production/ui/Jobs/JobMakeMethodTools.tsx +++ b/apps/erp/app/modules/production/ui/Jobs/JobMakeMethodTools.tsx @@ -21,6 +21,9 @@ import { TabsContent, TabsList, TabsTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, toast, useDisclosure, useMount, @@ -32,6 +35,7 @@ import { LuGitBranch, LuGitFork, LuGitMerge, + LuListChecks, LuQrCode, LuSquareStack, LuTriangleAlert @@ -55,7 +59,29 @@ import { path } from "~/utils/path"; import { getJobMethodValidator } from "../../production.models"; import type { Job, JobMakeMethod, JobMethod } from "../../types"; -const JobMakeMethodTools = ({ makeMethod }: { makeMethod?: JobMakeMethod }) => { +interface AssemblyMetadata { + isAssembly: boolean; + partCount: number; + rootName?: string; +} + +interface ModelData { + id: string; + assemblyMetadata?: AssemblyMetadata | null; + parsingStatus?: string | null; +} + +interface JobMakeMethodToolsProps { + makeMethod?: JobMakeMethod; + model?: Promise | ModelData; + onGenerateFromAssembly?: () => void; +} + +const JobMakeMethodTools = ({ + makeMethod, + model, + onGenerateFromAssembly +}: JobMakeMethodToolsProps) => { const permissions = usePermissions(); const { jobId, methodId } = useParams(); if (!jobId) throw new Error("jobId not found"); @@ -306,6 +332,34 @@ const JobMakeMethodTools = ({ makeMethod }: { makeMethod?: JobMakeMethod }) => { Tracking Labels )} + {model && onGenerateFromAssembly && ( + + + {(resolvedModel) => + resolvedModel?.assemblyMetadata && + (resolvedModel.assemblyMetadata as AssemblyMetadata) + .isAssembly && + resolvedModel.parsingStatus === "completed" ? ( + + + } + isDisabled={isDisabled} + onClick={onGenerateFromAssembly} + > + Generate from Assembly + + + + Generate work instructions from the CAD assembly + structure + + + ) : null + } + + + )} diff --git a/apps/erp/app/modules/production/ui/Jobs/index.ts b/apps/erp/app/modules/production/ui/Jobs/index.ts index cf64685d92..b0a062486d 100644 --- a/apps/erp/app/modules/production/ui/Jobs/index.ts +++ b/apps/erp/app/modules/production/ui/Jobs/index.ts @@ -1,4 +1,5 @@ import { getDeadlineIcon } from "./Deadline"; +import { GenerateFromAssemblyModal } from "./GenerateFromAssemblyModal"; import JobBillOfMaterial from "./JobBillOfMaterial"; import JobBillOfProcess from "./JobBillOfProcess"; import JobBoMExplorer from "./JobBoMExplorer"; @@ -21,6 +22,7 @@ import ProductionQuantitiesTable from "./ProductionQuantitiesTable"; import ProductionQuantityForm from "./ProductionQuantityForm"; export { + GenerateFromAssemblyModal, getDeadlineIcon, JobBillOfMaterial, JobBillOfProcess, diff --git a/apps/erp/app/routes/api+/job.generate-from-assembly.ts b/apps/erp/app/routes/api+/job.generate-from-assembly.ts new file mode 100644 index 0000000000..561245333d --- /dev/null +++ b/apps/erp/app/routes/api+/job.generate-from-assembly.ts @@ -0,0 +1,97 @@ +import { requirePermissions } from "@carbon/auth/auth.server"; +import type { assemblyToOperationsTask } from "@carbon/jobs/trigger/assembly-to-operations"; +import { tasks } from "@trigger.dev/sdk"; +import { type ActionFunctionArgs, data } from "react-router"; + +export async function action({ request }: ActionFunctionArgs) { + const { client, companyId, userId } = await requirePermissions(request, { + update: "production" + }); + + const formData = await request.formData(); + const jobId = formData.get("jobId") as string; + const modelUploadId = formData.get("modelUploadId") as string; + const processId = formData.get("processId") as string; + + if (!jobId) { + return data({ error: "Job ID is required" }, { status: 400 }); + } + if (!modelUploadId) { + return data({ error: "Model upload ID is required" }, { status: 400 }); + } + if (!processId) { + return data({ error: "Process ID is required" }, { status: 400 }); + } + + // Verify the job exists and belongs to this company + const { data: job, error: jobError } = await client + .from("job") + .select("id, companyId") + .eq("id", jobId) + .eq("companyId", companyId) + .single(); + + if (jobError || !job) { + return data({ error: "Job not found" }, { status: 404 }); + } + + // Verify the model upload exists and has completed parsing + const { data: modelUpload, error: modelError } = await client + .from("modelUpload") + .select("id, parsingStatus, assemblyMetadata") + .eq("id", modelUploadId) + .single(); + + if (modelError || !modelUpload) { + return data({ error: "Model upload not found" }, { status: 404 }); + } + + if (modelUpload.parsingStatus !== "completed") { + return data( + { + error: `Model parsing not complete. Status: ${modelUpload.parsingStatus}` + }, + { status: 400 } + ); + } + + const assemblyMetadata = modelUpload.assemblyMetadata as { + isAssembly: boolean; + } | null; + + if (!assemblyMetadata?.isAssembly) { + return data( + { error: "Model upload does not contain assembly data" }, + { status: 400 } + ); + } + + // Verify the process exists + const { data: process, error: processError } = await client + .from("process") + .select("id") + .eq("id", processId) + .eq("companyId", companyId) + .single(); + + if (processError || !process) { + return data({ error: "Process not found" }, { status: 404 }); + } + + // Trigger the background job + await tasks.trigger( + "assembly-to-operations", + { + jobId, + modelUploadId, + processId, + companyId, + userId + } + ); + + return data({ + success: true, + message: "Work instructions generation started" + }); +} diff --git a/apps/erp/app/routes/api+/model.upload.ts b/apps/erp/app/routes/api+/model.upload.ts index f5664c5aab..55d7fd58c1 100644 --- a/apps/erp/app/routes/api+/model.upload.ts +++ b/apps/erp/app/routes/api+/model.upload.ts @@ -1,6 +1,7 @@ // import { error } from "@carbon/auth"; import { requirePermissions } from "@carbon/auth/auth.server"; import type { modelThumbnailTask } from "@carbon/jobs/trigger/model-thumbnail"; +import type { stepParserTask } from "@carbon/jobs/trigger/step-parser"; import { tasks } from "@trigger.dev/sdk"; import { type ActionFunctionArgs } from "react-router"; @@ -77,6 +78,16 @@ export async function action({ request }: ActionFunctionArgs) { modelId }); + // Trigger STEP parsing for STEP files + const extension = name.split(".").pop()?.toLowerCase(); + if (extension === "step" || extension === "stp") { + await tasks.trigger("step-parser", { + companyId, + modelId, + modelPath + }); + } + return { success: true }; diff --git a/apps/erp/app/routes/x+/job+/$jobId.make.$methodId.tsx b/apps/erp/app/routes/x+/job+/$jobId.make.$methodId.tsx index 95b253f4c8..c5b0c1e364 100644 --- a/apps/erp/app/routes/x+/job+/$jobId.make.$methodId.tsx +++ b/apps/erp/app/routes/x+/job+/$jobId.make.$methodId.tsx @@ -2,7 +2,7 @@ import { error } from "@carbon/auth"; import { requirePermissions } from "@carbon/auth/auth.server"; import { flash } from "@carbon/auth/session.server"; import type { JSONContent } from "@carbon/react"; -import { Spinner, useMount, VStack } from "@carbon/react"; +import { Spinner, useDisclosure, useMount, VStack } from "@carbon/react"; import { Suspense } from "react"; import type { LoaderFunctionArgs } from "react-router"; import { Await, redirect, useLoaderData, useParams } from "react-router"; @@ -19,13 +19,14 @@ import { getProductionDataByOperations } from "~/modules/production"; import { + GenerateFromAssemblyModal, JobBillOfMaterial, JobBillOfProcess, JobDocuments, JobEstimatesVsActuals } from "~/modules/production/ui/Jobs"; import JobMakeMethodTools from "~/modules/production/ui/Jobs/JobMakeMethodTools"; -import { getModelByItemId, getTagsList } from "~/modules/shared"; +import { getTagsList } from "~/modules/shared"; import { path } from "~/utils/path"; export async function loader({ request, params }: LoaderFunctionArgs) { @@ -109,11 +110,27 @@ export async function loader({ request, params }: LoaderFunctionArgs) { operations?.data?.map((o) => o.id) ), files: getPartDocuments(client, companyId, makeMethod.data), - model: getModelByItemId(client, makeMethod.data.itemId!), + // Model data comes from the jobs view which already coalesces job + item modelUploadId + model: { + itemId: job.data.itemId, + type: job.data.itemType, + id: job.data.modelId, + modelPath: job.data.modelPath, + assemblyMetadata: job.data.assemblyMetadata, + parsingStatus: job.data.parsingStatus, + parsedAt: job.data.parsedAt, + parsingError: job.data.parsingError + }, tags: tags.data ?? [] }; } +interface AssemblyMetadata { + isAssembly: boolean; + partCount: number; + rootName?: string; +} + export default function JobMakeMethodRoute() { const permissions = usePermissions(); const { methodId, jobId } = useParams(); @@ -132,6 +149,7 @@ export default function JobMakeMethodRoute() { } = loaderData; const { setIsExplorerCollapsed, isExplorerCollapsed } = usePanels(); + const generateModal = useDisclosure(); useMount(() => { if (isExplorerCollapsed) { @@ -141,7 +159,11 @@ export default function JobMakeMethodRoute() { return ( - + )} - - - {(model) => ( - - )} - - + + {loaderData.model?.assemblyMetadata && + (loaderData.model.assemblyMetadata as AssemblyMetadata).isAssembly && + loaderData.model.parsingStatus === "completed" && ( + + )} ); } diff --git a/apps/erp/app/routes/x+/part+/$itemId.view.details.tsx b/apps/erp/app/routes/x+/part+/$itemId.view.details.tsx index ab65b827ce..2cf6567f89 100644 --- a/apps/erp/app/routes/x+/part+/$itemId.view.details.tsx +++ b/apps/erp/app/routes/x+/part+/$itemId.view.details.tsx @@ -7,7 +7,7 @@ import { Spinner, VStack } from "@carbon/react"; import { Suspense } from "react"; import type { ActionFunctionArgs } from "react-router"; import { Await, redirect, useParams } from "react-router"; -import { CadModel } from "~/components"; +import { AssemblyMetadata, CadModel } from "~/components"; import { usePermissions, useRouteData } from "~/hooks"; import type { ItemFile, PartSummary } from "~/modules/items"; import { partValidator, upsertPart } from "~/modules/items"; @@ -100,7 +100,24 @@ export default function PartDetailsRoute() { metadata={{ itemId }} modelPath={partData?.partSummary?.modelPath ?? null} title="CAD Model" + colorByPart={ + partData?.partSummary?.parsingStatus === "completed" && + !!(partData?.partSummary?.assemblyMetadata as any)?.isAssembly + } /> + {partData?.partSummary?.parsingStatus && ( + + )} )} diff --git a/apps/erp/app/routes/x+/production+/scrap-reasons.new.tsx b/apps/erp/app/routes/x+/production+/scrap-reasons.new.tsx index 3116996a81..62abf931af 100644 --- a/apps/erp/app/routes/x+/production+/scrap-reasons.new.tsx +++ b/apps/erp/app/routes/x+/production+/scrap-reasons.new.tsx @@ -3,7 +3,7 @@ import { requirePermissions } from "@carbon/auth/auth.server"; import { flash } from "@carbon/auth/session.server"; import { validationError, validator } from "@carbon/form"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; -import { json, redirect, useNavigate } from "react-router"; +import { redirect, useNavigate } from "react-router"; import { scrapReasonValidator, upsertScrapReason } from "~/modules/production"; import ScrapReasonForm from "~/modules/production/ui/ScrapReasons/ScrapReasonForm"; import { setCustomFields } from "~/utils/form"; diff --git a/packages/database/src/swagger-docs-schema.ts b/packages/database/src/swagger-docs-schema.ts index bbb499b68a..65e9e365b5 100644 --- a/packages/database/src/swagger-docs-schema.ts +++ b/packages/database/src/swagger-docs-schema.ts @@ -11892,6 +11892,9 @@ export default { { $ref: "#/parameters/rowFilter.jobs.shelfId", }, + { + $ref: "#/parameters/rowFilter.jobs.priority", + }, { $ref: "#/parameters/rowFilter.jobs.jobMakeMethodId", }, @@ -11934,6 +11937,18 @@ export default { { $ref: "#/parameters/rowFilter.jobs.modelSize", }, + { + $ref: "#/parameters/rowFilter.jobs.assemblyMetadata", + }, + { + $ref: "#/parameters/rowFilter.jobs.parsingStatus", + }, + { + $ref: "#/parameters/rowFilter.jobs.parsedAt", + }, + { + $ref: "#/parameters/rowFilter.jobs.parsingError", + }, { $ref: "#/parameters/rowFilter.jobs.salesOrderReadableId", }, @@ -13218,6 +13233,9 @@ export default { { $ref: "#/parameters/rowFilter.configurationParameter.updatedBy", }, + { + $ref: "#/parameters/rowFilter.configurationParameter.materialFormFilterId", + }, { $ref: "#/parameters/select", }, @@ -13316,6 +13334,9 @@ export default { { $ref: "#/parameters/rowFilter.configurationParameter.updatedBy", }, + { + $ref: "#/parameters/rowFilter.configurationParameter.materialFormFilterId", + }, { $ref: "#/parameters/preferReturn", }, @@ -13368,6 +13389,9 @@ export default { { $ref: "#/parameters/rowFilter.configurationParameter.updatedBy", }, + { + $ref: "#/parameters/rowFilter.configurationParameter.materialFormFilterId", + }, { $ref: "#/parameters/body.configurationParameter", }, @@ -28212,6 +28236,9 @@ export default { { $ref: "#/parameters/rowFilter.jobOperation.conflictReason", }, + { + $ref: "#/parameters/rowFilter.jobOperation.modelUploadId", + }, { $ref: "#/parameters/select", }, @@ -28397,6 +28424,9 @@ export default { { $ref: "#/parameters/rowFilter.jobOperation.conflictReason", }, + { + $ref: "#/parameters/rowFilter.jobOperation.modelUploadId", + }, { $ref: "#/parameters/preferReturn", }, @@ -28536,6 +28566,9 @@ export default { { $ref: "#/parameters/rowFilter.jobOperation.conflictReason", }, + { + $ref: "#/parameters/rowFilter.jobOperation.modelUploadId", + }, { $ref: "#/parameters/body.jobOperation", }, @@ -34146,6 +34179,15 @@ export default { { $ref: "#/parameters/rowFilter.jobOperationStep.nonConformanceInvestigationId", }, + { + $ref: "#/parameters/rowFilter.jobOperationStep.assemblyNodeId", + }, + { + $ref: "#/parameters/rowFilter.jobOperationStep.assemblyNodeName", + }, + { + $ref: "#/parameters/rowFilter.jobOperationStep.assemblyNodeQuantity", + }, { $ref: "#/parameters/select", }, @@ -34262,6 +34304,15 @@ export default { { $ref: "#/parameters/rowFilter.jobOperationStep.nonConformanceInvestigationId", }, + { + $ref: "#/parameters/rowFilter.jobOperationStep.assemblyNodeId", + }, + { + $ref: "#/parameters/rowFilter.jobOperationStep.assemblyNodeName", + }, + { + $ref: "#/parameters/rowFilter.jobOperationStep.assemblyNodeQuantity", + }, { $ref: "#/parameters/preferReturn", }, @@ -34332,6 +34383,15 @@ export default { { $ref: "#/parameters/rowFilter.jobOperationStep.nonConformanceInvestigationId", }, + { + $ref: "#/parameters/rowFilter.jobOperationStep.assemblyNodeId", + }, + { + $ref: "#/parameters/rowFilter.jobOperationStep.assemblyNodeName", + }, + { + $ref: "#/parameters/rowFilter.jobOperationStep.assemblyNodeQuantity", + }, { $ref: "#/parameters/body.jobOperationStep", }, @@ -39219,6 +39279,18 @@ export default { { $ref: "#/parameters/rowFilter.parts.modelSize", }, + { + $ref: "#/parameters/rowFilter.parts.assemblyMetadata", + }, + { + $ref: "#/parameters/rowFilter.parts.parsingStatus", + }, + { + $ref: "#/parameters/rowFilter.parts.parsedAt", + }, + { + $ref: "#/parameters/rowFilter.parts.parsingError", + }, { $ref: "#/parameters/rowFilter.parts.supplierIds", }, @@ -39234,9 +39306,6 @@ export default { { $ref: "#/parameters/rowFilter.parts.tags", }, - { - $ref: "#/parameters/rowFilter.parts.itemPostingGroupId", - }, { $ref: "#/parameters/rowFilter.parts.createdBy", }, @@ -39864,6 +39933,18 @@ export default { { $ref: "#/parameters/rowFilter.modelUpload.thumbnailPath", }, + { + $ref: "#/parameters/rowFilter.modelUpload.assemblyMetadata", + }, + { + $ref: "#/parameters/rowFilter.modelUpload.parsingStatus", + }, + { + $ref: "#/parameters/rowFilter.modelUpload.parsedAt", + }, + { + $ref: "#/parameters/rowFilter.modelUpload.parsingError", + }, { $ref: "#/parameters/select", }, @@ -39956,6 +40037,18 @@ export default { { $ref: "#/parameters/rowFilter.modelUpload.thumbnailPath", }, + { + $ref: "#/parameters/rowFilter.modelUpload.assemblyMetadata", + }, + { + $ref: "#/parameters/rowFilter.modelUpload.parsingStatus", + }, + { + $ref: "#/parameters/rowFilter.modelUpload.parsedAt", + }, + { + $ref: "#/parameters/rowFilter.modelUpload.parsingError", + }, { $ref: "#/parameters/preferReturn", }, @@ -40002,6 +40095,18 @@ export default { { $ref: "#/parameters/rowFilter.modelUpload.thumbnailPath", }, + { + $ref: "#/parameters/rowFilter.modelUpload.assemblyMetadata", + }, + { + $ref: "#/parameters/rowFilter.modelUpload.parsingStatus", + }, + { + $ref: "#/parameters/rowFilter.modelUpload.parsedAt", + }, + { + $ref: "#/parameters/rowFilter.modelUpload.parsingError", + }, { $ref: "#/parameters/body.modelUpload", }, @@ -63066,6 +63171,41 @@ export default { tags: ["(rpc) xid_encode"], }, }, + "/rpc/populate_sales_search_results": { + post: { + parameters: [ + { + in: "body", + name: "args", + required: true, + schema: { + properties: { + p_company_id: { + format: "text", + type: "string", + }, + }, + required: ["p_company_id"], + type: "object", + }, + }, + { + $ref: "#/parameters/preferParams", + }, + ], + produces: [ + "application/json", + "application/vnd.pgrst.object+json;nulls=stripped", + "application/vnd.pgrst.object+json", + ], + responses: { + "200": { + description: "OK", + }, + }, + tags: ["(rpc) populate_sales_search_results"], + }, + }, "/rpc/get_direct_descendants_of_tracked_entity": { post: { parameters: [ @@ -70505,6 +70645,10 @@ export default { format: "text", type: "string", }, + priority: { + format: "double precision", + type: "number", + }, jobMakeMethodId: { description: "Note:\nThis is a Primary Key.", format: "text", @@ -70573,6 +70717,21 @@ export default { format: "bigint", type: "integer", }, + assemblyMetadata: { + format: "jsonb", + }, + parsingStatus: { + format: "text", + type: "string", + }, + parsedAt: { + format: "timestamp with time zone", + type: "string", + }, + parsingError: { + format: "text", + type: "string", + }, salesOrderReadableId: { format: "text", type: "string", @@ -71267,6 +71426,12 @@ export default { format: "text", type: "string", }, + materialFormFilterId: { + description: + "Note:\nThis is a Foreign Key to `materialForm.id`.", + format: "text", + type: "string", + }, }, type: "object", }, @@ -78160,6 +78325,12 @@ export default { format: "text", type: "string", }, + modelUploadId: { + description: + "Note:\nThis is a Foreign Key to `modelUpload.id`.", + format: "text", + type: "string", + }, }, type: "object", }, @@ -80886,6 +81057,18 @@ export default { format: "text", type: "string", }, + assemblyNodeId: { + format: "text", + type: "string", + }, + assemblyNodeName: { + format: "text", + type: "string", + }, + assemblyNodeQuantity: { + format: "integer", + type: "integer", + }, }, type: "object", }, @@ -83186,6 +83369,21 @@ export default { format: "bigint", type: "integer", }, + assemblyMetadata: { + format: "jsonb", + }, + parsingStatus: { + format: "text", + type: "string", + }, + parsedAt: { + format: "timestamp with time zone", + type: "string", + }, + parsingError: { + format: "text", + type: "string", + }, supplierIds: { format: "text", type: "string", @@ -83207,12 +83405,6 @@ export default { }, type: "array", }, - itemPostingGroupId: { - description: - "Note:\nThis is a Foreign Key to `itemPostingGroup.id`.", - format: "text", - type: "string", - }, createdBy: { description: "Note:\nThis is a Foreign Key to `user.id`.", @@ -83598,6 +83790,28 @@ export default { format: "text", type: "string", }, + assemblyMetadata: { + description: + "Parsed CAD assembly structure: hierarchy, parts, quantities, transforms", + format: "jsonb", + }, + parsingStatus: { + default: "pending", + description: + "Status of CAD parsing: pending, processing, completed, failed", + format: "text", + type: "string", + }, + parsedAt: { + description: "Timestamp when parsing completed", + format: "timestamp with time zone", + type: "string", + }, + parsingError: { + description: "Error message if parsing failed", + format: "text", + type: "string", + }, }, type: "object", }, @@ -100139,6 +100353,12 @@ export default { in: "query", type: "string", }, + "rowFilter.jobs.priority": { + name: "priority", + required: false, + in: "query", + type: "string", + }, "rowFilter.jobs.jobMakeMethodId": { name: "jobMakeMethodId", required: false, @@ -100223,6 +100443,30 @@ export default { in: "query", type: "string", }, + "rowFilter.jobs.assemblyMetadata": { + name: "assemblyMetadata", + required: false, + in: "query", + type: "string", + }, + "rowFilter.jobs.parsingStatus": { + name: "parsingStatus", + required: false, + in: "query", + type: "string", + }, + "rowFilter.jobs.parsedAt": { + name: "parsedAt", + required: false, + in: "query", + type: "string", + }, + "rowFilter.jobs.parsingError": { + name: "parsingError", + required: false, + in: "query", + type: "string", + }, "rowFilter.jobs.salesOrderReadableId": { name: "salesOrderReadableId", required: false, @@ -100991,6 +101235,12 @@ export default { in: "query", type: "string", }, + "rowFilter.configurationParameter.materialFormFilterId": { + name: "materialFormFilterId", + required: false, + in: "query", + type: "string", + }, "body.nonConformanceReceiptLine": { name: "nonConformanceReceiptLine", description: "nonConformanceReceiptLine", @@ -108633,6 +108883,12 @@ export default { in: "query", type: "string", }, + "rowFilter.jobOperation.modelUploadId": { + name: "modelUploadId", + required: false, + in: "query", + type: "string", + }, "body.userAttributeCategory": { name: "userAttributeCategory", description: "userAttributeCategory", @@ -111819,6 +112075,24 @@ export default { in: "query", type: "string", }, + "rowFilter.jobOperationStep.assemblyNodeId": { + name: "assemblyNodeId", + required: false, + in: "query", + type: "string", + }, + "rowFilter.jobOperationStep.assemblyNodeName": { + name: "assemblyNodeName", + required: false, + in: "query", + type: "string", + }, + "rowFilter.jobOperationStep.assemblyNodeQuantity": { + name: "assemblyNodeQuantity", + required: false, + in: "query", + type: "string", + }, "body.receiptLine": { name: "receiptLine", description: "receiptLine", @@ -114424,6 +114698,30 @@ export default { in: "query", type: "string", }, + "rowFilter.parts.assemblyMetadata": { + name: "assemblyMetadata", + required: false, + in: "query", + type: "string", + }, + "rowFilter.parts.parsingStatus": { + name: "parsingStatus", + required: false, + in: "query", + type: "string", + }, + "rowFilter.parts.parsedAt": { + name: "parsedAt", + required: false, + in: "query", + type: "string", + }, + "rowFilter.parts.parsingError": { + name: "parsingError", + required: false, + in: "query", + type: "string", + }, "rowFilter.parts.supplierIds": { name: "supplierIds", required: false, @@ -114454,12 +114752,6 @@ export default { in: "query", type: "string", }, - "rowFilter.parts.itemPostingGroupId": { - name: "itemPostingGroupId", - required: false, - in: "query", - type: "string", - }, "rowFilter.parts.createdBy": { name: "createdBy", required: false, @@ -114946,6 +115238,36 @@ export default { in: "query", type: "string", }, + "rowFilter.modelUpload.assemblyMetadata": { + name: "assemblyMetadata", + description: + "Parsed CAD assembly structure: hierarchy, parts, quantities, transforms", + required: false, + in: "query", + type: "string", + }, + "rowFilter.modelUpload.parsingStatus": { + name: "parsingStatus", + description: + "Status of CAD parsing: pending, processing, completed, failed", + required: false, + in: "query", + type: "string", + }, + "rowFilter.modelUpload.parsedAt": { + name: "parsedAt", + description: "Timestamp when parsing completed", + required: false, + in: "query", + type: "string", + }, + "rowFilter.modelUpload.parsingError": { + name: "parsingError", + description: "Error message if parsing failed", + required: false, + in: "query", + type: "string", + }, "body.costLedger": { name: "costLedger", description: "costLedger", diff --git a/packages/database/src/types.ts b/packages/database/src/types.ts index 30e2023b66..9eb7f9f61c 100644 --- a/packages/database/src/types.ts +++ b/packages/database/src/types.ts @@ -10280,7 +10280,6 @@ export type Database = { } jobMaterial: { Row: { - bomId: string | null companyId: string createdAt: string createdBy: string @@ -10310,7 +10309,6 @@ export type Database = { updatedBy: string | null } Insert: { - bomId?: string | null companyId: string createdAt?: string createdBy: string @@ -10340,7 +10338,6 @@ export type Database = { updatedBy?: string | null } Update: { - bomId?: string | null companyId?: string createdAt?: string createdBy?: string @@ -10602,6 +10599,7 @@ export type Database = { machineRate: number | null machineTime: number machineUnit: Database["public"]["Enums"]["factor"] + modelUploadId: string | null operationLeadTime: number operationMinimumCost: number operationOrder: Database["public"]["Enums"]["methodOperationOrder"] @@ -10646,6 +10644,7 @@ export type Database = { machineRate?: number | null machineTime?: number machineUnit?: Database["public"]["Enums"]["factor"] + modelUploadId?: string | null operationLeadTime?: number operationMinimumCost?: number operationOrder?: Database["public"]["Enums"]["methodOperationOrder"] @@ -10690,6 +10689,7 @@ export type Database = { machineRate?: number | null machineTime?: number machineUnit?: Database["public"]["Enums"]["factor"] + modelUploadId?: string | null operationLeadTime?: number operationMinimumCost?: number operationOrder?: Database["public"]["Enums"]["methodOperationOrder"] @@ -10856,6 +10856,27 @@ export type Database = { referencedRelation: "jobs" referencedColumns: ["jobMakeMethodId"] }, + { + foreignKeyName: "jobOperation_modelUploadId_fkey" + columns: ["modelUploadId"] + isOneToOne: false + referencedRelation: "jobs" + referencedColumns: ["modelId"] + }, + { + foreignKeyName: "jobOperation_modelUploadId_fkey" + columns: ["modelUploadId"] + isOneToOne: false + referencedRelation: "modelUpload" + referencedColumns: ["id"] + }, + { + foreignKeyName: "jobOperation_modelUploadId_fkey" + columns: ["modelUploadId"] + isOneToOne: false + referencedRelation: "salesRfqLines" + referencedColumns: ["modelId"] + }, { foreignKeyName: "jobOperation_procedureId_fkey" columns: ["procedureId"] @@ -11337,6 +11358,9 @@ export type Database = { } jobOperationStep: { Row: { + assemblyNodeId: string | null + assemblyNodeName: string | null + assemblyNodeQuantity: number | null companyId: string createdAt: string createdBy: string @@ -11358,6 +11382,9 @@ export type Database = { updatedBy: string | null } Insert: { + assemblyNodeId?: string | null + assemblyNodeName?: string | null + assemblyNodeQuantity?: number | null companyId: string createdAt?: string createdBy: string @@ -11379,6 +11406,9 @@ export type Database = { updatedBy?: string | null } Update: { + assemblyNodeId?: string | null + assemblyNodeName?: string | null + assemblyNodeQuantity?: number | null companyId?: string createdAt?: string createdBy?: string @@ -15188,7 +15218,6 @@ export type Database = { } methodMaterial: { Row: { - bomId: string | null companyId: string createdAt: string createdBy: string @@ -15212,7 +15241,6 @@ export type Database = { updatedBy: string | null } Insert: { - bomId?: string | null companyId: string createdAt?: string createdBy: string @@ -15236,7 +15264,6 @@ export type Database = { updatedBy?: string | null } Update: { - bomId?: string | null companyId?: string createdAt?: string createdBy?: string @@ -16183,6 +16210,7 @@ export type Database = { } modelUpload: { Row: { + assemblyMetadata: Json | null autodeskUrn: string | null companyId: string createdAt: string | null @@ -16190,12 +16218,16 @@ export type Database = { id: string modelPath: string name: string | null + parsedAt: string | null + parsingError: string | null + parsingStatus: string | null size: number | null thumbnailPath: string | null updatedAt: string | null updatedBy: string | null } Insert: { + assemblyMetadata?: Json | null autodeskUrn?: string | null companyId: string createdAt?: string | null @@ -16203,12 +16235,16 @@ export type Database = { id?: string modelPath: string name?: string | null + parsedAt?: string | null + parsingError?: string | null + parsingStatus?: string | null size?: number | null thumbnailPath?: string | null updatedAt?: string | null updatedBy?: string | null } Update: { + assemblyMetadata?: Json | null autodeskUrn?: string | null companyId?: string createdAt?: string | null @@ -16216,6 +16252,9 @@ export type Database = { id?: string modelPath?: string name?: string | null + parsedAt?: string | null + parsingError?: string | null + parsingStatus?: string | null size?: number | null thumbnailPath?: string | null updatedAt?: string | null @@ -19907,21 +19946,21 @@ export type Database = { opportunity: { Row: { companyId: string - customerId: string + customerId: string | null id: string purchaseOrderDocumentPath: string | null requestForQuoteDocumentPath: string | null } Insert: { companyId: string - customerId: string + customerId?: string | null id?: string purchaseOrderDocumentPath?: string | null requestForQuoteDocumentPath?: string | null } Update: { companyId?: string - customerId?: string + customerId?: string | null id?: string purchaseOrderDocumentPath?: string | null requestForQuoteDocumentPath?: string | null @@ -26495,7 +26534,6 @@ export type Database = { } quoteMaterial: { Row: { - bomId: string | null companyId: string createdAt: string createdBy: string @@ -26522,7 +26560,6 @@ export type Database = { updatedBy: string | null } Insert: { - bomId?: string | null companyId: string createdAt?: string createdBy: string @@ -26549,7 +26586,6 @@ export type Database = { updatedBy?: string | null } Update: { - bomId?: string | null companyId?: string createdAt?: string createdBy?: string @@ -31334,174 +31370,6 @@ export type Database = { }, ] } - searchIndex_BJiGdDNuetJ1iyE8USN7AD: { - Row: { - createdAt: string - description: string | null - entityId: string - entityType: string - id: number - link: string - metadata: Json | null - searchVector: unknown - tags: string[] | null - title: string - updatedAt: string | null - } - Insert: { - createdAt?: string - description?: string | null - entityId: string - entityType: string - id?: number - link: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title: string - updatedAt?: string | null - } - Update: { - createdAt?: string - description?: string | null - entityId?: string - entityType?: string - id?: number - link?: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title?: string - updatedAt?: string | null - } - Relationships: [] - } - searchIndex_L4saDKMCpFQurK9c3bEr1G: { - Row: { - createdAt: string - description: string | null - entityId: string - entityType: string - id: number - link: string - metadata: Json | null - searchVector: unknown - tags: string[] | null - title: string - updatedAt: string | null - } - Insert: { - createdAt?: string - description?: string | null - entityId: string - entityType: string - id?: number - link: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title: string - updatedAt?: string | null - } - Update: { - createdAt?: string - description?: string | null - entityId?: string - entityType?: string - id?: number - link?: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title?: string - updatedAt?: string | null - } - Relationships: [] - } - searchIndex_Nrc78GJXti5gro8G5m3k9u: { - Row: { - createdAt: string - description: string | null - entityId: string - entityType: string - id: number - link: string - metadata: Json | null - searchVector: unknown - tags: string[] | null - title: string - updatedAt: string | null - } - Insert: { - createdAt?: string - description?: string | null - entityId: string - entityType: string - id?: number - link: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title: string - updatedAt?: string | null - } - Update: { - createdAt?: string - description?: string | null - entityId?: string - entityType?: string - id?: number - link?: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title?: string - updatedAt?: string | null - } - Relationships: [] - } - searchIndex_TzPMV5bvte7aRhGwLwmjs9: { - Row: { - createdAt: string - description: string | null - entityId: string - entityType: string - id: number - link: string - metadata: Json | null - searchVector: unknown - tags: string[] | null - title: string - updatedAt: string | null - } - Insert: { - createdAt?: string - description?: string | null - entityId: string - entityType: string - id?: number - link: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title: string - updatedAt?: string | null - } - Update: { - createdAt?: string - description?: string | null - entityId?: string - entityType?: string - id?: number - link?: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title?: string - updatedAt?: string | null - } - Relationships: [] - } searchIndexRegistry: { Row: { companyId: string @@ -42916,7 +42784,6 @@ export type Database = { } jobMaterialWithMakeMethodId: { Row: { - bomId: string | null companyId: string | null createdAt: string | null createdBy: string | null @@ -42943,6 +42810,7 @@ export type Database = { requiresSerialTracking: boolean | null scrapQuantity: number | null shelfId: string | null + shelfName: string | null unitCost: number | null unitOfMeasureCode: string | null updatedAt: string | null @@ -43814,6 +43682,7 @@ export type Database = { Row: { active: boolean | null actualTime: number | null + assemblyMetadata: Json | null assignee: string | null autodeskUrn: string | null companyId: string | null @@ -43844,6 +43713,10 @@ export type Database = { modelUploadId: string | null name: string | null notes: Json | null + parsedAt: string | null + parsingError: string | null + parsingStatus: string | null + priority: number | null productionQuantity: number | null quantity: number | null quantityComplete: number | null @@ -45548,14 +45421,14 @@ export type Database = { }, { foreignKeyName: "partner_id_fkey" - columns: ["id"] + columns: ["supplierLocationId"] isOneToOne: false referencedRelation: "supplierLocation" referencedColumns: ["id"] }, { foreignKeyName: "partner_id_fkey" - columns: ["supplierLocationId"] + columns: ["id"] isOneToOne: false referencedRelation: "supplierLocation" referencedColumns: ["id"] @@ -45600,6 +45473,7 @@ export type Database = { parts: { Row: { active: boolean | null + assemblyMetadata: Json | null assignee: string | null companyId: string | null createdAt: string | null @@ -45608,7 +45482,6 @@ export type Database = { defaultMethodType: Database["public"]["Enums"]["methodType"] | null description: string | null id: string | null - itemPostingGroupId: string | null itemTrackingType: | Database["public"]["Enums"]["itemTrackingType"] | null @@ -45617,6 +45490,9 @@ export type Database = { modelSize: number | null name: string | null notes: Json | null + parsedAt: string | null + parsingError: string | null + parsingStatus: string | null readableId: string | null readableIdWithRevision: string | null replenishmentSystem: @@ -45773,13 +45649,6 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, - { - foreignKeyName: "itemCost_itemPostingGroupId_fkey" - columns: ["itemPostingGroupId"] - isOneToOne: false - referencedRelation: "itemPostingGroup" - referencedColumns: ["id"] - }, ] } procedures: { @@ -48154,7 +48023,6 @@ export type Database = { } quoteMaterialWithMakeMethodId: { Row: { - bomId: string | null companyId: string | null createdAt: string | null createdBy: string | null @@ -50163,14 +50031,14 @@ export type Database = { Relationships: [ { foreignKeyName: "address_countryCode_fkey" - columns: ["customerCountryCode"] + columns: ["paymentCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] }, { foreignKeyName: "address_countryCode_fkey" - columns: ["paymentCountryCode"] + columns: ["customerCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] @@ -53373,7 +53241,6 @@ export type Database = { get_job_method: { Args: { jid: string } Returns: { - bomId: string description: string isRoot: boolean itemId: string @@ -53397,7 +53264,6 @@ export type Database = { get_job_methods_by_method_id: { Args: { mid: string } Returns: { - bomId: string description: string isRoot: boolean itemId: string @@ -53695,7 +53561,6 @@ export type Database = { get_method_tree: { Args: { uid: string } Returns: { - bomId: string description: string externalId: Json isRoot: boolean @@ -53757,6 +53622,7 @@ export type Database = { Args: { item_id: string } Returns: { active: boolean + assemblyMetadata: Json assignee: string companyId: string createdAt: string @@ -53773,6 +53639,9 @@ export type Database = { modelSize: number name: string notes: Json + parsedAt: string + parsingError: string + parsingStatus: string readableId: string readableIdWithRevision: string replenishmentSystem: Database["public"]["Enums"]["itemReplenishmentSystem"] @@ -54033,7 +53902,6 @@ export type Database = { get_quote_methods: { Args: { qid: string } Returns: { - bomId: string description: string externalId: Json isRoot: boolean @@ -54059,7 +53927,6 @@ export type Database = { get_quote_methods_by_method_id: { Args: { mid: string } Returns: { - bomId: string description: string externalId: Json isRoot: boolean @@ -54415,6 +54282,10 @@ export type Database = { Args: { p_company_id: string } Returns: undefined } + populate_sales_search_results: { + Args: { p_company_id: string } + Returns: undefined + } search_company_index: { Args: { p_company_id: string diff --git a/packages/database/supabase/functions/lib/types.ts b/packages/database/supabase/functions/lib/types.ts index 30e2023b66..9eb7f9f61c 100644 --- a/packages/database/supabase/functions/lib/types.ts +++ b/packages/database/supabase/functions/lib/types.ts @@ -10280,7 +10280,6 @@ export type Database = { } jobMaterial: { Row: { - bomId: string | null companyId: string createdAt: string createdBy: string @@ -10310,7 +10309,6 @@ export type Database = { updatedBy: string | null } Insert: { - bomId?: string | null companyId: string createdAt?: string createdBy: string @@ -10340,7 +10338,6 @@ export type Database = { updatedBy?: string | null } Update: { - bomId?: string | null companyId?: string createdAt?: string createdBy?: string @@ -10602,6 +10599,7 @@ export type Database = { machineRate: number | null machineTime: number machineUnit: Database["public"]["Enums"]["factor"] + modelUploadId: string | null operationLeadTime: number operationMinimumCost: number operationOrder: Database["public"]["Enums"]["methodOperationOrder"] @@ -10646,6 +10644,7 @@ export type Database = { machineRate?: number | null machineTime?: number machineUnit?: Database["public"]["Enums"]["factor"] + modelUploadId?: string | null operationLeadTime?: number operationMinimumCost?: number operationOrder?: Database["public"]["Enums"]["methodOperationOrder"] @@ -10690,6 +10689,7 @@ export type Database = { machineRate?: number | null machineTime?: number machineUnit?: Database["public"]["Enums"]["factor"] + modelUploadId?: string | null operationLeadTime?: number operationMinimumCost?: number operationOrder?: Database["public"]["Enums"]["methodOperationOrder"] @@ -10856,6 +10856,27 @@ export type Database = { referencedRelation: "jobs" referencedColumns: ["jobMakeMethodId"] }, + { + foreignKeyName: "jobOperation_modelUploadId_fkey" + columns: ["modelUploadId"] + isOneToOne: false + referencedRelation: "jobs" + referencedColumns: ["modelId"] + }, + { + foreignKeyName: "jobOperation_modelUploadId_fkey" + columns: ["modelUploadId"] + isOneToOne: false + referencedRelation: "modelUpload" + referencedColumns: ["id"] + }, + { + foreignKeyName: "jobOperation_modelUploadId_fkey" + columns: ["modelUploadId"] + isOneToOne: false + referencedRelation: "salesRfqLines" + referencedColumns: ["modelId"] + }, { foreignKeyName: "jobOperation_procedureId_fkey" columns: ["procedureId"] @@ -11337,6 +11358,9 @@ export type Database = { } jobOperationStep: { Row: { + assemblyNodeId: string | null + assemblyNodeName: string | null + assemblyNodeQuantity: number | null companyId: string createdAt: string createdBy: string @@ -11358,6 +11382,9 @@ export type Database = { updatedBy: string | null } Insert: { + assemblyNodeId?: string | null + assemblyNodeName?: string | null + assemblyNodeQuantity?: number | null companyId: string createdAt?: string createdBy: string @@ -11379,6 +11406,9 @@ export type Database = { updatedBy?: string | null } Update: { + assemblyNodeId?: string | null + assemblyNodeName?: string | null + assemblyNodeQuantity?: number | null companyId?: string createdAt?: string createdBy?: string @@ -15188,7 +15218,6 @@ export type Database = { } methodMaterial: { Row: { - bomId: string | null companyId: string createdAt: string createdBy: string @@ -15212,7 +15241,6 @@ export type Database = { updatedBy: string | null } Insert: { - bomId?: string | null companyId: string createdAt?: string createdBy: string @@ -15236,7 +15264,6 @@ export type Database = { updatedBy?: string | null } Update: { - bomId?: string | null companyId?: string createdAt?: string createdBy?: string @@ -16183,6 +16210,7 @@ export type Database = { } modelUpload: { Row: { + assemblyMetadata: Json | null autodeskUrn: string | null companyId: string createdAt: string | null @@ -16190,12 +16218,16 @@ export type Database = { id: string modelPath: string name: string | null + parsedAt: string | null + parsingError: string | null + parsingStatus: string | null size: number | null thumbnailPath: string | null updatedAt: string | null updatedBy: string | null } Insert: { + assemblyMetadata?: Json | null autodeskUrn?: string | null companyId: string createdAt?: string | null @@ -16203,12 +16235,16 @@ export type Database = { id?: string modelPath: string name?: string | null + parsedAt?: string | null + parsingError?: string | null + parsingStatus?: string | null size?: number | null thumbnailPath?: string | null updatedAt?: string | null updatedBy?: string | null } Update: { + assemblyMetadata?: Json | null autodeskUrn?: string | null companyId?: string createdAt?: string | null @@ -16216,6 +16252,9 @@ export type Database = { id?: string modelPath?: string name?: string | null + parsedAt?: string | null + parsingError?: string | null + parsingStatus?: string | null size?: number | null thumbnailPath?: string | null updatedAt?: string | null @@ -19907,21 +19946,21 @@ export type Database = { opportunity: { Row: { companyId: string - customerId: string + customerId: string | null id: string purchaseOrderDocumentPath: string | null requestForQuoteDocumentPath: string | null } Insert: { companyId: string - customerId: string + customerId?: string | null id?: string purchaseOrderDocumentPath?: string | null requestForQuoteDocumentPath?: string | null } Update: { companyId?: string - customerId?: string + customerId?: string | null id?: string purchaseOrderDocumentPath?: string | null requestForQuoteDocumentPath?: string | null @@ -26495,7 +26534,6 @@ export type Database = { } quoteMaterial: { Row: { - bomId: string | null companyId: string createdAt: string createdBy: string @@ -26522,7 +26560,6 @@ export type Database = { updatedBy: string | null } Insert: { - bomId?: string | null companyId: string createdAt?: string createdBy: string @@ -26549,7 +26586,6 @@ export type Database = { updatedBy?: string | null } Update: { - bomId?: string | null companyId?: string createdAt?: string createdBy?: string @@ -31334,174 +31370,6 @@ export type Database = { }, ] } - searchIndex_BJiGdDNuetJ1iyE8USN7AD: { - Row: { - createdAt: string - description: string | null - entityId: string - entityType: string - id: number - link: string - metadata: Json | null - searchVector: unknown - tags: string[] | null - title: string - updatedAt: string | null - } - Insert: { - createdAt?: string - description?: string | null - entityId: string - entityType: string - id?: number - link: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title: string - updatedAt?: string | null - } - Update: { - createdAt?: string - description?: string | null - entityId?: string - entityType?: string - id?: number - link?: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title?: string - updatedAt?: string | null - } - Relationships: [] - } - searchIndex_L4saDKMCpFQurK9c3bEr1G: { - Row: { - createdAt: string - description: string | null - entityId: string - entityType: string - id: number - link: string - metadata: Json | null - searchVector: unknown - tags: string[] | null - title: string - updatedAt: string | null - } - Insert: { - createdAt?: string - description?: string | null - entityId: string - entityType: string - id?: number - link: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title: string - updatedAt?: string | null - } - Update: { - createdAt?: string - description?: string | null - entityId?: string - entityType?: string - id?: number - link?: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title?: string - updatedAt?: string | null - } - Relationships: [] - } - searchIndex_Nrc78GJXti5gro8G5m3k9u: { - Row: { - createdAt: string - description: string | null - entityId: string - entityType: string - id: number - link: string - metadata: Json | null - searchVector: unknown - tags: string[] | null - title: string - updatedAt: string | null - } - Insert: { - createdAt?: string - description?: string | null - entityId: string - entityType: string - id?: number - link: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title: string - updatedAt?: string | null - } - Update: { - createdAt?: string - description?: string | null - entityId?: string - entityType?: string - id?: number - link?: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title?: string - updatedAt?: string | null - } - Relationships: [] - } - searchIndex_TzPMV5bvte7aRhGwLwmjs9: { - Row: { - createdAt: string - description: string | null - entityId: string - entityType: string - id: number - link: string - metadata: Json | null - searchVector: unknown - tags: string[] | null - title: string - updatedAt: string | null - } - Insert: { - createdAt?: string - description?: string | null - entityId: string - entityType: string - id?: number - link: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title: string - updatedAt?: string | null - } - Update: { - createdAt?: string - description?: string | null - entityId?: string - entityType?: string - id?: number - link?: string - metadata?: Json | null - searchVector?: unknown - tags?: string[] | null - title?: string - updatedAt?: string | null - } - Relationships: [] - } searchIndexRegistry: { Row: { companyId: string @@ -42916,7 +42784,6 @@ export type Database = { } jobMaterialWithMakeMethodId: { Row: { - bomId: string | null companyId: string | null createdAt: string | null createdBy: string | null @@ -42943,6 +42810,7 @@ export type Database = { requiresSerialTracking: boolean | null scrapQuantity: number | null shelfId: string | null + shelfName: string | null unitCost: number | null unitOfMeasureCode: string | null updatedAt: string | null @@ -43814,6 +43682,7 @@ export type Database = { Row: { active: boolean | null actualTime: number | null + assemblyMetadata: Json | null assignee: string | null autodeskUrn: string | null companyId: string | null @@ -43844,6 +43713,10 @@ export type Database = { modelUploadId: string | null name: string | null notes: Json | null + parsedAt: string | null + parsingError: string | null + parsingStatus: string | null + priority: number | null productionQuantity: number | null quantity: number | null quantityComplete: number | null @@ -45548,14 +45421,14 @@ export type Database = { }, { foreignKeyName: "partner_id_fkey" - columns: ["id"] + columns: ["supplierLocationId"] isOneToOne: false referencedRelation: "supplierLocation" referencedColumns: ["id"] }, { foreignKeyName: "partner_id_fkey" - columns: ["supplierLocationId"] + columns: ["id"] isOneToOne: false referencedRelation: "supplierLocation" referencedColumns: ["id"] @@ -45600,6 +45473,7 @@ export type Database = { parts: { Row: { active: boolean | null + assemblyMetadata: Json | null assignee: string | null companyId: string | null createdAt: string | null @@ -45608,7 +45482,6 @@ export type Database = { defaultMethodType: Database["public"]["Enums"]["methodType"] | null description: string | null id: string | null - itemPostingGroupId: string | null itemTrackingType: | Database["public"]["Enums"]["itemTrackingType"] | null @@ -45617,6 +45490,9 @@ export type Database = { modelSize: number | null name: string | null notes: Json | null + parsedAt: string | null + parsingError: string | null + parsingStatus: string | null readableId: string | null readableIdWithRevision: string | null replenishmentSystem: @@ -45773,13 +45649,6 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, - { - foreignKeyName: "itemCost_itemPostingGroupId_fkey" - columns: ["itemPostingGroupId"] - isOneToOne: false - referencedRelation: "itemPostingGroup" - referencedColumns: ["id"] - }, ] } procedures: { @@ -48154,7 +48023,6 @@ export type Database = { } quoteMaterialWithMakeMethodId: { Row: { - bomId: string | null companyId: string | null createdAt: string | null createdBy: string | null @@ -50163,14 +50031,14 @@ export type Database = { Relationships: [ { foreignKeyName: "address_countryCode_fkey" - columns: ["customerCountryCode"] + columns: ["paymentCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] }, { foreignKeyName: "address_countryCode_fkey" - columns: ["paymentCountryCode"] + columns: ["customerCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] @@ -53373,7 +53241,6 @@ export type Database = { get_job_method: { Args: { jid: string } Returns: { - bomId: string description: string isRoot: boolean itemId: string @@ -53397,7 +53264,6 @@ export type Database = { get_job_methods_by_method_id: { Args: { mid: string } Returns: { - bomId: string description: string isRoot: boolean itemId: string @@ -53695,7 +53561,6 @@ export type Database = { get_method_tree: { Args: { uid: string } Returns: { - bomId: string description: string externalId: Json isRoot: boolean @@ -53757,6 +53622,7 @@ export type Database = { Args: { item_id: string } Returns: { active: boolean + assemblyMetadata: Json assignee: string companyId: string createdAt: string @@ -53773,6 +53639,9 @@ export type Database = { modelSize: number name: string notes: Json + parsedAt: string + parsingError: string + parsingStatus: string readableId: string readableIdWithRevision: string replenishmentSystem: Database["public"]["Enums"]["itemReplenishmentSystem"] @@ -54033,7 +53902,6 @@ export type Database = { get_quote_methods: { Args: { qid: string } Returns: { - bomId: string description: string externalId: Json isRoot: boolean @@ -54059,7 +53927,6 @@ export type Database = { get_quote_methods_by_method_id: { Args: { mid: string } Returns: { - bomId: string description: string externalId: Json isRoot: boolean @@ -54415,6 +54282,10 @@ export type Database = { Args: { p_company_id: string } Returns: undefined } + populate_sales_search_results: { + Args: { p_company_id: string } + Returns: undefined + } search_company_index: { Args: { p_company_id: string diff --git a/packages/database/supabase/migrations/20260110120000_model-upload-assembly-metadata.sql b/packages/database/supabase/migrations/20260110120000_model-upload-assembly-metadata.sql new file mode 100644 index 0000000000..2ce4523deb --- /dev/null +++ b/packages/database/supabase/migrations/20260110120000_model-upload-assembly-metadata.sql @@ -0,0 +1,16 @@ +-- Add assembly metadata columns to modelUpload for CAD parsing + +ALTER TABLE "modelUpload" + ADD COLUMN "assemblyMetadata" JSONB, + ADD COLUMN "parsingStatus" TEXT DEFAULT 'pending', + ADD COLUMN "parsedAt" TIMESTAMP WITH TIME ZONE, + ADD COLUMN "parsingError" TEXT; + +-- Create index for querying by parsing status +CREATE INDEX "modelUpload_parsingStatus_idx" ON "modelUpload" ("parsingStatus") + WHERE "parsingStatus" IS NOT NULL; + +COMMENT ON COLUMN "modelUpload"."assemblyMetadata" IS 'Parsed CAD assembly structure: hierarchy, parts, quantities, transforms'; +COMMENT ON COLUMN "modelUpload"."parsingStatus" IS 'Status of CAD parsing: pending, processing, completed, failed'; +COMMENT ON COLUMN "modelUpload"."parsedAt" IS 'Timestamp when parsing completed'; +COMMENT ON COLUMN "modelUpload"."parsingError" IS 'Error message if parsing failed'; diff --git a/packages/database/supabase/migrations/20260110120001_update-parts-view-assembly-metadata.sql b/packages/database/supabase/migrations/20260110120001_update-parts-view-assembly-metadata.sql new file mode 100644 index 0000000000..56e572e144 --- /dev/null +++ b/packages/database/supabase/migrations/20260110120001_update-parts-view-assembly-metadata.sql @@ -0,0 +1,82 @@ +-- Update parts view to include assembly metadata from modelUpload + +DROP VIEW IF EXISTS "parts"; +CREATE OR REPLACE VIEW "parts" WITH (SECURITY_INVOKER=true) AS +WITH latest_items AS ( + SELECT DISTINCT ON (i."readableId") + i.*, + mu.id as "modelUploadId", + mu."modelPath", + mu."thumbnailPath" as "modelThumbnailPath", + mu."name" as "modelName", + mu."size" as "modelSize", + mu."assemblyMetadata", + mu."parsingStatus", + mu."parsedAt", + mu."parsingError" + FROM "item" i + LEFT JOIN "modelUpload" mu ON mu.id = i."modelUploadId" + ORDER BY i."readableId", i."createdAt" DESC NULLS LAST +), +item_revisions AS ( + SELECT + i."readableId", + json_agg( + json_build_object( + 'id', i.id, + 'revision', i."revision", + 'name', i."name", + 'description', i."description", + 'active', i."active", + 'createdAt', i."createdAt" + ) ORDER BY i."createdAt" + ) as "revisions" + FROM "item" i + GROUP BY i."readableId" +) +SELECT + li."active", + li."assignee", + li."defaultMethodType", + li."description", + li."itemTrackingType", + li."name", + li."replenishmentSystem", + li."unitOfMeasureCode", + li."notes", + li."revision", + li."readableId", + li."readableIdWithRevision", + li."id", + li."companyId", + CASE + WHEN li."thumbnailPath" IS NULL AND li."modelThumbnailPath" IS NOT NULL THEN li."modelThumbnailPath" + ELSE li."thumbnailPath" + END as "thumbnailPath", + li."modelPath", + li."modelName", + li."modelSize", + li."assemblyMetadata", + li."parsingStatus", + li."parsedAt", + li."parsingError", + ps."supplierIds", + uom.name as "unitOfMeasure", + ir."revisions", + p."customFields", + p."tags", + li."createdBy", + li."createdAt", + li."updatedBy", + li."updatedAt" +FROM "part" p +INNER JOIN latest_items li ON li."readableId" = p."id" +LEFT JOIN item_revisions ir ON ir."readableId" = p."id" +LEFT JOIN ( + SELECT + "itemId", + string_agg(ps."supplierPartId", ',') AS "supplierIds" + FROM "supplierPart" ps + GROUP BY "itemId" +) ps ON ps."itemId" = li."id" +LEFT JOIN "unitOfMeasure" uom ON uom.code = li."unitOfMeasureCode" AND uom."companyId" = li."companyId"; diff --git a/packages/database/supabase/migrations/20260110120002_add-assembly-metadata-to-part-details.sql b/packages/database/supabase/migrations/20260110120002_add-assembly-metadata-to-part-details.sql new file mode 100644 index 0000000000..d2cfa06e0d --- /dev/null +++ b/packages/database/supabase/migrations/20260110120002_add-assembly-metadata-to-part-details.sql @@ -0,0 +1,115 @@ +-- Add assembly metadata fields to get_part_details function + +DROP FUNCTION IF EXISTS get_part_details; +CREATE OR REPLACE FUNCTION get_part_details(item_id TEXT) +RETURNS TABLE ( + "active" BOOLEAN, + "assignee" TEXT, + "defaultMethodType" "methodType", + "description" TEXT, + "itemTrackingType" "itemTrackingType", + "name" TEXT, + "replenishmentSystem" "itemReplenishmentSystem", + "unitOfMeasureCode" TEXT, + "notes" JSONB, + "thumbnailPath" TEXT, + "modelId" TEXT, + "modelPath" TEXT, + "modelName" TEXT, + "modelSize" BIGINT, + "assemblyMetadata" JSONB, + "parsingStatus" TEXT, + "parsedAt" TIMESTAMP WITH TIME ZONE, + "parsingError" TEXT, + "id" TEXT, + "companyId" TEXT, + "unitOfMeasure" TEXT, + "readableId" TEXT, + "revision" TEXT, + "readableIdWithRevision" TEXT, + "revisions" JSON, + "customFields" JSONB, + "tags" TEXT[], + "itemPostingGroupId" TEXT, + "createdBy" TEXT, + "createdAt" TIMESTAMP WITH TIME ZONE, + "updatedBy" TEXT, + "updatedAt" TIMESTAMP WITH TIME ZONE +) AS $$ +DECLARE + v_readable_id TEXT; + v_company_id TEXT; +BEGIN + -- First get the readableId and companyId for the item + SELECT i."readableId", i."companyId" INTO v_readable_id, v_company_id + FROM "item" i + WHERE i.id = item_id; + + RETURN QUERY + WITH item_revisions AS ( + SELECT + json_agg( + json_build_object( + 'id', i.id, + 'revision', i."revision", + 'methodType', i."defaultMethodType", + 'type', i."type" + ) ORDER BY + i."createdAt" DESC + ) as "revisions" + FROM "item" i + WHERE i."readableId" = v_readable_id + AND i."companyId" = v_company_id + ) + SELECT + i."active", + i."assignee", + i."defaultMethodType", + i."description", + i."itemTrackingType", + i."name", + i."replenishmentSystem", + i."unitOfMeasureCode", + i."notes", + CASE + WHEN i."thumbnailPath" IS NULL AND mu."thumbnailPath" IS NOT NULL THEN mu."thumbnailPath" + ELSE i."thumbnailPath" + END as "thumbnailPath", + mu.id as "modelId", + mu."modelPath", + mu."name" as "modelName", + mu."size" as "modelSize", + mu."assemblyMetadata", + mu."parsingStatus", + mu."parsedAt", + mu."parsingError", + i."id", + i."companyId", + uom.name as "unitOfMeasure", + i."readableId", + i."revision", + i."readableIdWithRevision", + ir."revisions", + p."customFields", + p."tags", + ic."itemPostingGroupId", + i."createdBy", + i."createdAt", + i."updatedBy", + i."updatedAt" + FROM "part" p + LEFT JOIN "item" i ON i."readableId" = p."id" AND i."companyId" = p."companyId" + LEFT JOIN item_revisions ir ON true + LEFT JOIN ( + SELECT + ps."itemId", + string_agg(ps."supplierPartId", ',') AS "supplierIds" + FROM "supplierPart" ps + GROUP BY ps."itemId" + ) ps ON ps."itemId" = i.id + LEFT JOIN "modelUpload" mu ON mu.id = i."modelUploadId" + LEFT JOIN "unitOfMeasure" uom ON uom.code = i."unitOfMeasureCode" AND uom."companyId" = i."companyId" + LEFT JOIN "itemCost" ic ON ic."itemId" = i.id + WHERE i."id" = item_id; +END; +$$ LANGUAGE plpgsql; diff --git a/packages/database/supabase/migrations/20260114120000_assembly-work-instructions.sql b/packages/database/supabase/migrations/20260114120000_assembly-work-instructions.sql new file mode 100644 index 0000000000..9d227178bb --- /dev/null +++ b/packages/database/supabase/migrations/20260114120000_assembly-work-instructions.sql @@ -0,0 +1,18 @@ +-- Add columns to link job operations and steps to assembly metadata + +-- Add modelUploadId to jobOperation to link operations to CAD files +ALTER TABLE "jobOperation" ADD COLUMN "modelUploadId" TEXT; +ALTER TABLE "jobOperation" ADD CONSTRAINT "jobOperation_modelUploadId_fkey" + FOREIGN KEY ("modelUploadId") REFERENCES "modelUpload"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "jobOperation_modelUploadId_idx" ON "jobOperation" ("modelUploadId"); + +-- Add assemblyNodeId to jobOperationStep to link steps to specific parts in the assembly hierarchy +-- This is a JSON path reference to the node in assemblyMetadata.hierarchy (e.g., "0.children.1.children.0") +ALTER TABLE "jobOperationStep" ADD COLUMN "assemblyNodeId" TEXT; + +-- Add assemblyNodeName for display purposes (denormalized for performance) +ALTER TABLE "jobOperationStep" ADD COLUMN "assemblyNodeName" TEXT; + +-- Add assemblyNodeQuantity for the quantity of this part in the assembly +ALTER TABLE "jobOperationStep" ADD COLUMN "assemblyNodeQuantity" INTEGER; diff --git a/packages/database/supabase/migrations/20260114120001_jobs-view-assembly-metadata.sql b/packages/database/supabase/migrations/20260114120001_jobs-view-assembly-metadata.sql new file mode 100644 index 0000000000..4d322e580e --- /dev/null +++ b/packages/database/supabase/migrations/20260114120001_jobs-view-assembly-metadata.sql @@ -0,0 +1,44 @@ +-- Update jobs view to include assembly metadata from modelUpload + +DROP VIEW IF EXISTS "jobs"; +CREATE OR REPLACE VIEW "jobs" WITH(SECURITY_INVOKER=true) AS +WITH job_model AS ( + SELECT + j.id AS job_id, + j."companyId", + COALESCE(j."modelUploadId", i."modelUploadId") AS model_upload_id + FROM "job" j + INNER JOIN "item" i ON j."itemId" = i."id" AND j."companyId" = i."companyId" +) +SELECT + j.*, + jmm."id" as "jobMakeMethodId", + i.name, + i."readableIdWithRevision" as "itemReadableIdWithRevision", + i.type as "itemType", + i.name as "description", + i."itemTrackingType", + i.active, + i."replenishmentSystem", + mu.id as "modelId", + mu."autodeskUrn", + mu."modelPath", + CASE + WHEN i."thumbnailPath" IS NULL AND mu."thumbnailPath" IS NOT NULL THEN mu."thumbnailPath" + ELSE i."thumbnailPath" + END as "thumbnailPath", + mu."name" as "modelName", + mu."size" as "modelSize", + mu."assemblyMetadata", + mu."parsingStatus", + mu."parsedAt", + mu."parsingError", + so."salesOrderId" as "salesOrderReadableId", + qo."quoteId" as "quoteReadableId" +FROM "job" j +LEFT JOIN "jobMakeMethod" jmm ON jmm."jobId" = j.id AND jmm."parentMaterialId" IS NULL +INNER JOIN "item" i ON j."itemId" = i."id" AND j."companyId" = i."companyId" +LEFT JOIN job_model jm ON j.id = jm.job_id AND j."companyId" = jm."companyId" +LEFT JOIN "modelUpload" mu ON mu.id = jm.model_upload_id +LEFT JOIN "salesOrder" so on j."salesOrderId" = so.id AND j."companyId" = so."companyId" +LEFT JOIN "quote" qo ON j."quoteId" = qo.id AND j."companyId" = qo."companyId"; diff --git a/packages/jobs/trigger/assembly-to-operations.ts b/packages/jobs/trigger/assembly-to-operations.ts new file mode 100644 index 0000000000..c49f2ac9d1 --- /dev/null +++ b/packages/jobs/trigger/assembly-to-operations.ts @@ -0,0 +1,286 @@ +import { getCarbonServiceRole } from "@carbon/auth"; +import { task, logger } from "@trigger.dev/sdk"; + +/** + * Assembly node structure from parsed STEP file + */ +interface AssemblyNode { + id: string; + name: string; + partNumber?: string; + quantity: number; + children: AssemblyNode[]; +} + +interface AssemblyMetadata { + isAssembly: boolean; + partCount: number; + hierarchy: AssemblyNode[]; + rootName?: string; +} + +interface GeneratedOperation { + description: string; + order: number; + modelUploadId: string; + steps: GeneratedStep[]; +} + +interface GeneratedStep { + name: string; + assemblyNodeId: string; + assemblyNodeName: string; + assemblyNodeQuantity: number; + sortOrder: number; +} + +/** + * Traverse assembly hierarchy and generate operations in bottom-up order. + * Each assembly node with children becomes an operation. + * Each child becomes a step in that operation. + */ +function generateOperationsFromHierarchy( + hierarchy: AssemblyNode[], + modelUploadId: string +): GeneratedOperation[] { + const operations: GeneratedOperation[] = []; + let operationOrder = 1; + + // Recursive function to process nodes depth-first (bottom-up assembly) + function processNode(node: AssemblyNode, path: string): void { + // First, process all children (depth-first) + node.children.forEach((child, index) => { + const childPath = path ? `${path}.children.${index}` : `${index}`; + processNode(child, childPath); + }); + + // If this node has children, it's an assembly step - create an operation + if (node.children.length > 0) { + const steps: GeneratedStep[] = node.children.map((child, index) => ({ + name: `Install ${child.name}${child.quantity > 1 ? ` (×${child.quantity})` : ""}`, + assemblyNodeId: path ? `${path}.children.${index}` : `${index}`, + assemblyNodeName: child.name, + assemblyNodeQuantity: child.quantity, + sortOrder: index + 1, + })); + + operations.push({ + description: `Assemble ${node.name}`, + order: operationOrder++, + modelUploadId, + steps, + }); + } + } + + // Process each root node + hierarchy.forEach((rootNode, index) => { + processNode(rootNode, `${index}`); + }); + + return operations; +} + +/** + * Trigger.dev task to generate job operations from assembly metadata + */ +export const assemblyToOperationsTask = task({ + id: "assembly-to-operations", + retry: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 30000, + }, + run: async (payload: { + jobId: string; + modelUploadId: string; + processId: string; + companyId: string; + userId: string; + }) => { + const { jobId, modelUploadId, processId, companyId, userId } = payload; + + logger.info("Starting assembly-to-operations task", { + jobId, + modelUploadId, + processId, + }); + + const client = getCarbonServiceRole(); + + // Get the job and its make method + const { data: job, error: jobError } = await client + .from("job") + .select("id, jobMakeMethod(id)") + .eq("id", jobId) + .single(); + + if (jobError || !job) { + console.error("Failed to fetch job", { + jobId, + error: jobError?.message ?? "Job not found", + }); + throw new Error(`Failed to fetch job: ${jobError?.message}`); + } + + const jobMakeMethod = Array.isArray(job.jobMakeMethod) + ? job.jobMakeMethod[0] + : job.jobMakeMethod; + + if (!jobMakeMethod) { + console.error("Job does not have a make method", { jobId }); + throw new Error("Job does not have a make method"); + } + + // Get the model upload with assembly metadata + const { data: modelUpload, error: modelError } = await client + .from("modelUpload") + .select("id, assemblyMetadata, parsingStatus") + .eq("id", modelUploadId) + .single(); + + if (modelError || !modelUpload) { + console.error("Failed to fetch model upload", { + modelUploadId, + error: modelError?.message ?? "Model upload not found", + }); + throw new Error(`Failed to fetch model upload: ${modelError?.message}`); + } + + if (modelUpload.parsingStatus !== "completed") { + console.error("Model upload parsing not complete", { + modelUploadId, + parsingStatus: modelUpload.parsingStatus, + }); + throw new Error( + `Model upload parsing not complete. Status: ${modelUpload.parsingStatus}` + ); + } + + const assemblyMetadata = modelUpload.assemblyMetadata as AssemblyMetadata | null; + + if (!assemblyMetadata || !assemblyMetadata.isAssembly) { + console.error("Model upload does not contain assembly metadata", { + modelUploadId, + hasMetadata: !!assemblyMetadata, + isAssembly: assemblyMetadata?.isAssembly, + }); + throw new Error("Model upload does not contain assembly metadata"); + } + + logger.info("Found assembly metadata", { + partCount: assemblyMetadata.partCount, + rootName: assemblyMetadata.rootName, + hierarchyLength: assemblyMetadata.hierarchy.length, + }); + + // Generate operations from hierarchy + const generatedOperations = generateOperationsFromHierarchy( + assemblyMetadata.hierarchy, + modelUploadId + ); + + logger.info("Generated operations", { + count: generatedOperations.length, + }); + + if (generatedOperations.length === 0) { + logger.warn("No operations generated from assembly"); + return { + success: true, + jobId, + operationsCreated: 0, + stepsCreated: 0, + }; + } + + // Get existing operations to determine starting order + const { data: existingOps } = await client + .from("jobOperation") + .select("order") + .eq("jobId", jobId) + .order("order", { ascending: false }) + .limit(1); + + const startingOrder = existingOps?.[0]?.order ?? 0; + + let totalStepsCreated = 0; + + // Create operations and steps + for (const genOp of generatedOperations) { + // Insert the operation + const { data: operation, error: opError } = await client + .from("jobOperation") + .insert({ + jobId, + jobMakeMethodId: jobMakeMethod.id, + processId, + description: genOp.description, + order: startingOrder + genOp.order, + modelUploadId: genOp.modelUploadId, + companyId, + createdBy: userId, + }) + .select("id") + .single(); + + if (opError || !operation) { + logger.error("Failed to create operation", { + error: opError?.message, + description: genOp.description, + }); + continue; + } + + logger.info("Created operation", { + operationId: operation.id, + description: genOp.description, + }); + + // Insert steps for this operation + if (genOp.steps.length > 0) { + const stepsToInsert = genOp.steps.map((step) => ({ + operationId: operation.id, + name: step.name, + type: "Checkbox" as const, + sortOrder: step.sortOrder, + assemblyNodeId: step.assemblyNodeId, + assemblyNodeName: step.assemblyNodeName, + assemblyNodeQuantity: step.assemblyNodeQuantity, + companyId, + createdBy: userId, + })); + + const { error: stepsError } = await client + .from("jobOperationStep") + .insert(stepsToInsert); + + if (stepsError) { + logger.error("Failed to create steps", { + error: stepsError.message, + operationId: operation.id, + }); + } else { + totalStepsCreated += genOp.steps.length; + logger.info("Created steps", { + operationId: operation.id, + count: genOp.steps.length, + }); + } + } + } + + logger.info("Assembly-to-operations completed", { + jobId, + operationsCreated: generatedOperations.length, + stepsCreated: totalStepsCreated, + }); + + return { + success: true, + jobId, + operationsCreated: generatedOperations.length, + stepsCreated: totalStepsCreated, + }; + }, +}); diff --git a/packages/jobs/trigger/step-parser.ts b/packages/jobs/trigger/step-parser.ts new file mode 100644 index 0000000000..2898271ec7 --- /dev/null +++ b/packages/jobs/trigger/step-parser.ts @@ -0,0 +1,287 @@ +import { getCarbonServiceRole } from "@carbon/auth"; +import { task, logger } from "@trigger.dev/sdk"; + +/** + * Assembly metadata extracted from STEP file + */ +interface AssemblyNode { + id: string; + name: string; + partNumber?: string; + quantity: number; + children: AssemblyNode[]; +} + +interface AssemblyMetadata { + isAssembly: boolean; + partCount: number; + hierarchy: AssemblyNode[]; + rootName?: string; +} + +/** + * Parse STEP file text content to extract assembly structure. + * STEP files are text-based and contain structured data about parts and assemblies. + */ +function parseStepContent(content: string): AssemblyMetadata { + // Extract PRODUCT definitions - these define individual parts/assemblies + // Format: PRODUCT('name','description','part_number',(context)); + const productRegex = + /PRODUCT\s*\(\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'/gi; + const products = new Map(); + + // Extract PRODUCT_DEFINITION - links products to their definitions + // Format: #123 = PRODUCT_DEFINITION(...,#productId,...); + const productDefRegex = + /#(\d+)\s*=\s*PRODUCT_DEFINITION\s*\([^)]*,\s*#(\d+)/gi; + const productDefs = new Map(); + + // Extract NEXT_ASSEMBLY_USAGE_OCCURRENCE - defines parent-child relationships + // Format: NEXT_ASSEMBLY_USAGE_OCCURRENCE('id','name','desc',#parent,#child,...); + const assemblyRegex = + /NEXT_ASSEMBLY_USAGE_OCCURRENCE\s*\(\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'[^']*'\s*,\s*#(\d+)\s*,\s*#(\d+)/gi; + const assemblyRelations: Array<{ + id: string; + name: string; + parentId: string; + childId: string; + }> = []; + + // Find all entity ID to PRODUCT mappings + const entityProductRegex = /#(\d+)\s*=\s*PRODUCT\s*\(\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'/gi; + const entityToProduct = new Map< + string, + { name: string; description: string; partNumber: string } + >(); + + let match; + + // Parse entity-to-product mappings + while ((match = entityProductRegex.exec(content)) !== null) { + const [, entityId, name, description, partNumber] = match; + entityToProduct.set(entityId, { + name: name || description || `Part_${entityId}`, + description, + partNumber, + }); + } + + // Parse product definitions (maps product_definition entity to product entity) + while ((match = productDefRegex.exec(content)) !== null) { + const [, defId, productId] = match; + productDefs.set(defId, productId); + } + + // Parse assembly relationships + while ((match = assemblyRegex.exec(content)) !== null) { + const [, id, name, parentId, childId] = match; + assemblyRelations.push({ id, name, parentId, childId }); + } + + // Build hierarchy from relationships + const childToParent = new Map(); + const parentToChildren = new Map(); + + for (const rel of assemblyRelations) { + childToParent.set(rel.childId, rel.parentId); + const children = parentToChildren.get(rel.parentId) || []; + children.push(rel.childId); + parentToChildren.set(rel.parentId, children); + } + + // Find root nodes (entities with no parent) + const allChildIds = new Set(assemblyRelations.map((r) => r.childId)); + const allParentIds = new Set(assemblyRelations.map((r) => r.parentId)); + const rootIds = [...allParentIds].filter((id) => !allChildIds.has(id)); + + // Get product info for an entity ID + const getProductInfo = (entityId: string) => { + // First try direct lookup + if (entityToProduct.has(entityId)) { + return entityToProduct.get(entityId)!; + } + // Try through product definition + const productId = productDefs.get(entityId); + if (productId && entityToProduct.has(productId)) { + return entityToProduct.get(productId)!; + } + return { name: `Entity_${entityId}`, description: "", partNumber: "" }; + }; + + // Count occurrences of each child + const childCounts = new Map(); + for (const rel of assemblyRelations) { + childCounts.set(rel.childId, (childCounts.get(rel.childId) || 0) + 1); + } + + // Build tree recursively + const buildNode = (entityId: string, visited = new Set()): AssemblyNode => { + if (visited.has(entityId)) { + // Prevent circular references + const info = getProductInfo(entityId); + return { + id: entityId, + name: info.name, + partNumber: info.partNumber || undefined, + quantity: 1, + children: [], + }; + } + visited.add(entityId); + + const info = getProductInfo(entityId); + const childIds = parentToChildren.get(entityId) || []; + + // Group children by their product to count quantities + const childGroups = new Map(); + for (const childId of childIds) { + const childInfo = getProductInfo(childId); + const key = `${childInfo.name}_${childInfo.partNumber}`; + const existing = childGroups.get(key); + if (existing) { + existing.count++; + } else { + childGroups.set(key, { count: 1, entityId: childId }); + } + } + + const children: AssemblyNode[] = []; + for (const [, group] of childGroups) { + const childNode = buildNode(group.entityId, new Set(visited)); + childNode.quantity = group.count; + children.push(childNode); + } + + return { + id: entityId, + name: info.name, + partNumber: info.partNumber || undefined, + quantity: 1, + children, + }; + }; + + // Build hierarchy from roots + const hierarchy: AssemblyNode[] = rootIds.map((rootId) => buildNode(rootId)); + + // Count total unique parts + const countParts = (nodes: AssemblyNode[]): number => { + let count = 0; + for (const node of nodes) { + if (node.children.length === 0) { + count += node.quantity; + } else { + count += countParts(node.children); + } + } + return count; + }; + + const partCount = entityToProduct.size; + const isAssembly = assemblyRelations.length > 0; + + return { + isAssembly, + partCount, + hierarchy, + rootName: hierarchy.length > 0 ? hierarchy[0].name : undefined, + }; +} + +/** + * Trigger.dev task to parse STEP files and extract assembly metadata + */ +export const stepParserTask = task({ + id: "step-parser", + retry: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 30000, + }, + run: async (payload: { modelId: string; companyId: string; modelPath: string }) => { + const { modelId, companyId, modelPath } = payload; + + logger.info("Starting STEP parser task", { modelId, companyId, modelPath }); + + const client = getCarbonServiceRole(); + + // Update status to processing + const { error: statusError } = await client + .from("modelUpload") + .update({ parsingStatus: "processing" }) + .eq("id", modelId); + + if (statusError) { + console.error("Failed to update parsing status to processing", { + modelId, + error: statusError.message, + }); + } + + try { + // Download the STEP file from storage + logger.info("Downloading STEP file", { modelPath }); + const { data: fileData, error: downloadError } = await client.storage + .from("private") + .download(modelPath); + + if (downloadError || !fileData) { + throw new Error(`Failed to download file: ${downloadError?.message}`); + } + + // Read file content as text (STEP files are text-based) + const content = await fileData.text(); + logger.info("File downloaded", { size: content.length }); + + // Parse the STEP content + const assemblyMetadata = parseStepContent(content); + logger.info("Parsed assembly metadata", { + isAssembly: assemblyMetadata.isAssembly, + partCount: assemblyMetadata.partCount, + hierarchyDepth: assemblyMetadata.hierarchy.length, + }); + + // Update the modelUpload record with parsed metadata + const { error: updateError } = await client + .from("modelUpload") + .update({ + assemblyMetadata, + parsingStatus: "completed", + parsedAt: new Date().toISOString(), + parsingError: null, + }) + .eq("id", modelId); + + if (updateError) { + console.error("Failed to update model metadata", { + modelId, + error: updateError.message, + }); + throw new Error(`Failed to update metadata: ${updateError.message}`); + } + + logger.info("STEP parsing completed", { modelId }); + + return { + success: true, + modelId, + isAssembly: assemblyMetadata.isAssembly, + partCount: assemblyMetadata.partCount, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + logger.error("STEP parsing failed", { modelId, error: errorMessage }); + + // Update status to failed + await client + .from("modelUpload") + .update({ + parsingStatus: "failed", + parsingError: errorMessage, + }) + .eq("id", modelId); + + throw error; + } + }, +}); From 626a7e13d81b85978d2b494d4416ebbabc712528 Mon Sep 17 00:00:00 2001 From: Gaurav Bhatt Date: Thu, 15 Jan 2026 14:21:51 +0530 Subject: [PATCH 02/25] -untested modelviewer changes --- packages/react/src/ModelViewer.tsx | 90 +++++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 7 deletions(-) diff --git a/packages/react/src/ModelViewer.tsx b/packages/react/src/ModelViewer.tsx index 8e9a578023..3d0db6276a 100644 --- a/packages/react/src/ModelViewer.tsx +++ b/packages/react/src/ModelViewer.tsx @@ -21,7 +21,11 @@ export function ModelViewer({ className, withProperties = true, onDataUrl, - resetZoomButton = true + resetZoomButton = true, + preserveColors = false, + enableSelection = false, + highlightColor = "#8ec9f0", + onPartSelected }: { file: File | null; url: string | null; @@ -31,6 +35,14 @@ export function ModelViewer({ onDataUrl?: (dataUrl: string) => void; resetZoomButton?: boolean; className?: string; + /** When true, preserves original colors from the model file (e.g., STEP colors) */ + preserveColors?: boolean; + /** When true, enables click-to-select parts with highlighting */ + enableSelection?: boolean; + /** Color used to highlight selected parts (default: light blue) */ + highlightColor?: `#${string}`; + /** Callback when a part is selected or deselected */ + onPartSelected?: (partId: string | null, partName: string | null) => void; }) { const parentDiv = useRef(null); const viewerRef = useRef(null); @@ -45,6 +57,7 @@ export function ModelViewer({ volume: number; dimensions: { x: number; y: number; z: number }; } | null>(null); + const [selectedMeshId, setSelectedMeshId] = useState(null); useMount(() => { if (file || url) { @@ -61,12 +74,18 @@ export function ModelViewer({ backgroundColor: isDarkMode ? new OV.RGBAColor(20, 22, 25, 0) : new OV.RGBAColor(255, 255, 255, 0), - defaultColor: new OV.RGBColor(0, 125, 125), + // Only set defaultColor when not preserving colors - this allows model colors to show + ...(preserveColors + ? {} + : { defaultColor: new OV.RGBColor(151, 151, 165) }), onModelLoaded: () => { try { if (viewerRef.current) { const viewer3D = viewerRef.current.GetViewer(); - updateColor(color ?? (isDarkMode ? darkColor : lightColor)); + // Only override colors if not preserving original colors + if (!preserveColors) { + updateColor(color ?? (isDarkMode ? darkColor : lightColor)); + } viewer3D.Resize( parentDiv.current?.clientWidth, @@ -133,6 +152,38 @@ export function ModelViewer({ dimensions }); } + + // Set up click handler for part selection + if (enableSelection) { + viewer3D.SetMouseClickHandler( + ( + button: number, + mouseCoordinates: { x: number; y: number } + ) => { + if (button !== 1) return; // Left click only + + const meshUserData = viewer3D.GetMeshUserDataUnderMouse( + OV.IntersectionMode.MeshAndLine, + mouseCoordinates + ); + + if (meshUserData === null) { + // Clicked on empty space - deselect + setSelectedMeshId(null); + onPartSelected?.(null, null); + } else { + const meshId = + meshUserData.originalMeshInstance?.id?.GetKey?.() ?? + null; + const meshName = + meshUserData.originalMeshInstance?.GetName?.() ?? + null; + setSelectedMeshId(meshId); + onPartSelected?.(meshId, meshName); + } + } + ); + } } // Clear progress interval and set to 100% @@ -422,10 +473,11 @@ export function ModelViewer({ // biome-ignore lint/correctness/useExhaustiveDependencies: suppressed due to migration useEffect(() => { - if (color) { + // Only apply explicit color prop when not preserving colors + if (color && !preserveColors) { updateColor(color); } - }, [color]); + }, [color, preserveColors]); // biome-ignore lint/correctness/useExhaustiveDependencies: suppressed due to migration useEffect(() => { @@ -452,11 +504,35 @@ export function ModelViewer({ : new OV.RGBAColor(255, 255, 255, 255) ); - if (!color) { + // Only update colors if not preserving original colors + if (!preserveColors && !color) { updateColor(isDarkMode ? darkColor : lightColor); } } - }, [isDarkMode, color]); + }, [isDarkMode, color, preserveColors]); + + // Highlight selected mesh when selection changes + // biome-ignore lint/correctness/useExhaustiveDependencies: suppressed due to migration + useEffect(() => { + if (!enableSelection || !viewerRef.current || isLoading) return; + + const viewer3D = viewerRef.current.GetViewer(); + if (!viewer3D) return; + + // Parse highlight color to RGB components + const hex = highlightColor.replace("#", ""); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + const ovHighlightColor = new OV.RGBColor(r, g, b); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + viewer3D.SetMeshesHighlight(ovHighlightColor, (meshUserData: any) => { + if (selectedMeshId === null) return false; + const meshId = meshUserData?.originalMeshInstance?.id?.GetKey?.(); + return meshId === selectedMeshId; + }); + }, [selectedMeshId, enableSelection, highlightColor, isLoading]); const { locale } = useLocale(); From 0f248a1f8203aead74d588de82c201115738f392 Mon Sep 17 00:00:00 2001 From: Gaurav Bhatt Date: Wed, 21 Jan 2026 16:13:32 +0530 Subject: [PATCH 03/25] - finally preserving colors --- apps/erp/app/components/CadModel.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/erp/app/components/CadModel.tsx b/apps/erp/app/components/CadModel.tsx index e7a9551dcb..dcbb4a5246 100644 --- a/apps/erp/app/components/CadModel.tsx +++ b/apps/erp/app/components/CadModel.tsx @@ -37,8 +37,12 @@ type CadModelProps = { uploadClassName?: string; viewerClassName?: string; isReadOnly?: boolean; - /** When true, each part in the assembly gets a unique color */ - colorByPart?: boolean; + /** When true, preserves original colors from the model file (e.g., STEP colors) */ + preserveColors?: boolean; + /** When true, enables click-to-select parts with highlighting */ + enableSelection?: boolean; + /** Callback when a part is selected or deselected */ + onPartSelected?: (partId: string | null, partName: string | null) => void; }; const CadModel = ({ @@ -48,7 +52,9 @@ const CadModel = ({ title, uploadClassName, viewerClassName, - colorByPart + preserveColors = true, + enableSelection = true, + onPartSelected }: CadModelProps) => { const { company: { id: companyId } @@ -130,7 +136,9 @@ const CadModel = ({ url={modelPath ? getPrivateUrl(modelPath) : null} mode={mode} className={viewerClassName} - colorByPart={colorByPart} + preserveColors={preserveColors} + enableSelection={enableSelection} + onPartSelected={onPartSelected} /> ) : ( Date: Thu, 29 Jan 2026 17:53:05 +0530 Subject: [PATCH 04/25] CAD changese --- .gitignore | 11 +- .opencode.json | 52 + .opencode/agents/db-migrate.md | 35 + .opencode/agents/feature-dev.md | 51 + .opencode/agents/reviewer.md | 48 + .vscode/PythonImportHelper-v2-Completion.json | 551 +++ AGENTS.md | 91 + CHANGELOG.md | 208 + apps/assembly/.dockerignore | 3 + apps/assembly/.gitignore | 6 + apps/assembly/Dockerfile | 31 + .../app/components/Layout/Sidebar.tsx | 86 + .../assembly/app/components/Layout/Topbar.tsx | 35 + apps/assembly/app/components/Layout/index.ts | 2 + .../app/components/Viewer/XeokitCanvas.tsx | 324 ++ apps/assembly/app/components/Viewer/index.ts | 9 + .../app/components/Viewer/useXeokit.ts | 235 ++ .../CenterViewer/PlaybackControls.tsx | 159 + .../CenterViewer/StepNavigation.tsx | 70 + .../CenterViewer/ViewerToolbar.tsx | 138 + .../LeftPanel/ComponentTree.tsx | 117 + .../LeftPanel/GeometriesList.tsx | 61 + .../WorkInstructions/LeftPanel/StepTree.tsx | 238 ++ .../WorkInstructions/LeftPanel/index.tsx | 99 + .../WorkInstructions/RightPanel/MediaTab.tsx | 171 + .../WorkInstructions/RightPanel/NotesTab.tsx | 59 + .../RightPanel/StandardNotesTab.tsx | 187 + .../RightPanel/SupplementsTab.tsx | 154 + .../WorkInstructions/RightPanel/ToolsTab.tsx | 196 + .../WorkInstructions/RightPanel/index.tsx | 92 + .../WorkInstructionEditor.tsx | 188 + .../app/components/WorkInstructions/index.ts | 4 + apps/assembly/app/components/index.ts | 1 + apps/assembly/app/context.ts | 5 + apps/assembly/app/entry.client.tsx | 12 + apps/assembly/app/entry.server.tsx | 21 + apps/assembly/app/global.d.ts | 8 + apps/assembly/app/modules/settings/index.ts | 1 + .../app/modules/settings/settings.server.ts | 19 + apps/assembly/app/modules/users/index.ts | 1 + .../app/modules/users/users.server.ts | 128 + apps/assembly/app/polyfill.ts | 5 + apps/assembly/app/root.tsx | 244 ++ apps/assembly/app/routes.ts | 16 + apps/assembly/app/routes/_index.tsx | 13 + apps/assembly/app/routes/file+/model.$.tsx | 72 + apps/assembly/app/routes/login.tsx | 99 + apps/assembly/app/routes/refresh-session.tsx | 41 + apps/assembly/app/routes/x+/_index.tsx | 144 + apps/assembly/app/routes/x+/_layout.tsx | 113 + .../app/routes/x+/projects.$id._index.tsx | 285 ++ .../app/routes/x+/projects.$id.edit.tsx | 557 +++ .../app/routes/x+/projects.$id.export.tsx | 419 ++ .../app/routes/x+/projects.$id.prep.tsx | 457 +++ .../app/routes/x+/projects._index.tsx | 166 + apps/assembly/app/routes/x+/projects.new.tsx | 256 ++ .../app/routes/x+/settings._index.tsx | 129 + .../app/routes/x+/settings.associations.tsx | 319 ++ .../assembly/app/routes/x+/settings.tools.tsx | 255 ++ .../app/routes/x+/settings.torque.tsx | 300 ++ apps/assembly/app/services/mode.server.ts | 30 + apps/assembly/app/services/theme.server.ts | 31 + apps/assembly/app/styles/background.css | 72 + apps/assembly/app/styles/nprogress.css | 16 + apps/assembly/app/styles/tailwind.css | 128 + apps/assembly/app/types/assembly.types.ts | 281 ++ apps/assembly/app/types/xeokit-sdk.d.ts | 143 + apps/assembly/app/utils/path.ts | 87 + apps/assembly/env.d.ts | 2 + apps/assembly/package.json | 95 + apps/assembly/postcss.config.cjs | 6 + apps/assembly/public/carbon-logo-mark.svg | 20 + apps/assembly/public/favicon-96x96.png | Bin 0 -> 2226 bytes apps/assembly/public/grid.svg | 5 + apps/assembly/public/site.webmanifest | 21 + apps/assembly/react-router.config.ts | 9 + apps/assembly/server/app.ts | 8 + apps/assembly/sst-env.d.ts | 3 + apps/assembly/tailwind.config.js | 3 + apps/assembly/tsconfig.json | 23 + apps/assembly/vite.config.ts | 49 + package-lock.json | 424 ++ package.json | 2 + packages/cad-rust/Cargo.lock | 1706 ++++++++ packages/cad-rust/Cargo.toml | 63 + packages/cad-rust/as1_pe_203.stp | 3089 ++++++++++++++ packages/cad-rust/cad-common/Cargo.toml | 11 + packages/cad-rust/cad-common/src/assembly.rs | 184 + packages/cad-rust/cad-common/src/lib.rs | 7 + packages/cad-rust/cad-common/src/types.rs | 129 + packages/cad-rust/cad-parser/Cargo.toml | 16 + packages/cad-rust/cad-parser/src/lib.rs | 12 + .../cad-rust/cad-parser/src/mesh_converter.rs | 151 + .../cad-rust/cad-parser/src/step_parser.rs | 119 + packages/cad-rust/cad-server/Cargo.toml | 32 + .../cad-rust/cad-server/src/glb_loader.rs | 299 ++ packages/cad-rust/cad-server/src/handlers.rs | 225 + packages/cad-rust/cad-server/src/main.rs | 58 + packages/cad-rust/cad-server/src/state.rs | 40 + packages/cad-rust/cad-simulator/Cargo.toml | 15 + .../cad-rust/cad-simulator/src/collision.rs | 72 + packages/cad-rust/cad-simulator/src/lib.rs | 16 + .../cad-rust/cad-simulator/src/sequence.rs | 119 + .../cad-rust/cad-simulator/src/simulator.rs | 344 ++ .../cad-rust/cad-simulator/src/stability.rs | 125 + packages/cad-rust/cad-wasm/Cargo.toml | 25 + packages/cad-rust/cad-wasm/src/animation.rs | 143 + packages/cad-rust/cad-wasm/src/exploded.rs | 154 + packages/cad-rust/cad-wasm/src/lib.rs | 25 + packages/cad-service/Dockerfile | 37 + packages/cad-service/docker-compose.yml | 20 + packages/cad-service/requirements.txt | 18 + packages/cad-service/src/__init__.py | 1 + packages/cad-service/src/gltf_writer.py | 289 ++ packages/cad-service/src/main.py | 269 ++ packages/cad-service/src/parser.py | 935 +++++ packages/cad-service/tests/__init__.py | 1 + packages/cad-service/tests/test_parser.py | 170 + packages/database/src/swagger-docs-schema.ts | 3601 +++++++++++++++-- packages/database/src/types.ts | 757 ++++ .../database/supabase/functions/lib/types.ts | 757 ++++ ...20260123120000_assembly-app-standalone.sql | 418 ++ ...125131824_assembly-project-opencascade.sql | 21 + .../20260125180000_assembly-permissions.sql | 3 + ...60125180001_assembly-permissions-grant.sql | 25 + packages/jobs/trigger/assembly-simulate.ts | 306 ++ packages/jobs/trigger/step-parser-occ.ts | 248 ++ scripts/setup-env-files.ts | 2 +- sst.config.ts | 23 + 129 files changed, 24009 insertions(+), 326 deletions(-) create mode 100644 .opencode.json create mode 100644 .opencode/agents/db-migrate.md create mode 100644 .opencode/agents/feature-dev.md create mode 100644 .opencode/agents/reviewer.md create mode 100644 .vscode/PythonImportHelper-v2-Completion.json create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 apps/assembly/.dockerignore create mode 100644 apps/assembly/.gitignore create mode 100644 apps/assembly/Dockerfile create mode 100644 apps/assembly/app/components/Layout/Sidebar.tsx create mode 100644 apps/assembly/app/components/Layout/Topbar.tsx create mode 100644 apps/assembly/app/components/Layout/index.ts create mode 100644 apps/assembly/app/components/Viewer/XeokitCanvas.tsx create mode 100644 apps/assembly/app/components/Viewer/index.ts create mode 100644 apps/assembly/app/components/Viewer/useXeokit.ts create mode 100644 apps/assembly/app/components/WorkInstructions/CenterViewer/PlaybackControls.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/CenterViewer/StepNavigation.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/CenterViewer/ViewerToolbar.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/LeftPanel/ComponentTree.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/LeftPanel/GeometriesList.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/LeftPanel/StepTree.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/LeftPanel/index.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/RightPanel/MediaTab.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/RightPanel/NotesTab.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/RightPanel/StandardNotesTab.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/RightPanel/SupplementsTab.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/RightPanel/ToolsTab.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/RightPanel/index.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/WorkInstructionEditor.tsx create mode 100644 apps/assembly/app/components/WorkInstructions/index.ts create mode 100644 apps/assembly/app/components/index.ts create mode 100644 apps/assembly/app/context.ts create mode 100644 apps/assembly/app/entry.client.tsx create mode 100644 apps/assembly/app/entry.server.tsx create mode 100644 apps/assembly/app/global.d.ts create mode 100644 apps/assembly/app/modules/settings/index.ts create mode 100644 apps/assembly/app/modules/settings/settings.server.ts create mode 100644 apps/assembly/app/modules/users/index.ts create mode 100644 apps/assembly/app/modules/users/users.server.ts create mode 100644 apps/assembly/app/polyfill.ts create mode 100644 apps/assembly/app/root.tsx create mode 100644 apps/assembly/app/routes.ts create mode 100644 apps/assembly/app/routes/_index.tsx create mode 100644 apps/assembly/app/routes/file+/model.$.tsx create mode 100644 apps/assembly/app/routes/login.tsx create mode 100644 apps/assembly/app/routes/refresh-session.tsx create mode 100644 apps/assembly/app/routes/x+/_index.tsx create mode 100644 apps/assembly/app/routes/x+/_layout.tsx create mode 100644 apps/assembly/app/routes/x+/projects.$id._index.tsx create mode 100644 apps/assembly/app/routes/x+/projects.$id.edit.tsx create mode 100644 apps/assembly/app/routes/x+/projects.$id.export.tsx create mode 100644 apps/assembly/app/routes/x+/projects.$id.prep.tsx create mode 100644 apps/assembly/app/routes/x+/projects._index.tsx create mode 100644 apps/assembly/app/routes/x+/projects.new.tsx create mode 100644 apps/assembly/app/routes/x+/settings._index.tsx create mode 100644 apps/assembly/app/routes/x+/settings.associations.tsx create mode 100644 apps/assembly/app/routes/x+/settings.tools.tsx create mode 100644 apps/assembly/app/routes/x+/settings.torque.tsx create mode 100644 apps/assembly/app/services/mode.server.ts create mode 100644 apps/assembly/app/services/theme.server.ts create mode 100644 apps/assembly/app/styles/background.css create mode 100644 apps/assembly/app/styles/nprogress.css create mode 100644 apps/assembly/app/styles/tailwind.css create mode 100644 apps/assembly/app/types/assembly.types.ts create mode 100644 apps/assembly/app/types/xeokit-sdk.d.ts create mode 100644 apps/assembly/app/utils/path.ts create mode 100644 apps/assembly/env.d.ts create mode 100644 apps/assembly/package.json create mode 100644 apps/assembly/postcss.config.cjs create mode 100644 apps/assembly/public/carbon-logo-mark.svg create mode 100644 apps/assembly/public/favicon-96x96.png create mode 100644 apps/assembly/public/grid.svg create mode 100644 apps/assembly/public/site.webmanifest create mode 100644 apps/assembly/react-router.config.ts create mode 100644 apps/assembly/server/app.ts create mode 100644 apps/assembly/sst-env.d.ts create mode 100644 apps/assembly/tailwind.config.js create mode 100644 apps/assembly/tsconfig.json create mode 100644 apps/assembly/vite.config.ts create mode 100644 packages/cad-rust/Cargo.lock create mode 100644 packages/cad-rust/Cargo.toml create mode 100644 packages/cad-rust/as1_pe_203.stp create mode 100644 packages/cad-rust/cad-common/Cargo.toml create mode 100644 packages/cad-rust/cad-common/src/assembly.rs create mode 100644 packages/cad-rust/cad-common/src/lib.rs create mode 100644 packages/cad-rust/cad-common/src/types.rs create mode 100644 packages/cad-rust/cad-parser/Cargo.toml create mode 100644 packages/cad-rust/cad-parser/src/lib.rs create mode 100644 packages/cad-rust/cad-parser/src/mesh_converter.rs create mode 100644 packages/cad-rust/cad-parser/src/step_parser.rs create mode 100644 packages/cad-rust/cad-server/Cargo.toml create mode 100644 packages/cad-rust/cad-server/src/glb_loader.rs create mode 100644 packages/cad-rust/cad-server/src/handlers.rs create mode 100644 packages/cad-rust/cad-server/src/main.rs create mode 100644 packages/cad-rust/cad-server/src/state.rs create mode 100644 packages/cad-rust/cad-simulator/Cargo.toml create mode 100644 packages/cad-rust/cad-simulator/src/collision.rs create mode 100644 packages/cad-rust/cad-simulator/src/lib.rs create mode 100644 packages/cad-rust/cad-simulator/src/sequence.rs create mode 100644 packages/cad-rust/cad-simulator/src/simulator.rs create mode 100644 packages/cad-rust/cad-simulator/src/stability.rs create mode 100644 packages/cad-rust/cad-wasm/Cargo.toml create mode 100644 packages/cad-rust/cad-wasm/src/animation.rs create mode 100644 packages/cad-rust/cad-wasm/src/exploded.rs create mode 100644 packages/cad-rust/cad-wasm/src/lib.rs create mode 100644 packages/cad-service/Dockerfile create mode 100644 packages/cad-service/docker-compose.yml create mode 100644 packages/cad-service/requirements.txt create mode 100644 packages/cad-service/src/__init__.py create mode 100644 packages/cad-service/src/gltf_writer.py create mode 100644 packages/cad-service/src/main.py create mode 100644 packages/cad-service/src/parser.py create mode 100644 packages/cad-service/tests/__init__.py create mode 100644 packages/cad-service/tests/test_parser.py create mode 100644 packages/database/supabase/migrations/20260123120000_assembly-app-standalone.sql create mode 100644 packages/database/supabase/migrations/20260125131824_assembly-project-opencascade.sql create mode 100644 packages/database/supabase/migrations/20260125180000_assembly-permissions.sql create mode 100644 packages/database/supabase/migrations/20260125180001_assembly-permissions-grant.sql create mode 100644 packages/jobs/trigger/assembly-simulate.ts create mode 100644 packages/jobs/trigger/step-parser-occ.ts diff --git a/.gitignore b/.gitignore index 286ef636b9..1359fffa8c 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,13 @@ 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/ \ No newline at end of file diff --git a/.opencode.json b/.opencode.json new file mode 100644 index 0000000000..960f048154 --- /dev/null +++ b/.opencode.json @@ -0,0 +1,52 @@ +{ + "data": { + "directory": ".opencode" + }, + "agent": { + "coder": { + "model": "anthropic/claude-sonnet-4-20250514", + "maxTokens": 8000 + }, + "task": { + "model": "anthropic/claude-haiku-4-20250514" + }, + "title": { + "model": "anthropic/claude-haiku-4-20250514" + } + }, + "shell": { + "path": "/bin/zsh", + "args": ["-l"] + }, + "lsp": { + "typescript": { + "disabled": false, + "command": "typescript-language-server", + "args": ["--stdio"] + } + }, + "autoCompact": true, + "permission": { + "read": "allow", + "glob": "allow", + "grep": "allow", + "edit": "ask", + "write": "ask", + "bash": { + "*": "ask", + "npm run test*": "allow", + "npm run lint*": "allow", + "npm run typecheck*": "allow", + "npm run build*": "allow", + "npm run dev*": "allow", + "git status*": "allow", + "git diff*": "allow", + "git log*": "allow", + "git branch*": "allow", + "git checkout*": "ask", + "git push*": "deny", + "git commit*": "ask", + "turbo *": "allow" + } + } +} diff --git a/.opencode/agents/db-migrate.md b/.opencode/agents/db-migrate.md new file mode 100644 index 0000000000..c2daaa4501 --- /dev/null +++ b/.opencode/agents/db-migrate.md @@ -0,0 +1,35 @@ +--- +description: Database migration specialist for Supabase/PostgreSQL +mode: subagent +model: anthropic/claude-sonnet-4-20250514 +temperature: 0.1 +tools: + bash: true + read: true + write: true + edit: true +--- +You are a database migration specialist for the Carbon manufacturing system. + +## First Steps +ALWAYS read the workflow file at llm/workflows/database-migration.md first. +ALWAYS check llm/cache/ for relevant context before making changes. + +## Key Patterns +- Migrations are in packages/database/supabase/migrations/ +- Types generated via npm run db:generate +- Use npm run db:migrate to create new migrations +- Test migrations locally before committing + +## Database Structure +- Multi-tenant architecture with company-based isolation +- PostgreSQL with Supabase +- RLS (Row Level Security) enabled on all tables +- Use the existing migration naming convention: YYYYMMDDHHMMSS_description.sql + +## Before Creating Migrations +1. Check existing schema in packages/database/ +2. Review related migrations for patterns +3. Ensure RLS policies are included +4. Add appropriate indexes for performance +5. Consider data migration needs for existing data diff --git a/.opencode/agents/feature-dev.md b/.opencode/agents/feature-dev.md new file mode 100644 index 0000000000..d63ef1871d --- /dev/null +++ b/.opencode/agents/feature-dev.md @@ -0,0 +1,51 @@ +--- +description: Feature developer for Carbon ERP/MES/Academy apps +mode: subagent +model: anthropic/claude-sonnet-4-20250514 +temperature: 0.2 +--- +You are a feature developer for Carbon manufacturing apps. + +## First Steps +BEFORE any work, query llm/cache/ for relevant context using: +- project-overview.md for architecture +- coding-conventions.md for standards +- Module-specific docs for business logic + +## Project Structure +- Apps: erp/, mes/, academy/, starter/ +- Shared code in packages/ +- Routes use React Router 7 flat convention +- Components use Radix UI primitives +- Forms use Zod validation + +## Key Patterns + +### File Organization (per app) +``` +components/ - React components +hooks/ - Custom React hooks +routes/ - React Router routes +services/ - Business logic & API calls +stores/ - State management +types/ - TypeScript types & validators +modules/ - Feature modules (ERP-specific) +``` + +### Module Pattern (ERP) +- Each module has: .models.ts, .service.ts, UI components +- Service methods: delete*, get*, list*, upsert* +- Database access through typed Supabase queries + +### Routing Conventions +- Protected routes: x+/ prefix +- Public routes: _public+/ prefix +- API routes: api+/ prefix +- File serving: file+/ prefix + +## Development Rules +- Make small, incremental changes +- Ask clarifying questions if uncertain +- Always write tests for new code +- Run tests before committing +- Never commit directly to main diff --git a/.opencode/agents/reviewer.md b/.opencode/agents/reviewer.md new file mode 100644 index 0000000000..a005eb8307 --- /dev/null +++ b/.opencode/agents/reviewer.md @@ -0,0 +1,48 @@ +--- +description: Code reviewer for Carbon PRs +mode: subagent +model: anthropic/claude-sonnet-4-20250514 +temperature: 0.1 +tools: + write: false + edit: false + bash: false +--- +You are a code reviewer for the Carbon manufacturing system. + +## Review Checklist + +### TypeScript Best Practices +- Strict mode compliance +- Proper type annotations (no implicit any) +- Appropriate use of generics +- Correct null/undefined handling + +### Security (OWASP Top 10) +- No SQL injection vulnerabilities +- No XSS vulnerabilities +- Input validation at system boundaries +- No hardcoded secrets or credentials +- Proper authentication/authorization checks + +### Code Quality +- Adherence to coding-conventions.md +- Proper error handling with meaningful messages +- No unnecessary complexity +- DRY principle (but not over-abstracted) +- Clear naming conventions + +### Testing +- Test coverage for new code +- Unit tests for business logic +- Integration tests for critical paths +- Edge cases considered + +### Performance +- No N+1 queries +- Appropriate caching strategies +- Efficient React rendering (memo, useMemo, useCallback where needed) +- Proper database indexing for new queries + +## Reference Files +Check llm/cache/coding-conventions.md for project-specific standards. diff --git a/.vscode/PythonImportHelper-v2-Completion.json b/.vscode/PythonImportHelper-v2-Completion.json new file mode 100644 index 0000000000..de1918bad1 --- /dev/null +++ b/.vscode/PythonImportHelper-v2-Completion.json @@ -0,0 +1,551 @@ +[ + { + "label": "sys", + "kind": 6, + "isExtraImport": true, + "importPath": "sys", + "description": "sys", + "detail": "sys", + "documentation": {} + }, + { + "label": "io", + "kind": 6, + "isExtraImport": true, + "importPath": "io", + "description": "io", + "detail": "io", + "documentation": {} + }, + { + "label": "struct", + "kind": 6, + "isExtraImport": true, + "importPath": "struct", + "description": "struct", + "detail": "struct", + "documentation": {} + }, + { + "label": "json", + "kind": 6, + "isExtraImport": true, + "importPath": "json", + "description": "json", + "detail": "json", + "documentation": {} + }, + { + "label": "Optional", + "importPath": "typing", + "description": "typing", + "isExtraImport": true, + "detail": "typing", + "documentation": {} + }, + { + "label": "Optional", + "importPath": "typing", + "description": "typing", + "isExtraImport": true, + "detail": "typing", + "documentation": {} + }, + { + "label": "Optional", + "importPath": "typing", + "description": "typing", + "isExtraImport": true, + "detail": "typing", + "documentation": {} + }, + { + "label": "List", + "importPath": "typing", + "description": "typing", + "isExtraImport": true, + "detail": "typing", + "documentation": {} + }, + { + "label": "numpy", + "kind": 6, + "isExtraImport": true, + "importPath": "numpy", + "description": "numpy", + "detail": "numpy", + "documentation": {} + }, + { + "label": "base64", + "kind": 6, + "isExtraImport": true, + "importPath": "base64", + "description": "base64", + "detail": "base64", + "documentation": {} + }, + { + "label": "logging", + "kind": 6, + "isExtraImport": true, + "importPath": "logging", + "description": "logging", + "detail": "logging", + "documentation": {} + }, + { + "label": "multiprocessing", + "kind": 6, + "isExtraImport": true, + "importPath": "multiprocessing", + "description": "multiprocessing", + "detail": "multiprocessing", + "documentation": {} + }, + { + "label": "tempfile", + "kind": 6, + "isExtraImport": true, + "importPath": "tempfile", + "description": "tempfile", + "detail": "tempfile", + "documentation": {} + }, + { + "label": "time", + "kind": 6, + "isExtraImport": true, + "importPath": "time", + "description": "time", + "detail": "time", + "documentation": {} + }, + { + "label": "Path", + "importPath": "pathlib", + "description": "pathlib", + "isExtraImport": true, + "detail": "pathlib", + "documentation": {} + }, + { + "label": "FastAPI", + "importPath": "fastapi", + "description": "fastapi", + "isExtraImport": true, + "detail": "fastapi", + "documentation": {} + }, + { + "label": "File", + "importPath": "fastapi", + "description": "fastapi", + "isExtraImport": true, + "detail": "fastapi", + "documentation": {} + }, + { + "label": "Form", + "importPath": "fastapi", + "description": "fastapi", + "isExtraImport": true, + "detail": "fastapi", + "documentation": {} + }, + { + "label": "HTTPException", + "importPath": "fastapi", + "description": "fastapi", + "isExtraImport": true, + "detail": "fastapi", + "documentation": {} + }, + { + "label": "UploadFile", + "importPath": "fastapi", + "description": "fastapi", + "isExtraImport": true, + "detail": "fastapi", + "documentation": {} + }, + { + "label": "CORSMiddleware", + "importPath": "fastapi.middleware.cors", + "description": "fastapi.middleware.cors", + "isExtraImport": true, + "detail": "fastapi.middleware.cors", + "documentation": {} + }, + { + "label": "BaseModel", + "importPath": "pydantic", + "description": "pydantic", + "isExtraImport": true, + "detail": "pydantic", + "documentation": {} + }, + { + "label": "uuid", + "kind": 6, + "isExtraImport": true, + "importPath": "uuid", + "description": "uuid", + "detail": "uuid", + "documentation": {} + }, + { + "label": "dataclass", + "importPath": "dataclasses", + "description": "dataclasses", + "isExtraImport": true, + "detail": "dataclasses", + "documentation": {} + }, + { + "label": "field", + "importPath": "dataclasses", + "description": "dataclasses", + "isExtraImport": true, + "detail": "dataclasses", + "documentation": {} + }, + { + "label": "STEPControl_Reader", + "importPath": "OCC.Core.STEPControl", + "description": "OCC.Core.STEPControl", + "isExtraImport": true, + "detail": "OCC.Core.STEPControl", + "documentation": {} + }, + { + "label": "IFSelect_RetDone", + "importPath": "OCC.Core.IFSelect", + "description": "OCC.Core.IFSelect", + "isExtraImport": true, + "detail": "OCC.Core.IFSelect", + "documentation": {} + }, + { + "label": "STEPCAFControl_Reader", + "importPath": "OCC.Core.STEPCAFControl", + "description": "OCC.Core.STEPCAFControl", + "isExtraImport": true, + "detail": "OCC.Core.STEPCAFControl", + "documentation": {} + }, + { + "label": "TDocStd_Document", + "importPath": "OCC.Core.TDocStd", + "description": "OCC.Core.TDocStd", + "isExtraImport": true, + "detail": "OCC.Core.TDocStd", + "documentation": {} + }, + { + "label": "XCAFDoc_DocumentTool", + "importPath": "OCC.Core.XCAFDoc", + "description": "OCC.Core.XCAFDoc", + "isExtraImport": true, + "detail": "OCC.Core.XCAFDoc", + "documentation": {} + }, + { + "label": "TDF_LabelSequence", + "importPath": "OCC.Core.TDF", + "description": "OCC.Core.TDF", + "isExtraImport": true, + "detail": "OCC.Core.TDF", + "documentation": {} + }, + { + "label": "TDF_Label", + "importPath": "OCC.Core.TDF", + "description": "OCC.Core.TDF", + "isExtraImport": true, + "detail": "OCC.Core.TDF", + "documentation": {} + }, + { + "label": "TDataStd_Name", + "importPath": "OCC.Core.TDataStd", + "description": "OCC.Core.TDataStd", + "isExtraImport": true, + "detail": "OCC.Core.TDataStd", + "documentation": {} + }, + { + "label": "TCollection_ExtendedString", + "importPath": "OCC.Core.TCollection", + "description": "OCC.Core.TCollection", + "isExtraImport": true, + "detail": "OCC.Core.TCollection", + "documentation": {} + }, + { + "label": "BRepMesh_IncrementalMesh", + "importPath": "OCC.Core.BRepMesh", + "description": "OCC.Core.BRepMesh", + "isExtraImport": true, + "detail": "OCC.Core.BRepMesh", + "documentation": {} + }, + { + "label": "TopLoc_Location", + "importPath": "OCC.Core.TopLoc", + "description": "OCC.Core.TopLoc", + "isExtraImport": true, + "detail": "OCC.Core.TopLoc", + "documentation": {} + }, + { + "label": "BRep_Tool", + "importPath": "OCC.Core.BRep", + "description": "OCC.Core.BRep", + "isExtraImport": true, + "detail": "OCC.Core.BRep", + "documentation": {} + }, + { + "label": "TopExp_Explorer", + "importPath": "OCC.Core.TopExp", + "description": "OCC.Core.TopExp", + "isExtraImport": true, + "detail": "OCC.Core.TopExp", + "documentation": {} + }, + { + "label": "TopAbs_FACE", + "importPath": "OCC.Core.TopAbs", + "description": "OCC.Core.TopAbs", + "isExtraImport": true, + "detail": "OCC.Core.TopAbs", + "documentation": {} + }, + { + "label": "TopAbs_SOLID", + "importPath": "OCC.Core.TopAbs", + "description": "OCC.Core.TopAbs", + "isExtraImport": true, + "detail": "OCC.Core.TopAbs", + "documentation": {} + }, + { + "label": "TopAbs_SHELL", + "importPath": "OCC.Core.TopAbs", + "description": "OCC.Core.TopAbs", + "isExtraImport": true, + "detail": "OCC.Core.TopAbs", + "documentation": {} + }, + { + "label": "TopAbs_COMPOUND", + "importPath": "OCC.Core.TopAbs", + "description": "OCC.Core.TopAbs", + "isExtraImport": true, + "detail": "OCC.Core.TopAbs", + "documentation": {} + }, + { + "label": "topods", + "importPath": "OCC.Core.TopoDS", + "description": "OCC.Core.TopoDS", + "isExtraImport": true, + "detail": "OCC.Core.TopoDS", + "documentation": {} + }, + { + "label": "Quantity_Color", + "importPath": "OCC.Core.Quantity", + "description": "OCC.Core.Quantity", + "isExtraImport": true, + "detail": "OCC.Core.Quantity", + "documentation": {} + }, + { + "label": "gp_Trsf", + "importPath": "OCC.Core.gp", + "description": "OCC.Core.gp", + "isExtraImport": true, + "detail": "OCC.Core.gp", + "documentation": {} + }, + { + "label": "ShapeFix_Shape", + "importPath": "OCC.Core.ShapeFix", + "description": "OCC.Core.ShapeFix", + "isExtraImport": true, + "detail": "OCC.Core.ShapeFix", + "documentation": {} + }, + { + "label": "pytest", + "kind": 6, + "isExtraImport": true, + "importPath": "pytest", + "description": "pytest", + "detail": "pytest", + "documentation": {} + }, + { + "label": "TestClient", + "importPath": "fastapi.testclient", + "description": "fastapi.testclient", + "isExtraImport": true, + "detail": "fastapi.testclient", + "documentation": {} + }, + { + "label": "GltfWriter", + "kind": 6, + "importPath": "packages.cad-service.src.gltf_writer", + "description": "packages.cad-service.src.gltf_writer", + "peekOfCode": "class GltfWriter:\n \"\"\"\n Writes mesh data to glTF 2.0 binary format (GLB)\n Creates a single GLB file with:\n - All meshes as separate mesh primitives\n - Node hierarchy matching the assembly structure\n - Materials with colors from STEP file\n \"\"\"\n def __init__(self):\n self._buffer = io.BytesIO()", + "detail": "packages.cad-service.src.gltf_writer", + "documentation": {} + }, + { + "label": "AssemblyNode", + "kind": 6, + "importPath": "packages.cad-service.src.main", + "description": "packages.cad-service.src.main", + "peekOfCode": "class AssemblyNode(BaseModel):\n \"\"\"Node in the assembly hierarchy tree\"\"\"\n id: str\n name: str\n type: str # \"assembly\" | \"part\"\n children: list[\"AssemblyNode\"] = []\n transform: Optional[list[float]] = None # 4x4 matrix as flat array\n color: Optional[list[float]] = None # RGBA\nclass ParseResponse(BaseModel):\n \"\"\"Response from /parse endpoint\"\"\"", + "detail": "packages.cad-service.src.main", + "documentation": {} + }, + { + "label": "ParseResponse", + "kind": 6, + "importPath": "packages.cad-service.src.main", + "description": "packages.cad-service.src.main", + "peekOfCode": "class ParseResponse(BaseModel):\n \"\"\"Response from /parse endpoint\"\"\"\n success: bool\n hierarchy: Optional[AssemblyNode] = None\n glb_base64: Optional[str] = None\n part_count: int = 0\n parse_time_ms: int = 0\n error: Optional[str] = None\nclass HealthResponse(BaseModel):\n \"\"\"Response from /health endpoint\"\"\"", + "detail": "packages.cad-service.src.main", + "documentation": {} + }, + { + "label": "HealthResponse", + "kind": 6, + "importPath": "packages.cad-service.src.main", + "description": "packages.cad-service.src.main", + "peekOfCode": "class HealthResponse(BaseModel):\n \"\"\"Response from /health endpoint\"\"\"\n status: str\n version: str\n opencascade_version: str\ndef _parse_in_subprocess(step_path: str, tolerance: float, angular_tolerance: float, result_queue):\n \"\"\"\n Run STEP parsing in a subprocess to isolate C++ crashes.\n If OpenCascade throws a C++ exception, only this subprocess dies.\n \"\"\"", + "detail": "packages.cad-service.src.main", + "documentation": {} + }, + { + "label": "logger", + "kind": 5, + "importPath": "packages.cad-service.src.main", + "description": "packages.cad-service.src.main", + "peekOfCode": "logger = logging.getLogger(__name__)\napp = FastAPI(\n title=\"Carbon CAD Service\",\n description=\"STEP file parsing and glTF conversion using OpenCascade (PythonOCC)\",\n version=\"1.0.0\",\n)\n# CORS middleware for development\napp.add_middleware(\n CORSMiddleware,\n allow_origins=[\"*\"],", + "detail": "packages.cad-service.src.main", + "documentation": {} + }, + { + "label": "app", + "kind": 5, + "importPath": "packages.cad-service.src.main", + "description": "packages.cad-service.src.main", + "peekOfCode": "app = FastAPI(\n title=\"Carbon CAD Service\",\n description=\"STEP file parsing and glTF conversion using OpenCascade (PythonOCC)\",\n version=\"1.0.0\",\n)\n# CORS middleware for development\napp.add_middleware(\n CORSMiddleware,\n allow_origins=[\"*\"],\n allow_credentials=True,", + "detail": "packages.cad-service.src.main", + "documentation": {} + }, + { + "label": "Mesh", + "kind": 6, + "importPath": "packages.cad-service.src.parser", + "description": "packages.cad-service.src.parser", + "peekOfCode": "class Mesh:\n \"\"\"Triangle mesh data for a single part\"\"\"\n id: str\n name: str\n vertices: np.ndarray # Nx3 float32\n normals: np.ndarray # Nx3 float32\n indices: np.ndarray # Mx3 uint32\n color: Optional[list[float]] = None # RGBA\n transform: Optional[list[float]] = None # 4x4 matrix\n@dataclass", + "detail": "packages.cad-service.src.parser", + "documentation": {} + }, + { + "label": "HierarchyNode", + "kind": 6, + "importPath": "packages.cad-service.src.parser", + "description": "packages.cad-service.src.parser", + "peekOfCode": "class HierarchyNode:\n \"\"\"Node in assembly hierarchy\"\"\"\n id: str\n name: str\n type: str # \"assembly\" | \"part\"\n children: list[\"HierarchyNode\"] = field(default_factory=list)\n transform: Optional[list[float]] = None\n color: Optional[list[float]] = None\nclass StepParser:\n \"\"\"", + "detail": "packages.cad-service.src.parser", + "documentation": {} + }, + { + "label": "StepParser", + "kind": 6, + "importPath": "packages.cad-service.src.parser", + "description": "packages.cad-service.src.parser", + "peekOfCode": "class StepParser:\n \"\"\"\n STEP file parser using PythonOCC\n Uses a two-stage approach for robustness:\n 1. STEPControl_Reader for basic shape extraction (more reliable)\n 2. STEPCAFControl_Reader for assembly hierarchy (optional enhancement)\n \"\"\"\n def __init__(\n self,\n linear_deflection: float = 0.1,", + "detail": "packages.cad-service.src.parser", + "documentation": {} + }, + { + "label": "logger", + "kind": 5, + "importPath": "packages.cad-service.src.parser", + "description": "packages.cad-service.src.parser", + "peekOfCode": "logger = logging.getLogger(__name__)\n@dataclass\nclass Mesh:\n \"\"\"Triangle mesh data for a single part\"\"\"\n id: str\n name: str\n vertices: np.ndarray # Nx3 float32\n normals: np.ndarray # Nx3 float32\n indices: np.ndarray # Mx3 uint32\n color: Optional[list[float]] = None # RGBA", + "detail": "packages.cad-service.src.parser", + "documentation": {} + }, + { + "label": "test_health_check", + "kind": 2, + "importPath": "packages.cad-service.tests.test_parser", + "description": "packages.cad-service.tests.test_parser", + "peekOfCode": "def test_health_check():\n \"\"\"Test that the health check endpoint returns OK\"\"\"\n from src.main import app\n client = TestClient(app)\n response = client.get(\"/health\")\n assert response.status_code == 200\n data = response.json()\n assert data[\"status\"] == \"ok\"\n assert \"version\" in data\n assert \"opencascade_version\" in data", + "detail": "packages.cad-service.tests.test_parser", + "documentation": {} + }, + { + "label": "test_root_endpoint", + "kind": 2, + "importPath": "packages.cad-service.tests.test_parser", + "description": "packages.cad-service.tests.test_parser", + "peekOfCode": "def test_root_endpoint():\n \"\"\"Test root endpoint returns API info\"\"\"\n from src.main import app\n client = TestClient(app)\n response = client.get(\"/\")\n assert response.status_code == 200\n data = response.json()\n assert data[\"service\"] == \"Carbon CAD Service\"\n assert \"endpoints\" in data\ndef test_parse_invalid_extension():", + "detail": "packages.cad-service.tests.test_parser", + "documentation": {} + }, + { + "label": "test_parse_invalid_extension", + "kind": 2, + "importPath": "packages.cad-service.tests.test_parser", + "description": "packages.cad-service.tests.test_parser", + "peekOfCode": "def test_parse_invalid_extension():\n \"\"\"Test that non-STEP files are rejected\"\"\"\n from src.main import app\n client = TestClient(app)\n # Create a fake file with wrong extension\n files = {\"file\": (\"model.obj\", b\"some content\", \"application/octet-stream\")}\n response = client.post(\"/parse\", files=files)\n assert response.status_code == 200\n data = response.json()\n assert data[\"success\"] is False", + "detail": "packages.cad-service.tests.test_parser", + "documentation": {} + }, + { + "label": "test_gltf_writer_creates_valid_glb", + "kind": 2, + "importPath": "packages.cad-service.tests.test_parser", + "description": "packages.cad-service.tests.test_parser", + "peekOfCode": "def test_gltf_writer_creates_valid_glb():\n \"\"\"Test that the GLB writer creates valid binary glTF\"\"\"\n import numpy as np\n from src.gltf_writer import GltfWriter\n from src.parser import Mesh\n # Create a simple mesh (triangle)\n mesh = Mesh(\n id=\"test-part\",\n name=\"TestPart\",\n vertices=np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32),", + "detail": "packages.cad-service.tests.test_parser", + "documentation": {} + }, + { + "label": "test_hierarchy_to_dict", + "kind": 2, + "importPath": "packages.cad-service.tests.test_parser", + "description": "packages.cad-service.tests.test_parser", + "peekOfCode": "def test_hierarchy_to_dict():\n \"\"\"Test hierarchy conversion to dictionary\"\"\"\n from src.parser import HierarchyNode, StepParser\n parser = StepParser()\n node = HierarchyNode(\n id=\"root\",\n name=\"Assembly\",\n type=\"assembly\",\n children=[\n HierarchyNode(", + "detail": "packages.cad-service.tests.test_parser", + "documentation": {} + }, + { + "label": "test_calculate_normals", + "kind": 2, + "importPath": "packages.cad-service.tests.test_parser", + "description": "packages.cad-service.tests.test_parser", + "peekOfCode": "def test_calculate_normals():\n \"\"\"Test normal calculation for a simple triangle\"\"\"\n import numpy as np\n from src.parser import StepParser\n parser = StepParser()\n # Simple right triangle in XY plane\n vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dtype=np.float32)\n indices = np.array([[0, 1, 2]], dtype=np.uint32)\n normals = parser._calculate_normals(vertices, indices)\n # Normal should point in +Z direction", + "detail": "packages.cad-service.tests.test_parser", + "documentation": {} + }, + { + "label": "test_parse_sample_step_file", + "kind": 2, + "importPath": "packages.cad-service.tests.test_parser", + "description": "packages.cad-service.tests.test_parser", + "peekOfCode": "def test_parse_sample_step_file():\n \"\"\"Integration test with a real STEP file\"\"\"\n from src.main import app\n client = TestClient(app)\n with open(\"tests/fixtures/sample.step\", \"rb\") as f:\n files = {\"file\": (\"sample.step\", f, \"application/octet-stream\")}\n response = client.post(\"/parse\", files=files)\n assert response.status_code == 200\n data = response.json()\n assert data[\"success\"] is True", + "detail": "packages.cad-service.tests.test_parser", + "documentation": {} + } +] \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..7dd9ec4dd9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,91 @@ +# Carbon Manufacturing System - AI Agent Instructions + +## Project Overview + +Carbon is a manufacturing system with ERP, MES, and Academy applications built as a TypeScript monorepo. + +- **ERP** - Enterprise Resource Planning (primary app) +- **MES** - Manufacturing Execution System +- **Academy** - Training application +- **Starter** - Template application + +## Knowledge Base + +ALWAYS query `llm/cache/` before making changes: + +| File | Purpose | +|------|---------| +| `project-overview.md` | Architecture & structure | +| `coding-conventions.md` | Code standards | +| `authentication.md` | Auth patterns | +| `database-patterns.md` | Database conventions | +| Module-specific docs | Business logic | + +## Workflows + +Check `llm/workflows/` for documented procedures: + +- `database-migration.md` - Database changes workflow +- `edge-function.md` - Edge function development + +## Technology Stack + +- **Framework**: React Router 7 (flat routes) +- **UI**: Radix UI primitives with Tailwind CSS +- **Database**: Supabase (PostgreSQL) +- **Auth**: Supabase Auth with RBAC/ABAC +- **Forms**: Zod validation +- **Testing**: Jest with ts-jest +- **Linting**: Biome +- **Build**: Turbo monorepo + +## Development Rules + +1. Make small, incremental changes +2. Ask clarifying questions if uncertain +3. Always write tests for new code +4. Run tests before committing +5. Never commit directly to main +6. Update CHANGELOG.md for changes +7. Create PR for review before merging + +## Key Commands + +```bash +npm run dev # Start all apps +npm run dev:erp # ERP app only +npm run dev:mes # MES app only +npm run test # Run tests +npm run lint # Lint code +npm run typecheck # Type checking +npm run db:migrate # Create migration +npm run db:generate # Generate types +``` + +## File Patterns + +### App Structure +``` +components/ - React components +hooks/ - Custom React hooks +routes/ - React Router routes +services/ - Business logic & API calls +stores/ - State management +types/ - TypeScript types & validators +modules/ - Feature modules (ERP) +``` + +### Routing Conventions +- Protected routes: `x+/` prefix +- Public routes: `_public+/` prefix +- API routes: `api+/` prefix +- File serving: `file+/` prefix +- Shared/external: `share+/` prefix + +## Custom Agents + +Use these agents for specialized tasks: + +- `@db-migrate` - Database migration specialist +- `@feature-dev` - Feature development helper +- `@reviewer` - Code review assistant diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..8a024608f6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,208 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed + +#### Assembly App (`apps/assembly/`) +- Fixed race condition where CAD model wouldn't load on page refresh/navigation. Added `isViewerReady` state to ensure model loads only after xeokit viewer is initialized (`Viewer/XeokitCanvas.tsx`) + +#### CAD Service (`packages/cad-service/`) +- Fixed STEP file color extraction using official PythonOCC pattern (`GetInstanceColor` before `GetColor`) +- Added recursion depth limit and cycle detection to XCAF parser to prevent hangs +- Changed parsing order to XCAF-first (has names/colors) with simple parser as fallback +- Added comprehensive debug logging throughout XCAF parsing pipeline +- Added part name extraction logging and reference instance name fallback in parser +- Fixed tree-viewer ID matching by using hierarchy ID as GLB node name (`gltf_writer.py`) + + + +### Changed + +#### Assembly App (`apps/assembly/`) +- Improved xeokit CAD viewer rendering quality with anti-aliasing, SAO (ambient occlusion), PBR materials, gamma correction, and edge material tuning (`Viewer/XeokitCanvas.tsx`) +- Added bidirectional selection sync between prep page tree and 3D viewer - click tree node to highlight in viewer, click 3D part to select in tree (`projects.$id.prep.tsx`) + +### Added + +#### Assembly App (`apps/assembly/`) +A new standalone application for creating animated assembly work instructions from CAD files. + +**Core Features:** +- STEP file upload and CAD visualization +- Physics-based assembly sequence simulation +- Two-phase workflow: Preprocessing (tree editing) and Instruction Editing +- Video export (MP4/WebM) and PDF generation +- Shareable links for mobile viewing +- Tribal knowledge capture and learning system + +**Routes:** +- `/x/projects` - Project list with search and filter +- `/x/projects/new` - File upload with drag-and-drop +- `/x/projects/:id` - Project overview with workflow progress +- `/x/projects/:id/prep` - Phase 1: Tree editor for part renaming and reordering +- `/x/projects/:id/edit` - Phase 2: Instruction editor with animation controls +- `/x/projects/:id/export` - Video, PDF, and share link export +- `/x/settings` - App settings overview +- `/x/settings/tools` - Tool library management +- `/x/settings/torque` - Torque specification library +- `/x/settings/associations` - Part association rules for tribal knowledge + +**Components:** +- `Layout/Topbar.tsx` - Top navigation bar +- `Layout/Sidebar.tsx` - Side navigation with project links + +**BuildOS-Style Work Instruction Editor (xeokit-sdk):** +- `Viewer/XeokitCanvas.tsx` - xeokit WebGL viewer with NavCube, section planes, measurements +- `Viewer/useXeokit.ts` - React hook for viewer state and camera controls +- `WorkInstructions/WorkInstructionEditor.tsx` - Main 3-panel layout orchestrator +- `WorkInstructions/LeftPanel/` - Model tree and hierarchical step list (1.1, 1.2.1 numbering) + - `StepTree.tsx` - Hierarchical step display with grouping + - `ComponentTree.tsx` - Assembly tree viewer with expand/collapse + - `GeometriesList.tsx` - Part counts display +- `WorkInstructions/RightPanel/` - Supplement tabs for step editing + - `SupplementsTab.tsx` - Overview of tools, warnings, notes + - `ToolsTab.tsx` - Tool assignment with library search + - `NotesTab.tsx` - Step-specific notes editor + - `StandardNotesTab.tsx` - Reusable standard notes library + - `MediaTab.tsx` - Image/video attachments with drag-and-drop +- `WorkInstructions/CenterViewer/` - 3D viewer controls + - `ViewerToolbar.tsx` - View presets, exploded view, section tools + - `StepNavigation.tsx` - Step info bar with prev/next + - `PlaybackControls.tsx` - Play/pause, timeline, step markers + +**Type Definitions:** +- `types/assembly.types.ts` - Comprehensive TypeScript types (AssemblyStep, AssemblyTreeNode, ViewerState, PartAssociation with confidence scores) +- `types/xeokit-sdk.d.ts` - Type declarations for xeokit-sdk + +#### Database Schema (`packages/database/`) +New migration `20260123120000_assembly-app-standalone.sql` adding: + +**Tables:** +- `assemblyProject` - Assembly projects with status tracking, CAD file references, simulation results +- `assemblyStep` - Individual assembly steps with animation data, instructions, annotations +- `assemblyTool` - Tool library (wrenches, screwdrivers, etc.) +- `assemblyTorqueSpec` - Torque specification library with tolerances +- `assemblyStandardNote` - Reusable standard notes library (tribal knowledge, safety warnings) +- `assemblyPartAssociation` - Part matching rules for auto-applying tribal knowledge +- `assemblyAssociationUsage` - Learning system usage tracking +- `assemblyShareLink` - Shareable link tokens with expiration and password protection + +**RLS Policies:** +- All tables protected by company-based RLS using `get_companies_with_employee_permission()` +- Permission scopes: `assembly_view`, `assembly_create`, `assembly_update`, `assembly_delete` + +#### Rust Physics Simulator (`packages/cad-rust/`) +New Rust workspace for CAD processing and physics simulation: + +**Crates:** +- `cad-common` - Shared types (Position3D, BoundingBox, Transform4x4, AssemblyNode, SimulationResult) +- `cad-parser` - STEP file parsing (placeholder for truck-stepio integration) +- `cad-simulator` - Assembly-by-disassembly physics simulation using rapier3d/parry3d +- `cad-server` - HTTP API server (Axum) with `/health`, `/parse`, and `/simulate` endpoints +- `cad-wasm` - Browser WASM module for client-side animation interpolation + +**Physics Simulation:** +- Assembly-by-disassembly algorithm for sequence generation +- 6-direction removal testing (+X, -X, +Y, -Y, +Z, -Z) +- Collision detection using parry3d +- Gravitational stability checking +- AABB-based bounding box calculations + +**WASM Module:** +- `Keyframe` struct for animation data +- `interpolate_keyframes()` - Slerp for rotation, lerp for position/scale +- `to_matrix4()` - Convert transforms to 4x4 matrix (WebGL format) +- `ease_in_out()` - Smooth animation easing +- `ExplodedViewConfig` - Exploded view generation +- `calculate_exploded_position()` - Part explosion calculations + +#### CAD Service - OpenCascade Integration (`packages/cad-service/`) +Python microservice using PythonOCC (OpenCascade) for production-grade STEP file parsing: + +**Architecture:** +- FastAPI application with `/parse` and `/health` endpoints +- Docker container with conda-based PythonOCC installation +- Deployed as internal ECS service (called by Trigger.dev jobs) + +**Core Features:** +- STEP file parsing using XCAF reader (full assembly support) +- B-Rep to triangle mesh tessellation (BRepMesh_IncrementalMesh) +- glTF/GLB export for browser viewing (xeokit GLTFLoaderPlugin) +- Assembly hierarchy extraction with colors and transforms +- Configurable tessellation tolerance (linear/angular) + +**Files:** +- `src/main.py` - FastAPI application +- `src/parser.py` - PythonOCC STEP parsing with STEPCAFControl_Reader +- `src/gltf_writer.py` - GLB binary format export +- `Dockerfile` - conda-based container with pythonocc-core 7.7.2 +- `docker-compose.yml` - Local development setup + +**Trigger.dev Integration:** +- `step-parser-occ.ts` - New task for OpenCascade parsing workflow + - Progress tracking with `metadata.set()` (0-100%) + - Downloads STEP from Supabase Storage + - Uploads GLB to `{companyId}/assembly/{projectId}/model.glb` + - Updates `assemblyProject` with hierarchy and modelPath +- `assembly-simulate.ts` - New task for physics simulation + - Fetches assemblyProject's assemblyTree + - Calls Rust cad-server `/simulate` endpoint + - Creates `assemblyStep` records from simulation result + - Updates project status to "editing" + +**Database Migration:** +- `20260125131824_assembly-project-opencascade.sql` - Adds `modelPath`, `parsingProgress`, `parsingError` columns + +**Deployment:** +- Added `CarbonCADService` to `sst.config.ts` +- 1 vCPU, 4 GB memory (CAD processing requirements) +- Auto-scaling 1-5 instances + +**Instruction Editor (`projects.$id.edit.tsx`):** +- Implemented step saving with action function +- Upserts assemblyStep records to database +- Updates project timestamp on save +- Loading state indicator on Save button + +### Changed + +#### Root `package.json` +- Added `dev:assembly` script for running the assembly app on port 3002 + +### Fixed + +#### Rust Physics Simulator (`packages/cad-rust/`) +- Fixed unsafe `.unwrap()` calls in `cad-simulator/src/simulator.rs` +- Fixed unsafe `.unwrap()` calls in `cad-parser/src/mesh_converter.rs` +- Replaced with safe pattern matching and error handling + +--- + +## How to Use + +### Development + +```bash +# Start assembly app +npm run dev:assembly + +# Build Rust components +cd packages/cad-rust +cargo build --release + +# Build WASM module +wasm-pack build packages/cad-rust/cad-wasm --target web +``` + +### Database + +```bash +# Apply migrations +npm run db:migrate +``` 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/app/components/Layout/Sidebar.tsx b/apps/assembly/app/components/Layout/Sidebar.tsx new file mode 100644 index 0000000000..183c39835f --- /dev/null +++ b/apps/assembly/app/components/Layout/Sidebar.tsx @@ -0,0 +1,86 @@ +import { cn } from "@carbon/react"; +import { + BsFolder2Open, + BsGear, + BsHouseDoor, + BsTools, + BsWrench +} from "react-icons/bs"; +import { Link, useLocation } from "react-router"; +import { path } from "~/utils/path"; + +interface NavItem { + label: string; + to: string; + icon: React.ReactNode; +} + +const mainNavItems: NavItem[] = [ + { + label: "Dashboard", + to: path.to.dashboard, + icon: + }, + { + label: "Projects", + to: path.to.projects, + icon: + } +]; + +const settingsNavItems: NavItem[] = [ + { + label: "Settings", + to: path.to.settings, + icon: + }, + { + label: "Tools Library", + to: path.to.settingsTools, + icon: + }, + { + label: "Torque Specs", + to: path.to.settingsTorque, + icon: + } +]; + +function NavLink({ item }: { item: NavItem }) { + const location = useLocation(); + const isActive = + location.pathname === item.to || + location.pathname.startsWith(item.to + "/"); + + return ( + + {item.icon} + {item.label} + + ); +} + +export function Sidebar() { + return ( + + ); +} diff --git a/apps/assembly/app/components/Layout/Topbar.tsx b/apps/assembly/app/components/Layout/Topbar.tsx new file mode 100644 index 0000000000..f696b26d1c --- /dev/null +++ b/apps/assembly/app/components/Layout/Topbar.tsx @@ -0,0 +1,35 @@ +import { Button } from "@carbon/react"; +import { BsBoxSeam } 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; + }>(); + + return ( +
+
+ + + Assembly + + {company?.name && ( + {company.name} + )} +
+
+ {user && ( + + {user.firstName} {user.lastName} + + )} + +
+
+ ); +} diff --git a/apps/assembly/app/components/Layout/index.ts b/apps/assembly/app/components/Layout/index.ts new file mode 100644 index 0000000000..a31d18cd9a --- /dev/null +++ b/apps/assembly/app/components/Layout/index.ts @@ -0,0 +1,2 @@ +export { Sidebar } from "./Sidebar"; +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..bb8a5b0058 --- /dev/null +++ b/apps/assembly/app/components/Viewer/XeokitCanvas.tsx @@ -0,0 +1,324 @@ +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; + 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, + 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); + + // Check if we're on the client + useEffect(() => { + setIsClient(true); + }, []); + + // Initialize viewer - only on client + useEffect(() => { + if (!isClient) return; + if (viewerRef.current) return; + + // Dynamically import xeokit-sdk only on client + import("@xeokit/xeokit-sdk").then((xeokit) => { + 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 + }); + + // Set dark background + viewer.scene.canvas.canvas.style.background = "#1a1a2e"; + + // Enable SAO (Scalable Ambient Occlusion) for depth/shadow effects + viewer.scene.sao.enabled = true; + viewer.scene.sao.intensity = 0.25; // Subtle shadows + viewer.scene.sao.bias = 0.5; + viewer.scene.sao.scale = 500; + viewer.scene.sao.kernelRadius = 100; + + // Better gamma correction for color accuracy + viewer.scene.gammaOutput = true; + viewer.scene.gammaFactor = 2.2; + + // Configure edge material for better visibility + viewer.scene.edgeMaterial.edgeColor = [0.1, 0.1, 0.1]; + viewer.scene.edgeMaterial.edgeAlpha = 0.5; + viewer.scene.edgeMaterial.edgeWidth = 1; + + // 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); + onPartSelected?.(entityId, entityId); + } else { + console.log("[VIEWER] Clicked empty space"); + onPartSelected?.(null, null); + } + }); + + viewerRef.current = viewer; + setIsViewerReady(true); + onViewerReady?.(viewer); + }); + + return () => { + if (viewerRef.current) { + viewerRef.current.destroy(); + viewerRef.current = null; + setIsViewerReady(false); + } + }; + }, [isClient, canvasId, navCubeCanvasId, onViewerReady, onPartSelected]); + + // Load model when URL changes or viewer becomes ready + useEffect(() => { + if (!modelUrl || !isViewerReady || !viewerRef.current) return; + + const viewer = viewerRef.current; + + // Clear existing models + const existingModels = Object.keys(viewer.scene.models); + existingModels.forEach((modelId) => { + viewer.scene.models[modelId]?.destroy(); + }); + + // Load new model + if (modelFormat === "xkt" && xktLoaderRef.current) { + xktLoaderRef.current.load({ + id: "assembly", + src: modelUrl, + edges: true + }); + } else if (modelFormat === "gltf" && gltfLoaderRef.current) { + gltfLoaderRef.current.load({ + id: "assembly", + src: modelUrl, + edges: true + }); + } + + // Fit to view after load + setTimeout(() => { + viewer.cameraFlight.flyTo({ + aabb: viewer.scene.aabb, + duration: 0.5 + }); + }, 500); + }, [modelUrl, modelFormat, isViewerReady]); + + // Update highlighted parts + useEffect(() => { + if (!isViewerReady || !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, isViewerReady]); + + // Update hidden parts + useEffect(() => { + if (!isViewerReady || !viewerRef.current) return; + const viewer = viewerRef.current; + + // Show all first + viewer.scene.setObjectsVisible(viewer.scene.objectIds, true); + + // Hide specified parts + if (hiddenPartIds.length > 0) { + viewer.scene.setObjectsVisible(hiddenPartIds, false); + } + }, [hiddenPartIds, isViewerReady]); + + 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..7c4f6480a2 --- /dev/null +++ b/apps/assembly/app/components/Viewer/index.ts @@ -0,0 +1,9 @@ +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/useXeokit.ts b/apps/assembly/app/components/Viewer/useXeokit.ts new file mode 100644 index 0000000000..2da086faa3 --- /dev/null +++ b/apps/assembly/app/components/Viewer/useXeokit.ts @@ -0,0 +1,235 @@ +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 [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); + }, []); + + 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, + selectedPartId, + viewerState, + + // Event handlers for XeokitCanvas + handleViewerReady, + 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..e1a59f9d32 --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/CenterViewer/PlaybackControls.tsx @@ -0,0 +1,159 @@ +import { cn } from "@carbon/react"; +import type { AssemblyStep } from "~/types/assembly.types"; + +export interface PlaybackControlsProps { + steps: 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, + selectedStepIndex, + isPlaying, + onPlay, + onPause, + onSkipToStart, + onSkipToEnd, + onPrevious, + onNext, + onStepSelect +}: PlaybackControlsProps) { + return ( +
+ {/* Timeline */} +
+
+ {/* Progress */} +
+ + {/* Step Markers */} +
+ {steps.map((_, index) => ( + + ))} +
+
+
+ + {/* Controls */} +
+ {/* Skip to Start */} + + + {/* Previous */} + + + {/* Play/Pause */} + + + {/* Next */} + + + {/* Skip to End */} + +
+ + {/* Step Counter */} +
+ {selectedStepIndex + 1} / {steps.length} steps +
+
+ ); +} diff --git a/apps/assembly/app/components/WorkInstructions/CenterViewer/StepNavigation.tsx b/apps/assembly/app/components/WorkInstructions/CenterViewer/StepNavigation.tsx new file mode 100644 index 0000000000..94d84e8204 --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/CenterViewer/StepNavigation.tsx @@ -0,0 +1,70 @@ +import { cn } from "@carbon/react"; +import type { AssemblyStep } from "~/types/assembly.types"; + +export interface StepNavigationProps { + currentStep?: AssemblyStep; + stepIndex: number; + totalSteps: number; + onPrevious: () => void; + onNext: () => void; +} + +export function StepNavigation({ + currentStep, + stepIndex, + totalSteps, + onPrevious, + onNext +}: StepNavigationProps) { + const hasPrevious = stepIndex > 0; + const hasNext = stepIndex < totalSteps - 1; + + return ( +
+
+ {/* Previous Button */} + + + {/* Step Info */} +
+
+ Step {stepIndex + 1} of {totalSteps} +
+ {currentStep && ( +
+ [{currentStep.stepNumber}]{" "} + {currentStep.title || currentStep.partNames.join(", ")} +
+ )} +
+ + {/* Next Button */} + +
+
+ ); +} 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..c57f00bfb8 --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/CenterViewer/ViewerToolbar.tsx @@ -0,0 +1,138 @@ +import { cn } from "@carbon/react"; + +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: string; + 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 */} +
+
+ Views +
+
+ {viewButtons.map((button) => ( + + ))} +
+
+ + {/* Tools */} +
+
+ Tools +
+
+ {toolButtons.map((button) => ( + + ))} +
+
+
+ ); +} 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..9dbb505b49 --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/LeftPanel/ComponentTree.tsx @@ -0,0 +1,117 @@ +import { cn } from "@carbon/react"; +import { useState } from "react"; +import { + BsBox, + BsChevronDown, + BsChevronRight, + BsCollection +} from "react-icons/bs"; +import type { AssemblyTreeNode } from "~/types/assembly.types"; + +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; + const isAssembly = node.type === "assembly"; + + 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..d6fa695cdb --- /dev/null +++ b/apps/assembly/app/components/WorkInstructions/RightPanel/MediaTab.tsx @@ -0,0 +1,171 @@ +import { cn } from "@carbon/react"; +import { useCallback, useRef, useState } from "react"; +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. +

+ +