From d711742300c70e1c3f1aa9bdaa5072cf016b7b3e Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Sat, 15 Aug 2026 16:55:19 +0800 Subject: [PATCH] feat(installation-demo): add printable installation drawings --- .../[token]/object/route.ts | 15 + .../drawing-captures/route.ts | 138 + .../numbering-snapshots/route.ts | 10 +- .../InstallationDemoShareDrawingPage.tsx | 136 + ...nstallationDemoShareDrawingViewer.test.tsx | 173 + .../InstallationDemoShareDrawingViewer.tsx | 141 + .../InstallationDemoShareViewerShell.tsx | 60 + ...stallationDemoShareDrawingDocument.test.ts | 156 + .../installationDemoShareDrawingDocument.ts | 200 ++ .../installation-demo-share/[token]/page.tsx | 26 +- .../InstallationDemoWorkbenchClient.tsx | 2842 +++++++++++------ .../components/InstallationDetailsPanel.tsx | 21 + .../InstallationDrawingActionsPage.tsx | 184 ++ .../InstallationDrawingAutoCaptureControl.tsx | 98 + .../InstallationDrawingCaptureToolbar.tsx | 38 + ...llationDrawingConfirmationControl.test.tsx | 32 + ...InstallationDrawingConfirmationControl.tsx | 50 + .../InstallationDrawingImageAdjustDialog.tsx | 157 + .../InstallationDrawingImageFrame.test.tsx | 34 + .../InstallationDrawingImageFrame.tsx | 95 + .../InstallationDrawingInventoryPage.tsx | 98 + ...llationDrawingMarkerManagerDialog.test.tsx | 66 + ...InstallationDrawingMarkerManagerDialog.tsx | 151 + .../InstallationDrawingNumberMarkers.test.tsx | 174 + .../InstallationDrawingNumberMarkers.tsx | 129 + .../InstallationDrawingOverviewActions.tsx | 50 + .../InstallationDrawingOverviewInventory.tsx | 79 + .../InstallationDrawingOverviewPage.tsx | 174 + .../components/InstallationDrawingSheet.tsx | 653 ++++ .../InstallationDrawingSheetScroll.test.tsx | 130 + ...lationDrawingToolLineArtGenerator.test.tsx | 84 + ...nstallationDrawingToolLineArtGenerator.tsx | 66 + .../components/InstallationInspector.tsx | 3 + .../InstallationNumberingMarkerLayer.tsx | 17 +- .../InstallationNumberingOverlay.tsx | 4 + .../components/InstallationPreviewStage.tsx | 42 +- ...seInstallationDrawingActionCaptureFrame.ts | 150 + .../useInstallationDrawingAutoCapture.ts | 563 ++++ .../hooks/useInstallationDrawingCapture.ts | 431 +++ .../useInstallationDrawingConfirmation.ts | 79 + .../useInstallationDrawingMarkerInventory.ts | 101 + ...InstallationDrawingOverviewCaptureFrame.ts | 79 + .../hooks/useInstallationDrawingTools.ts | 21 +- .../useInstallationStepActionWorkspace.ts | 24 +- ...tallationDrawingActionCaptureFrame.test.ts | 268 ++ .../installationDrawingActionCaptureFrame.ts | 184 ++ ...installationDrawingAutoCapturePlan.test.ts | 27 + .../lib/installationDrawingAutoCapturePlan.ts | 17 + .../lib/installationDrawingCaptureApi.ts | 232 ++ ...tallationDrawingCaptureFingerprint.test.ts | 35 + .../installationDrawingCaptureFingerprint.ts | 29 + .../installationDrawingCaptureMarkers.test.ts | 49 + .../lib/installationDrawingCaptureMarkers.ts | 39 + ...nstallationDrawingCaptureNumbering.test.ts | 53 + .../installationDrawingCaptureNumbering.ts | 32 + ...allationDrawingCapturePreservation.test.ts | 49 + .../installationDrawingCapturePreservation.ts | 19 + .../installationDrawingCaptureTypes.test.ts | 34 + .../lib/installationDrawingCaptureTypes.ts | 216 ++ .../lib/installationDrawingLayout.test.ts | 98 + .../lib/installationDrawingLayout.ts | 179 ++ ...installationDrawingMarkerInventory.test.ts | 45 + .../lib/installationDrawingMarkerInventory.ts | 104 + .../lib/installationDrawingTemplates.ts | 27 + .../installationDrawingToolSelection.test.ts | 34 + .../lib/installationDrawingToolSelection.ts | 21 + .../lib/installationNumberingSnapshotApi.ts | 12 +- ...ationNumberingSnapshotBatchBuilder.test.ts | 108 + ...stallationNumberingSnapshotBatchBuilder.ts | 110 + .../installationNumberingSnapshotStorage.ts | 6 +- furniture-3d-system/src/lib/3d/engine.ts | 36 +- .../src/lib/3d/technical-line-camera.ts | 83 + .../src/lib/3d/technical-line-capture.test.ts | 66 + .../src/lib/3d/technical-line-capture.ts | 137 + .../lib/3d/technical-line-geometry.test.ts | 60 + .../src/lib/3d/technical-line-geometry.ts | 233 ++ .../src/lib/installation-demo-share-types.ts | 122 +- ...tion-demo-drawing-capture-marker-counts.ts | 33 + ...lation-demo-drawing-capture-replacement.ts | 47 + ...stallation-demo-drawing-capture-storage.ts | 91 + ...ation-demo-drawing-capture.service.test.ts | 393 +++ ...stallation-demo-drawing-capture.service.ts | 948 ++++++ ...on-demo-numbering-snapshot.service.test.ts | 10 +- ...llation-demo-numbering-snapshot.service.ts | 77 +- .../installation-demo-share.service.test.ts | 196 +- .../installation-demo-share.service.ts | 453 ++- 86 files changed, 11702 insertions(+), 1284 deletions(-) create mode 100644 furniture-3d-system/src/app/api/installation-demo-workbench/drawing-captures/route.ts create mode 100644 furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingViewer.test.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingViewer.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareViewerShell.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingAutoCaptureControl.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingCaptureToolbar.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingConfirmationControl.test.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingConfirmationControl.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageAdjustDialog.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageFrame.test.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageFrame.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingInventoryPage.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingMarkerManagerDialog.test.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingMarkerManagerDialog.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingNumberMarkers.test.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingNumberMarkers.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewActions.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewInventory.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewPage.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingToolLineArtGenerator.test.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingToolLineArtGenerator.tsx create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingActionCaptureFrame.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingAutoCapture.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingCapture.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingConfirmation.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingMarkerInventory.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingOverviewCaptureFrame.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureApi.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureFingerprint.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureFingerprint.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureMarkers.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureMarkers.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureNumbering.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureNumbering.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCapturePreservation.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCapturePreservation.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureTypes.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureTypes.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingLayout.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingLayout.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingMarkerInventory.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingMarkerInventory.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingTemplates.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingToolSelection.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingToolSelection.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotBatchBuilder.test.ts create mode 100644 furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotBatchBuilder.ts create mode 100644 furniture-3d-system/src/lib/3d/technical-line-camera.ts create mode 100644 furniture-3d-system/src/lib/3d/technical-line-capture.test.ts create mode 100644 furniture-3d-system/src/lib/3d/technical-line-capture.ts create mode 100644 furniture-3d-system/src/lib/3d/technical-line-geometry.test.ts create mode 100644 furniture-3d-system/src/lib/3d/technical-line-geometry.ts create mode 100644 furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-marker-counts.ts create mode 100644 furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-replacement.ts create mode 100644 furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-storage.ts create mode 100644 furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture.service.test.ts create mode 100644 furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture.service.ts diff --git a/furniture-3d-system/src/app/api/installation-demo-share/[token]/object/route.ts b/furniture-3d-system/src/app/api/installation-demo-share/[token]/object/route.ts index 5c5c70a5..8a9669a2 100644 --- a/furniture-3d-system/src/app/api/installation-demo-share/[token]/object/route.ts +++ b/furniture-3d-system/src/app/api/installation-demo-share/[token]/object/route.ts @@ -34,6 +34,20 @@ export async function GET( let allowedKeys = new Set() try { const snapshot = await installationDemoPublicViewerService.getPublic(token) + const drawingDocumentUrls = snapshot.drawingDocument + ? [ + snapshot.drawingDocument.captures.finished.originalUrl, + snapshot.drawingDocument.captures.finished.lineArtUrl, + snapshot.drawingDocument.captures.exploded.originalUrl, + snapshot.drawingDocument.captures.exploded.lineArtUrl, + ...snapshot.drawingDocument.captures.actions.flatMap((capture) => [ + capture.image.originalUrl, + capture.image.lineArtUrl, + ]), + ...Object.values(snapshot.drawingDocument.snapshotLineArtUrls), + ...Object.values(snapshot.drawingDocument.toolSnapshotLineArtUrls), + ].filter((url): url is string => Boolean(url)) + : [] allowedKeys = new Set( [ snapshot.modelUrl, @@ -43,6 +57,7 @@ export async function GET( ...Object.values(snapshot.stepMaterialsByStepId).flatMap((materialSnapshot) => Object.values(materialSnapshot.thumbnailUrls), ), + ...drawingDocumentUrls, ] .map((url) => readObjectStorageKeyFromUrl(url)) .filter((storageKey): storageKey is string => Boolean(storageKey)), diff --git a/furniture-3d-system/src/app/api/installation-demo-workbench/drawing-captures/route.ts b/furniture-3d-system/src/app/api/installation-demo-workbench/drawing-captures/route.ts new file mode 100644 index 00000000..61ed9a76 --- /dev/null +++ b/furniture-3d-system/src/app/api/installation-demo-workbench/drawing-captures/route.ts @@ -0,0 +1,138 @@ +import { NextRequest } from "next/server"; +import { AppError } from "@/server/core/errors"; +import { handleRequest, jsonError, jsonSuccess } from "@/server/core/response"; +import { installationDemoDrawingCaptureService } from "@/server/services/installation-demo/installation-demo-drawing-capture.service"; + +async function readImage(formData: FormData, field: string) { + const entry = formData.get(field); + if ( + !entry || + typeof entry === "string" || + typeof entry !== "object" || + !("arrayBuffer" in entry) || + typeof entry.arrayBuffer !== "function" || + !("type" in entry) + ) { + throw new Error(`${field} 图片缺失`); + } + return { + bytes: Buffer.from(await entry.arrayBuffer()), + contentType: + typeof entry.type === "string" && entry.type ? entry.type : "image/png", + }; +} + +function parseJson(value: FormDataEntryValue | null) { + if (typeof value !== "string") return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function respondWithAppError(error: unknown, requestId: string) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, requestId); + } + throw error; +} + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + try { + const snapshot = await installationDemoDrawingCaptureService.get( + ctx.user, + request.nextUrl.searchParams.get("drawingId"), + request.nextUrl.searchParams.get("modelUrl"), + ); + return jsonSuccess({ snapshot }, 200, ctx.requestId); + } catch (error) { + return respondWithAppError(error, ctx.requestId); + } + }, + { requireAuth: true }, + ); +} + +export async function POST(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + try { + const contentType = request.headers.get("content-type") ?? ""; + if (contentType.includes("multipart/form-data")) { + const formData = await request.formData(); + const imageInput = { + drawingId: formData.get("drawingId"), + modelUrl: formData.get("modelUrl"), + targetKey: formData.get("targetKey"), + transform: parseJson(formData.get("transform")), + numberingMarkers: parseJson(formData.get("numberingMarkers")), + numberingMarkerCountOverrides: parseJson( + formData.get("numberingMarkerCountOverrides"), + ), + captureAspectRatio: parseJson(formData.get("captureAspectRatio")), + original: await readImage(formData, "original"), + lineArt: await readImage(formData, "lineArt"), + }; + const snapshot = + formData.get("operation") === "replace" + ? await installationDemoDrawingCaptureService.replace(ctx.user, { + ...imageInput, + configFingerprint: formData.get("configFingerprint"), + expectedTargetKeys: parseJson( + formData.get("expectedTargetKeys"), + ), + }) + : await installationDemoDrawingCaptureService.upload(ctx.user, { + ...imageInput, + batchId: formData.get("batchId"), + }); + return jsonSuccess({ snapshot }, 200, ctx.requestId); + } + + const body = await request.json(); + const snapshot = + body?.operation === "finalize" + ? await installationDemoDrawingCaptureService.finalize( + ctx.user, + body, + ) + : body?.operation === "confirm" + ? await installationDemoDrawingCaptureService.confirm( + ctx.user, + body, + ) + : await installationDemoDrawingCaptureService.begin(ctx.user, body); + return jsonSuccess({ snapshot }, 200, ctx.requestId); + } catch (error) { + return respondWithAppError(error, ctx.requestId); + } + }, + { requireAuth: true }, + ); +} + +export async function PATCH(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + try { + const snapshot = + await installationDemoDrawingCaptureService.updateLayout( + ctx.user, + await request.json(), + ); + return jsonSuccess({ snapshot }, 200, ctx.requestId); + } catch (error) { + return respondWithAppError(error, ctx.requestId); + } + }, + { requireAuth: true }, + ); +} diff --git a/furniture-3d-system/src/app/api/installation-demo-workbench/numbering-snapshots/route.ts b/furniture-3d-system/src/app/api/installation-demo-workbench/numbering-snapshots/route.ts index 6393dac0..997426ef 100644 --- a/furniture-3d-system/src/app/api/installation-demo-workbench/numbering-snapshots/route.ts +++ b/furniture-3d-system/src/app/api/installation-demo-workbench/numbering-snapshots/route.ts @@ -25,8 +25,8 @@ function parseReferences(value: FormDataEntryValue | null) { } } -async function readUploads(formData: FormData, field: 'part' | 'tool') { - const references = parseReferences(formData.get(`${field}Snapshots`)) +async function readUploads(formData: FormData, manifestField: string) { + const references = parseReferences(formData.get(manifestField)) return Promise.all( references.map(async (reference) => { const entry = formData.get(reference.field) @@ -83,8 +83,10 @@ export async function POST(request: NextRequest) { drawingId: formData.get('drawingId'), modelUrl: formData.get('modelUrl'), modelIdentity: formData.get('modelIdentity'), - partSnapshots: await readUploads(formData, 'part'), - toolSnapshots: await readUploads(formData, 'tool'), + partSnapshots: await readUploads(formData, 'partSnapshots'), + partLineArtSnapshots: await readUploads(formData, 'partLineArtSnapshots'), + toolSnapshots: await readUploads(formData, 'toolSnapshots'), + toolLineArtSnapshots: await readUploads(formData, 'toolLineArtSnapshots'), }) return jsonSuccess({ snapshot }, 200, ctx.requestId) } catch (error) { diff --git a/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx new file mode 100644 index 00000000..efab831c --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingPage.tsx @@ -0,0 +1,136 @@ +import type { InstallationDemoShareSnapshot } from '@/lib/installation-demo-share-types' +import { InstallationDrawingActionsPage } from '@/app/installation-demo-workbench/components/InstallationDrawingActionsPage' +import { InstallationDrawingInventoryPage } from '@/app/installation-demo-workbench/components/InstallationDrawingInventoryPage' +import { InstallationDrawingOverviewPage } from '@/app/installation-demo-workbench/components/InstallationDrawingOverviewPage' +import type { InstallationDrawingInventoryItem } from '@/app/installation-demo-workbench/components/InstallationDrawingOverviewInventory' +import type { + InstallationDrawingCaptures, + InstallationDrawingImageMode, +} from '@/app/installation-demo-workbench/lib/installationDrawingCaptureTypes' +import type { InstallationDrawingPage } from '@/app/installation-demo-workbench/lib/installationDrawingLayout' +import type { InstallationDrawingPanel } from '@/app/installation-demo-workbench/lib/installationDrawingLayout' + +interface InstallationDemoShareDrawingPageProps { + snapshot: InstallationDemoShareSnapshot + page: InstallationDrawingPage + captures: InstallationDrawingCaptures + inventoryItems: InstallationDrawingInventoryItem[] + imageMode: InstallationDrawingImageMode + snapshotLineArtUrls: Record +} + +export const SHARED_DRAWING_PAGE_WIDTH = 1120 +export const SHARED_DRAWING_PAGE_HEIGHT = 792 +export const SHARED_DRAWING_HALF_PAGE_WIDTH = SHARED_DRAWING_PAGE_WIDTH / 2 + +interface InstallationDemoShareDrawingPanelProps extends Omit< + InstallationDemoShareDrawingPageProps, + 'page' +> { + panel: InstallationDrawingPanel +} + +function InstallationDemoShareDrawingPanel({ + snapshot, + panel, + captures, + inventoryItems, + imageMode, + snapshotLineArtUrls, +}: InstallationDemoShareDrawingPanelProps) { + return ( + <> + {panel.kind === 'overview' ? ( + + ) : null} + {panel.kind === 'inventory' ? ( + + ) : null} + {panel.kind === 'actions' ? ( + + ) : null} + + ) +} + +export function InstallationDemoShareDrawingPage({ + snapshot, + page, + captures, + inventoryItems, + imageMode, + snapshotLineArtUrls, +}: InstallationDemoShareDrawingPageProps) { + return ( +
+ {page.panels.map((panel, panelIndex) => ( +
0 ? 'border-l border-[#202526]/20 pl-4' : 'pr-4'}`} + > + +
+ ))} + {page.panels.length === 1 ? ( +
+ ) : null} +
+ ) +} + +export function InstallationDemoShareDrawingHalfPage({ + snapshot, + panel, + captures, + inventoryItems, + imageMode, + snapshotLineArtUrls, +}: InstallationDemoShareDrawingPanelProps) { + return ( +
+
+ +
+
+ ) +} diff --git a/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingViewer.test.tsx b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingViewer.test.tsx new file mode 100644 index 00000000..24803f8f --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingViewer.test.tsx @@ -0,0 +1,173 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { InstallationDemoShareSnapshot } from "@/lib/installation-demo-share-types"; +import * as drawingDocumentModule from "./installationDemoShareDrawingDocument"; +import type { InstallationDemoShareDrawingPresentation } from "./installationDemoShareDrawingDocument"; +import { InstallationDemoShareDrawingViewer } from "./InstallationDemoShareDrawingViewer"; +import { InstallationDemoShareViewerShell } from "./InstallationDemoShareViewerShell"; + +jest.mock("./InstallationDemoShareViewerClient", () => ({ + InstallationDemoShareViewerClient: () =>
, +})); + +jest.mock("./installationDemoShareDrawingDocument", () => ({ + ...jest.requireActual("./installationDemoShareDrawingDocument"), + buildInstallationDemoShareDrawingPresentation: jest.fn(), +})); + +jest.mock("./InstallationDemoShareDrawingPage", () => ({ + SHARED_DRAWING_PAGE_WIDTH: 1120, + SHARED_DRAWING_PAGE_HEIGHT: 792, + SHARED_DRAWING_HALF_PAGE_WIDTH: 560, + InstallationDemoShareDrawingPage: ({ + page, + imageMode, + }: { + page: { id: string }; + imageMode: string; + }) => ( +
+ {page.id} +
+ ), + InstallationDemoShareDrawingHalfPage: ({ + panel, + imageMode, + }: { + panel: { id: string }; + imageMode: string; + }) => ( +
+ {panel.id} +
+ ), +})); + +const snapshot = { + drawing: { + id: "drawing-1", + code: "XJ0096K01AV01", + modelCode: "XJ0096K01", + spuCode: "XJ0096", + }, +} as InstallationDemoShareSnapshot; + +const presentation = { + available: true, + unavailableReason: null, + imageMode: "line-art", + captures: {}, + inventoryItems: [], + snapshotLineArtUrls: {}, + layout: { + templateId: "set-1", + paper: "A4", + orientation: "landscape", + pages: [ + { + id: "page-1", + panels: [ + { id: "overview", kind: "overview" }, + { + id: "inventory-1", + kind: "inventory", + inventoryStart: 0, + inventoryEnd: 16, + }, + ], + }, + { + id: "page-2", + panels: [{ id: "actions-1", kind: "actions", actionGroups: [] }], + }, + ], + }, +} as InstallationDemoShareDrawingPresentation; + +function setViewportWidth(width: number) { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: width, + }); +} + +describe("InstallationDemoShareDrawingViewer", () => { + it("shows every complete A4 page in one desktop scroll without paging controls", () => { + setViewportWidth(1200); + render( + , + ); + + expect(screen.getAllByTestId("full-page")).toHaveLength(2); + expect(screen.queryByTestId("half-page")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "上一页" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "下一页" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("combobox", { name: "选择图纸页码" }), + ).not.toBeInTheDocument(); + }); + + it("shows every content half-page in one mobile scroll without a duplicate header", () => { + setViewportWidth(390); + render( + , + ); + + expect(screen.getAllByTestId("half-page")).toHaveLength(3); + expect(screen.getAllByTestId("half-page")[0]).toHaveAttribute( + "data-image-mode", + "line-art", + ); + expect(screen.queryByTestId("full-page")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("图纸图片模式")).not.toBeInTheDocument(); + }); + + it("keeps only the centered animation and drawing switch in the top shell", () => { + setViewportWidth(390); + jest + .mocked( + drawingDocumentModule.buildInstallationDemoShareDrawingPresentation, + ) + .mockReturnValue(presentation); + + render(); + fireEvent.click(screen.getByRole("button", { name: "安装图纸" })); + + const viewControl = screen.getByLabelText("安装演示查看方式"); + expect(viewControl).toHaveClass("left-1/2", "-translate-x-1/2"); + expect(screen.queryByLabelText("图纸图片模式")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "打印线稿" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "原图" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("XJ0096 / XJ0096K01 / XJ0096K01AV01"), + ).not.toBeInTheDocument(); + }); + + it("renders the original images when that mode was fixed into the share", () => { + setViewportWidth(390); + render( + , + ); + + expect(screen.getAllByTestId("half-page")[0]).toHaveAttribute( + "data-image-mode", + "original", + ); + }); +}); diff --git a/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingViewer.tsx b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingViewer.tsx new file mode 100644 index 00000000..dd17fce1 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareDrawingViewer.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { FileText } from "lucide-react"; +import type { InstallationDemoShareSnapshot } from "@/lib/installation-demo-share-types"; +import type { InstallationDemoShareDrawingPresentation } from "./installationDemoShareDrawingDocument"; +import { + InstallationDemoShareDrawingHalfPage, + InstallationDemoShareDrawingPage, + SHARED_DRAWING_HALF_PAGE_WIDTH, + SHARED_DRAWING_PAGE_HEIGHT, + SHARED_DRAWING_PAGE_WIDTH, +} from "./InstallationDemoShareDrawingPage"; + +interface InstallationDemoShareDrawingViewerProps { + snapshot: InstallationDemoShareSnapshot; + presentation: InstallationDemoShareDrawingPresentation; +} + +export function InstallationDemoShareDrawingViewer({ + snapshot, + presentation, +}: InstallationDemoShareDrawingViewerProps) { + const [pageScale, setPageScale] = useState(1); + const [halfPageScale, setHalfPageScale] = useState(1); + const [mobileLayout, setMobileLayout] = useState(false); + const stageRef = useRef(null); + const pages = presentation.layout?.pages ?? []; + const halfPages = pages.flatMap((page) => page.panels); + + useEffect(() => { + const stage = stageRef.current; + if (!stage) return; + const updateScale = () => { + const bounds = stage.getBoundingClientRect(); + const viewportWidth = bounds.width || window.innerWidth; + setMobileLayout(viewportWidth < 768); + setPageScale( + Math.min( + 1, + Math.max(0.1, (viewportWidth - 48) / SHARED_DRAWING_PAGE_WIDTH), + ), + ); + setHalfPageScale( + Math.min( + 1, + Math.max(0.1, (viewportWidth - 16) / SHARED_DRAWING_HALF_PAGE_WIDTH), + ), + ); + }; + updateScale(); + const observer = new ResizeObserver(updateScale); + observer.observe(stage); + return () => observer.disconnect(); + }, []); + + if ( + !presentation.available || + !presentation.captures || + !presentation.layout || + pages.length === 0 + ) { + return ( +
+
+ +

图纸尚未完成

+

+ {presentation.unavailableReason ?? + "请联系业务人员重新同步分享内容。"} +

+
+
+ ); + } + + const captures = presentation.captures; + + return ( +
+
+
+ {mobileLayout + ? halfPages.map((panel) => ( +
+
+ +
+
+ )) + : pages.map((page) => ( +
+
+ +
+
+ ))} +
+
+
+ ); +} diff --git a/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareViewerShell.tsx b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareViewerShell.tsx new file mode 100644 index 00000000..3789744a --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-share/[token]/InstallationDemoShareViewerShell.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Box, FileText } from "lucide-react"; +import type { InstallationDemoShareSnapshot } from "@/lib/installation-demo-share-types"; +import { InstallationDemoShareViewerClient } from "./InstallationDemoShareViewerClient"; +import { InstallationDemoShareDrawingViewer } from "./InstallationDemoShareDrawingViewer"; +import { buildInstallationDemoShareDrawingPresentation } from "./installationDemoShareDrawingDocument"; + +interface InstallationDemoShareViewerShellProps { + snapshot: InstallationDemoShareSnapshot; +} + +type ShareView = "animation" | "drawing"; + +export function InstallationDemoShareViewerShell({ + snapshot, +}: InstallationDemoShareViewerShellProps) { + const [activeView, setActiveView] = useState("animation"); + const drawingPresentation = useMemo( + () => buildInstallationDemoShareDrawingPresentation(snapshot), + [snapshot], + ); + + return ( +
+ + + {activeView === "animation" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts b/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts new file mode 100644 index 00000000..b6c7fda1 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.test.ts @@ -0,0 +1,156 @@ +import type { InstallationDemoShareSnapshot } from "@/lib/installation-demo-share-types"; +import { buildInstallationDemoShareDrawingPresentation } from "./installationDemoShareDrawingDocument"; + +function createImage() { + return { + originalUrl: "/api/storage/object?key=drawings%2Foriginal.png", + lineArtUrl: "/api/storage/object?key=drawings%2Fline-art.png", + transform: { scale: 1, offsetX: 0, offsetY: 0 }, + numberingMarkers: [], + numberingMarkerCountOverrides: {}, + captureAspectRatio: 1.75, + }; +} + +function createSnapshot(): InstallationDemoShareSnapshot { + return { + version: 1, + token: "a".repeat(32), + drawing: { + id: "drawing-1", + code: "D-1", + modelCode: "M-1", + spuCode: "SPU-1", + }, + modelUrl: "/api/storage/object?key=models%2Fmodel.glb", + uploadedModel: null, + parts: [], + numberingParts: [], + drawingTools: [], + numberingView: null, + numberingSnapshotUrls: {}, + numberingToolSnapshotUrls: {}, + stepMaterialsByStepId: {}, + steps: [], + drawingDocument: { + templateId: "set-1", + imageMode: "line-art", + captures: { + finished: createImage(), + exploded: createImage(), + actions: [], + inventoryTransforms: {}, + }, + snapshotLineArtUrls: {}, + toolSnapshotLineArtUrls: {}, + }, + initialView: null, + createdAt: "2026-08-15T00:00:00.000Z", + expiresAt: null, + createdBy: { id: "user-1", name: "User" }, + }; +} + +describe("buildInstallationDemoShareDrawingPresentation", () => { + it("publishes a complete read-only drawing as one A4 page", () => { + const presentation = + buildInstallationDemoShareDrawingPresentation(createSnapshot()); + + expect(presentation.available).toBe(true); + expect(presentation.layout?.paper).toBe("A4"); + expect(presentation.layout?.orientation).toBe("landscape"); + expect(presentation.layout?.pages).toHaveLength(1); + expect(presentation.imageMode).toBe("line-art"); + }); + + it("uses the image mode fixed into the shared drawing document", () => { + const snapshot = createSnapshot(); + snapshot.drawingDocument!.imageMode = "original"; + + expect( + buildInstallationDemoShareDrawingPresentation(snapshot).imageMode, + ).toBe("original"); + }); + + it("keeps the drawing tab unavailable when a captured image is incomplete", () => { + const snapshot = createSnapshot(); + snapshot.drawingDocument!.captures.finished.lineArtUrl = null; + + const presentation = + buildInstallationDemoShareDrawingPresentation(snapshot); + + expect(presentation.available).toBe(false); + expect(presentation.unavailableReason).toContain("1 张安装画面"); + }); + + it("keeps the drawing tab unavailable while a number marker is not placed", () => { + const snapshot = createSnapshot(); + snapshot.drawingDocument!.captures.exploded.numberingMarkers = [ + { + id: "marker-1", + partId: null, + code: "8", + x: 0.9, + y: 0.1, + placed: false, + }, + ]; + + const presentation = + buildInstallationDemoShareDrawingPresentation(snapshot); + + expect(presentation.available).toBe(false); + expect(presentation.unavailableReason).toContain("1 个编号待放置"); + }); + + it("keeps configured tool images when an action references the same tool code", () => { + const snapshot = createSnapshot(); + snapshot.drawingTools = [ + { + id: "tool-1", + toolCode: "TOOL-1", + name: "Screwdriver", + modelUrl: "/api/storage/object?key=models%2Fscrewdriver.obj", + fileName: "screwdriver.obj", + mountOffset: { x: 0, y: 0, z: 0 }, + mountRotation: { x: 0, y: 0, z: 0, w: 1 }, + motionType: "spin", + motionPivot: null, + motionAxis: null, + spinTurns: 1, + strikeDistanceRatio: 0.2, + createdAt: "2026-08-15T00:00:00.000Z", + updatedAt: "2026-08-15T00:00:00.000Z", + }, + ]; + snapshot.numberingToolSnapshotUrls = { "tool-1": "/tool-original.png" }; + snapshot.drawingDocument!.toolSnapshotLineArtUrls = { + "tool-1": "/tool-line-art.png", + }; + snapshot.drawingDocument!.captures.actions = [ + { + id: "capture-1", + stepId: "step-1", + frameKind: "action", + actionId: "action-1", + sequence: 1, + image: createImage(), + materials: [], + tools: [{ code: "TOOL-1", name: "Screwdriver" }], + }, + ]; + + const presentation = + buildInstallationDemoShareDrawingPresentation(snapshot); + + expect(presentation.available).toBe(true); + expect(presentation.inventoryItems).toContainEqual( + expect.objectContaining({ + id: "tool-1", + code: "TOOL-1", + originalImageUrl: "/tool-original.png", + lineArtImageUrl: "/tool-line-art.png", + }), + ); + }); +}); diff --git a/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.ts b/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.ts new file mode 100644 index 00000000..1fd406a2 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-share/[token]/installationDemoShareDrawingDocument.ts @@ -0,0 +1,200 @@ +import type { InstallationDemoShareSnapshot } from "@/lib/installation-demo-share-types"; +import { buildInstallationDrawingInventoryItems } from "@/app/installation-demo-workbench/components/InstallationDrawingOverviewInventory"; +import type { + InstallationDrawingOverviewTool, + InstallationDrawingInventoryItem, +} from "@/app/installation-demo-workbench/components/InstallationDrawingOverviewInventory"; +import { + isInstallationDrawingImageCaptured, + type InstallationDrawingCapturedImage, + type InstallationDrawingCaptures, + type InstallationDrawingImageMode, +} from "@/app/installation-demo-workbench/lib/installationDrawingCaptureTypes"; +import { + buildInstallationDrawingActionMarkerSources, + buildInstallationDrawingExplodedMarkerSources, + countInstallationDrawingUnplacedMarkers, + reconcileInstallationDrawingNumberMarkers, +} from "@/app/installation-demo-workbench/lib/installationDrawingMarkerInventory"; +import { + buildInstallationDrawingLayout, + type InstallationDrawingLayout, +} from "@/app/installation-demo-workbench/lib/installationDrawingLayout"; + +export interface InstallationDemoShareDrawingPresentation { + available: boolean; + unavailableReason: string | null; + imageMode: InstallationDrawingImageMode; + captures: InstallationDrawingCaptures | null; + inventoryItems: InstallationDrawingInventoryItem[]; + layout: InstallationDrawingLayout | null; + snapshotLineArtUrls: Record; +} + +function buildOverviewTools( + snapshot: InstallationDemoShareSnapshot, +): InstallationDrawingOverviewTool[] { + const document = snapshot.drawingDocument; + if (!document) return []; + const capturedTools = document.captures.actions.flatMap( + (action) => action.tools, + ); + const toolsByCode = new Map( + snapshot.drawingTools.map((tool) => [ + tool.toolCode, + { + id: tool.id, + code: tool.toolCode, + name: tool.name, + quantity: 1, + imageUrl: snapshot.numberingToolSnapshotUrls[tool.id], + lineArtImageUrl: document.toolSnapshotLineArtUrls[tool.id], + }, + ]), + ); + + capturedTools.forEach((tool) => { + if (toolsByCode.has(tool.code)) return; + toolsByCode.set(tool.code, { + id: `captured:${tool.code}`, + code: tool.code, + name: tool.name, + quantity: 1, + }); + }); + + return Array.from(toolsByCode.values()); +} + +function reconcileImage( + image: InstallationDrawingCapturedImage, + sources: ReturnType, +) { + if (!isInstallationDrawingImageCaptured(image)) return image; + return { + ...image, + numberingMarkers: reconcileInstallationDrawingNumberMarkers({ + markers: image.numberingMarkers, + sources, + countOverrides: image.numberingMarkerCountOverrides, + }), + }; +} + +function reconcileCaptures( + snapshot: InstallationDemoShareSnapshot, +): InstallationDrawingCaptures | null { + const document = snapshot.drawingDocument; + if (!document) return null; + const explodedSources = buildInstallationDrawingExplodedMarkerSources( + snapshot.numberingParts, + ); + + return { + ...document.captures, + exploded: reconcileImage(document.captures.exploded, explodedSources), + actions: document.captures.actions.map((capture) => ({ + ...capture, + image: reconcileImage( + capture.image, + buildInstallationDrawingActionMarkerSources(capture), + ), + })), + }; +} + +function countMissingImages( + captures: InstallationDrawingCaptures, + layout: InstallationDrawingLayout, +) { + const expectedActionIds = layout.pages.flatMap((page) => + page.panels.flatMap((panel) => + panel.kind === "actions" + ? (panel.actionGroups ?? []).flatMap((group) => + group.actions.map((action) => action.id), + ) + : [], + ), + ); + const captureById = new Map( + captures.actions.map((capture) => [capture.id, capture]), + ); + return ( + Number(!isInstallationDrawingImageCaptured(captures.finished)) + + Number(!isInstallationDrawingImageCaptured(captures.exploded)) + + expectedActionIds.filter((id) => { + const capture = captureById.get(id); + return !capture || !isInstallationDrawingImageCaptured(capture.image); + }).length + ); +} + +function countUnplacedMarkers(captures: InstallationDrawingCaptures) { + return ( + countInstallationDrawingUnplacedMarkers( + captures.exploded.numberingMarkers, + ) + + captures.actions.reduce( + (count, capture) => + count + + countInstallationDrawingUnplacedMarkers(capture.image.numberingMarkers), + 0, + ) + ); +} + +export function buildInstallationDemoShareDrawingPresentation( + snapshot: InstallationDemoShareSnapshot, +): InstallationDemoShareDrawingPresentation { + const document = snapshot.drawingDocument; + const captures = reconcileCaptures(snapshot); + if (!document || !captures) { + return { + available: false, + unavailableReason: "图纸尚未生成完整", + imageMode: "line-art", + captures: null, + inventoryItems: [], + layout: null, + snapshotLineArtUrls: {}, + }; + } + + const inventoryItems = buildInstallationDrawingInventoryItems({ + parts: snapshot.parts, + numberingParts: snapshot.numberingParts, + tools: buildOverviewTools(snapshot), + snapshotUrls: snapshot.numberingSnapshotUrls, + snapshotLineArtUrls: document.snapshotLineArtUrls, + toolSnapshotUrls: snapshot.numberingToolSnapshotUrls, + toolSnapshotLineArtUrls: document.toolSnapshotLineArtUrls, + }); + const layout = buildInstallationDrawingLayout({ + templateId: document.templateId, + steps: snapshot.steps, + inventoryItemCount: inventoryItems.length, + }); + const missingImageCount = countMissingImages(captures, layout); + const missingInventoryImageCount = inventoryItems.filter( + (item) => !item.originalImageUrl || !item.lineArtImageUrl, + ).length; + const unplacedMarkerCount = countUnplacedMarkers(captures); + const unavailableReason = + missingImageCount > 0 + ? `还有 ${missingImageCount} 张安装画面未完成` + : missingInventoryImageCount > 0 + ? `还有 ${missingInventoryImageCount} 个部件图未完成` + : unplacedMarkerCount > 0 + ? `还有 ${unplacedMarkerCount} 个编号待放置` + : null; + + return { + available: unavailableReason === null, + unavailableReason, + imageMode: document.imageMode, + captures, + inventoryItems, + layout, + snapshotLineArtUrls: document.snapshotLineArtUrls, + }; +} diff --git a/furniture-3d-system/src/app/installation-demo-share/[token]/page.tsx b/furniture-3d-system/src/app/installation-demo-share/[token]/page.tsx index d8e6264e..3db03608 100644 --- a/furniture-3d-system/src/app/installation-demo-share/[token]/page.tsx +++ b/furniture-3d-system/src/app/installation-demo-share/[token]/page.tsx @@ -2,7 +2,7 @@ import { AppError, ErrorCode } from '@/server/core/errors' import { installationDemoPublicViewerService } from '@/server/services/installation-demo/installation-demo-public-viewer.service' import type { InstallationDemoShareSnapshot } from '@/lib/installation-demo-share-types' import { readObjectStorageKeyFromUrl } from '@/lib/storage' -import { InstallationDemoShareViewerClient } from './InstallationDemoShareViewerClient' +import { InstallationDemoShareViewerShell } from './InstallationDemoShareViewerShell' export const metadata = { title: '安装演示', @@ -34,6 +34,27 @@ function withPublicModelUrl(snapshot: InstallationDemoShareSnapshot): Installati } const toPublicObjectUrlMap = (urls: Record) => Object.fromEntries(Object.entries(urls).map(([id, url]) => [id, toPublicObjectUrl(url)])) + const toPublicDrawingImage = (image: T): T => ({ + ...image, + originalUrl: image.originalUrl ? toPublicObjectUrl(image.originalUrl) : null, + lineArtUrl: image.lineArtUrl ? toPublicObjectUrl(image.lineArtUrl) : null, + }) + const drawingDocument = snapshot.drawingDocument + ? { + ...snapshot.drawingDocument, + captures: { + ...snapshot.drawingDocument.captures, + finished: toPublicDrawingImage(snapshot.drawingDocument.captures.finished), + exploded: toPublicDrawingImage(snapshot.drawingDocument.captures.exploded), + actions: snapshot.drawingDocument.captures.actions.map((capture) => ({ + ...capture, + image: toPublicDrawingImage(capture.image), + })), + }, + snapshotLineArtUrls: toPublicObjectUrlMap(snapshot.drawingDocument.snapshotLineArtUrls), + toolSnapshotLineArtUrls: toPublicObjectUrlMap(snapshot.drawingDocument.toolSnapshotLineArtUrls), + } + : null return { ...snapshot, @@ -53,6 +74,7 @@ function withPublicModelUrl(snapshot: InstallationDemoShareSnapshot): Installati }, ]), ), + drawingDocument, } } @@ -76,7 +98,7 @@ export default async function InstallationDemoSharePage({ } if (snapshot) { - return + return } return diff --git a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx index c8a47623..f188ff3b 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx @@ -1,52 +1,58 @@ -'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { ComponentProps } from 'react' -import type { CameraViewSnapshot } from '@/lib/3d/camera-controller' -import type { ModelPreviewCapture, ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel' -import type { ThreeDEngine } from '@/lib/3d/engine' -import type { InstallationDemoWorkbenchAccess } from '@/lib/installation-demo-workbench-permissions' -import type { CreateInstallationDemoShareInput } from '@/lib/installation-demo-share-types' +"use client"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ComponentProps } from "react"; +import type { CameraViewSnapshot } from "@/lib/3d/camera-controller"; +import type { + ModelPreviewCapture, + ModelPreviewPanel, +} from "@/components/platform/ModelPreviewPanel"; +import type { ThreeDEngine } from "@/lib/3d/engine"; +import type { InstallationDemoWorkbenchAccess } from "@/lib/installation-demo-workbench-permissions"; +import type { + CreateInstallationDemoShareInput, + InstallationDemoQrShareDTO, +} from "@/lib/installation-demo-share-types"; import { createInstallationDemoShareLink, fetchInstallationDemoNumberingConfig, saveInstallationDemoNumberingConfig, syncInstallationDemoQrShare, -} from '@/lib/open-product/platform-api' +} from "@/lib/open-product/platform-api"; import { applyInstallationCameraView, buildInstallationCameraViewKey, captureInstallationCameraView, -} from './lib/installationCameraView' -import { collectInstallationModelNodeSnapshots } from './lib/installationModelGeometry' -import { normalizeInstallationStepDrafts } from './lib/installationStepDraftBuilder' -import { buildInstallationStepMaterialShareSnapshotsByStepId } from './lib/installationStepMaterialShareSnapshots' +} from "./lib/installationCameraView"; +import { collectInstallationModelNodeSnapshots } from "./lib/installationModelGeometry"; +import { normalizeInstallationStepDrafts } from "./lib/installationStepDraftBuilder"; +import { buildInstallationStepMaterialShareSnapshotsByStepId } from "./lib/installationStepMaterialShareSnapshots"; import { buildPartToken, getPartNodeNames, -} from './lib/installationPartCatalogHelpers' +} from "./lib/installationPartCatalogHelpers"; import { resolveRepresentativeNumberingNodeNames, resolveNumberingRepresentativeHiddenNodeNames, -} from './lib/installationNumberingRepresentativeNodes' +} from "./lib/installationNumberingRepresentativeNodes"; import { buildInstallationNumberingDisplayParts, resolveInstallationNumberingCategory, -} from './lib/installationNumberingDisplayParts' +} from "./lib/installationNumberingDisplayParts"; import { resolveExplosionBoardNodeNames, resolveExplosionHiddenNodeNames, resolveExplosionNumberingParts, resolveExplosionOverlayNumberingParts, -} from './lib/installationExplosionNumberingDisplay' +} from "./lib/installationExplosionNumberingDisplay"; import { buildHardwareCompositeTemplate, resolveHardwareCompositeCandidateGroups, -} from './lib/installationHardwareComposite' -import { applySingleToolDefaultBindings } from './lib/installationToolBindingDefaults' +} from "./lib/installationHardwareComposite"; +import { applySingleToolDefaultBindings } from "./lib/installationToolBindingDefaults"; import { readStoredInstallationNumberingViews, writeStoredInstallationNumberingViews, -} from './lib/installationNumberingViewStorage' +} from "./lib/installationNumberingViewStorage"; import { readStoredInstallationNumberingSnapshotBatch, readStoredInstallationNumberingSnapshotDrafts, @@ -54,42 +60,62 @@ import { writeStoredInstallationNumberingSnapshotDrafts, type InstallationNumberingSnapshotBatch, type InstallationNumberingSnapshotDraft, -} from './lib/installationNumberingSnapshotStorage' +} from "./lib/installationNumberingSnapshotStorage"; import { fetchInstallationNumberingSnapshotBatch, saveInstallationNumberingSnapshotBatch as saveInstallationNumberingSnapshotBatchToServer, -} from './lib/installationNumberingSnapshotApi' -import { isInstallationDemoLocalRuntime } from './lib/installationDemoRuntimeStorage' +} from "./lib/installationNumberingSnapshotApi"; +import { isInstallationDemoLocalRuntime } from "./lib/installationDemoRuntimeStorage"; import { bindInstallationNumberingSnapshotToRuntimeModel, installationNumberingSnapshotMatchesModel, - resolveInstallationNumberingSnapshotModelIdentity, -} from './lib/installationNumberingSnapshotModel' +} from "./lib/installationNumberingSnapshotModel"; +import { + buildInstallationNumberingSnapshotBatch, + restoreInstallationNumberingSnapshotBatchForShare, +} from "./lib/installationNumberingSnapshotBatchBuilder"; import { buildInstallationStepBoardAttentionParts, buildInstallationStepMaterialItems, buildInstallationStepMaterialMarkerParts, -} from './lib/installationStepMaterials' -import { resolveInstallationPreviewRuntimePresentation } from './lib/installationPreviewRuntimePresentation' +} from "./lib/installationStepMaterials"; +import { resolveInstallationPreviewRuntimePresentation } from "./lib/installationPreviewRuntimePresentation"; import { resolveInstallationStepHardwareFocusNodeNames, shouldUseSingleRepresentativeHardwareFocus, -} from './lib/installationHardwareFocusCamera' -import { NUMBERING_ITEM_PLAYBACK_DURATION_MS } from './lib/installationNumberingOverlayConstants' -import { useInstallationCatalogController } from './hooks/useInstallationCatalogController' -import { useInstallationModelUploadController } from './hooks/useInstallationModelUploadController' -import { useInstallationNumberingCatalog } from './hooks/useInstallationNumberingCatalog' -import { useInstallationPartCatalog } from './hooks/useInstallationPartCatalog' -import { useInstallationPreviewController } from './hooks/useInstallationPreviewController' -import { useInstallationDrawingTools } from './hooks/useInstallationDrawingTools' -import { useInstallationStepActionCamera } from './hooks/useInstallationStepActionCamera' -import { useInstallationStepActionWorkspace } from './hooks/useInstallationStepActionWorkspace' -import { useInstallationStepCameraPlayback } from './hooks/useInstallationStepCameraPlayback' -import { useInstallationStepEditor } from './hooks/useInstallationStepEditor' -import { useInstallationStepMaterialThumbnails } from './hooks/useInstallationStepMaterialThumbnails' -import { InstallationInspector } from './components/InstallationInspector' -import { InstallationPreviewStage } from './components/InstallationPreviewStage' -import { StepActionEditor } from './components/StepActionEditor' +} from "./lib/installationHardwareFocusCamera"; +import { NUMBERING_ITEM_PLAYBACK_DURATION_MS } from "./lib/installationNumberingOverlayConstants"; +import { useInstallationCatalogController } from "./hooks/useInstallationCatalogController"; +import { useInstallationModelUploadController } from "./hooks/useInstallationModelUploadController"; +import { useInstallationNumberingCatalog } from "./hooks/useInstallationNumberingCatalog"; +import { useInstallationPartCatalog } from "./hooks/useInstallationPartCatalog"; +import { useInstallationPreviewController } from "./hooks/useInstallationPreviewController"; +import { useInstallationDrawingTools } from "./hooks/useInstallationDrawingTools"; +import { useInstallationDrawingOverviewCaptureFrame } from "./hooks/useInstallationDrawingOverviewCaptureFrame"; +import { useInstallationDrawingActionCaptureFrame } from "./hooks/useInstallationDrawingActionCaptureFrame"; +import { useInstallationStepActionCamera } from "./hooks/useInstallationStepActionCamera"; +import { useInstallationStepActionWorkspace } from "./hooks/useInstallationStepActionWorkspace"; +import { useInstallationStepCameraPlayback } from "./hooks/useInstallationStepCameraPlayback"; +import { useInstallationStepEditor } from "./hooks/useInstallationStepEditor"; +import { useInstallationStepMaterialThumbnails } from "./hooks/useInstallationStepMaterialThumbnails"; +import { InstallationInspector } from "./components/InstallationInspector"; +import { InstallationPreviewStage } from "./components/InstallationPreviewStage"; +import { StepActionEditor } from "./components/StepActionEditor"; +import { InstallationDrawingSheet } from "./components/InstallationDrawingSheet"; +import { InstallationDrawingCaptureToolbar } from "./components/InstallationDrawingCaptureToolbar"; +import { InstallationDrawingToolLineArtGenerator } from "./components/InstallationDrawingToolLineArtGenerator"; +import { useInstallationDrawingCapture } from "./hooks/useInstallationDrawingCapture"; +import { useInstallationDrawingAutoCapture } from "./hooks/useInstallationDrawingAutoCapture"; +import { useInstallationDrawingConfirmation } from "./hooks/useInstallationDrawingConfirmation"; +import { buildInstallationDrawingCaptureFingerprint } from "./lib/installationDrawingCaptureFingerprint"; +import { + isInstallationDrawingActionCaptureTarget, + isInstallationDrawingOverviewCaptureTarget, +} from "./lib/installationDrawingCaptureTypes"; +import type { + InstallationDrawingImageMode, + InstallationDrawingMarkerSnapshot, +} from "./lib/installationDrawingCaptureTypes"; import type { InstallationPartRole, InstallationPartItem, @@ -101,148 +127,290 @@ import type { InstallationExplosionCategory, InstallationNumberingDisplayCategory, InstallationToolItem, -} from './types' +} from "./types"; -type PreviewNodeSelectHandler = NonNullable['onNodeSelect']> +type PreviewNodeSelectHandler = NonNullable< + ComponentProps["onNodeSelect"] +>; interface HardwareCompositeCandidateDraft { - partId: string - groups: string[][] + partId: string; + groups: string[][]; } interface InstallationDemoWorkbenchClientProps { - initialAccess: InstallationDemoWorkbenchAccess + initialAccess: InstallationDemoWorkbenchAccess; } export default function InstallationDemoWorkbenchClient({ initialAccess, }: InstallationDemoWorkbenchClientProps) { - const [activeTab, setActiveTab] = useState('structure') - const [detailView, setDetailView] = useState('finished') - const [selectionRole, setSelectionRole] = useState('install') - const [mode, setMode] = useState('edit') - const [playing, setPlaying] = useState(false) - const [stepPlaybackResetKey, setStepPlaybackResetKey] = useState(0) - const [modelNodeNames, setModelNodeNames] = useState([]) - const [modelNodeSnapshots, setModelNodeSnapshots] = useState([]) - const [previewEngine, setPreviewEngine] = useState(null) - const [explosionGizmoMode, setExplosionGizmoMode] = useState<'translate' | 'rotate'>('translate') - const [explosionTransforming, setExplosionTransforming] = useState(false) - const [explosionSelectedNodeNames, setExplosionSelectedNodeNames] = useState([]) - const [numberingSelectedNodeNames, setNumberingSelectedNodeNames] = useState([]) - const [numberingCategory, setNumberingCategory] = useState('part') - const [numberingPreviewToolId, setNumberingPreviewToolId] = useState(null) + const [activeTab, setActiveTab] = + useState("structure"); + const [detailView, setDetailView] = + useState("finished"); + const [selectionRole, setSelectionRole] = + useState("install"); + const [mode, setMode] = useState("edit"); + const [playing, setPlaying] = useState(false); + const [stepPlaybackResetKey, setStepPlaybackResetKey] = useState(0); + const [modelNodeNames, setModelNodeNames] = useState([]); + const [modelNodeSnapshots, setModelNodeSnapshots] = useState< + InstallationModelNodeSnapshot[] + >([]); + const [previewEngine, setPreviewEngine] = useState(null); + const [explosionGizmoMode, setExplosionGizmoMode] = useState< + "translate" | "rotate" + >("translate"); + const [explosionTransforming, setExplosionTransforming] = useState(false); + const [explosionSelectedNodeNames, setExplosionSelectedNodeNames] = useState< + string[] + >([]); + const [numberingSelectedNodeNames, setNumberingSelectedNodeNames] = useState< + string[] + >([]); + const [numberingCategory, setNumberingCategory] = + useState("part"); + const [numberingPreviewToolId, setNumberingPreviewToolId] = useState< + string | null + >(null); const [explosionNumberingCategory, setExplosionNumberingCategory] = - useState('part') - const [hardwareCompositeDefinitionPartId, setHardwareCompositeDefinitionPartId] = useState(null) - const [hardwareCompositeDraftNodeNames, setHardwareCompositeDraftNodeNames] = useState([]) + useState("part"); + const [ + hardwareCompositeDefinitionPartId, + setHardwareCompositeDefinitionPartId, + ] = useState(null); + const [hardwareCompositeDraftNodeNames, setHardwareCompositeDraftNodeNames] = + useState([]); const [hardwareCompositeCandidate, setHardwareCompositeCandidate] = - useState(null) - const [numberingRevealCount, setNumberingRevealCount] = useState(0) - const [numberingPlaying, setNumberingPlaying] = useState(false) - const [numberingSnapshotDraftsByDrawingId, setNumberingSnapshotDraftsByDrawingId] = - useState>( - readStoredInstallationNumberingSnapshotDrafts, - ) - const [confirmedNumberingSnapshotBatchesByDrawingId, setConfirmedNumberingSnapshotBatchesByDrawingId] = - useState>({}) - const [numberingSnapshotSaving, setNumberingSnapshotSaving] = useState(false) - const [numberingSnapshotMessage, setNumberingSnapshotMessage] = useState(null) - const [shareUrl, setShareUrl] = useState(null) - const [shareExpiresAt, setShareExpiresAt] = useState(null) - const [shareLoading, setShareLoading] = useState(false) - const [shareMessage, setShareMessage] = useState(null) - const [shareCopied, setShareCopied] = useState(false) - const [qrShareUrl, setQrShareUrl] = useState(null) - const [qrShareLoading, setQrShareLoading] = useState(false) - const [qrShareMessage, setQrShareMessage] = useState(null) - const [numberingViewsByDrawingId, setNumberingViewsByDrawingId] = - useState>(readStoredInstallationNumberingViews) - const previewEngineRef = useRef(null) - const autoAppliedNumberingViewKeyRef = useRef(null) - const numberingEntryCameraViewRef = useRef(null) - const previousStageViewRef = useRef(null) - const numberingViewsByDrawingIdRef = useRef(numberingViewsByDrawingId) - const serverLoadedNumberingViewDrawingIdsRef = useRef>(new Set()) + useState(null); + const [numberingRevealCount, setNumberingRevealCount] = useState(0); + const [numberingPlaying, setNumberingPlaying] = useState(false); + const [ + numberingSnapshotDraftsByDrawingId, + setNumberingSnapshotDraftsByDrawingId, + ] = useState>( + readStoredInstallationNumberingSnapshotDrafts, + ); + const [ + confirmedNumberingSnapshotBatchesByDrawingId, + setConfirmedNumberingSnapshotBatchesByDrawingId, + ] = useState>({}); + const [numberingSnapshotSaving, setNumberingSnapshotSaving] = useState(false); + const [numberingLineArtGenerating, setNumberingLineArtGenerating] = + useState(false); + const [numberingSnapshotMessage, setNumberingSnapshotMessage] = useState< + string | null + >(null); + const [shareUrl, setShareUrl] = useState(null); + const [shareExpiresAt, setShareExpiresAt] = useState(null); + const [shareLoading, setShareLoading] = useState(false); + const [shareMessage, setShareMessage] = useState(null); + const [shareCopied, setShareCopied] = useState(false); + const [qrShareUrl, setQrShareUrl] = useState(null); + const [qrShareLoading, setQrShareLoading] = useState(false); + const [qrShareMessage, setQrShareMessage] = useState(null); + const [drawingSheetOpen, setDrawingSheetOpen] = useState(false); + const [drawingSheetScrollPosition, setDrawingSheetScrollPosition] = useState<{ + drawingId: string | null; + scrollTop: number; + }>({ drawingId: null, scrollTop: 0 }); + const [numberingViewsByDrawingId, setNumberingViewsByDrawingId] = useState< + Record + >(readStoredInstallationNumberingViews); + const previewEngineRef = useRef(null); + const previewCaptureRef = useRef(null); + const drawingCaptureMarkersRef = useRef({ + markers: [], + aspectRatio: null, + }); + const numberingLineArtGenerationRef = useRef(false); + const handlePreviewCaptureReady = useCallback( + (capture: ModelPreviewCapture | null) => { + previewCaptureRef.current = capture; + }, + [], + ); + const autoAppliedNumberingViewKeyRef = useRef(null); + const numberingEntryCameraViewRef = useRef(null); + const previousStageViewRef = useRef(null); + const numberingViewsByDrawingIdRef = useRef(numberingViewsByDrawingId); + const serverLoadedNumberingViewDrawingIdsRef = useRef>(new Set()); - const catalog = useInstallationCatalogController(initialAccess) - const selectedDrawing = catalog.selectedDrawing - const selectedDrawingId = selectedDrawing?.id ?? null - const upload = useInstallationModelUploadController(selectedDrawingId, catalog.access.canEdit) - const partCatalog = useInstallationPartCatalog(selectedDrawingId, modelNodeSnapshots, catalog.access.canEdit) - const numberingCatalog = useInstallationNumberingCatalog(selectedDrawingId, catalog.access.canEdit) - const stepEditor = useInstallationStepEditor(selectedDrawingId, catalog.access.canEdit) - const drawingTools = useInstallationDrawingTools(selectedDrawingId, catalog.access.canEdit) + const catalog = useInstallationCatalogController(initialAccess); + const selectedDrawing = catalog.selectedDrawing; + const selectedDrawingId = selectedDrawing?.id ?? null; + const upload = useInstallationModelUploadController( + selectedDrawingId, + catalog.access.canEdit, + ); + const partCatalog = useInstallationPartCatalog( + selectedDrawingId, + modelNodeSnapshots, + catalog.access.canEdit, + ); + const numberingCatalog = useInstallationNumberingCatalog( + selectedDrawingId, + catalog.access.canEdit, + ); + const stepEditor = useInstallationStepEditor( + selectedDrawingId, + catalog.access.canEdit, + ); + const drawingTools = useInstallationDrawingTools( + selectedDrawingId, + catalog.access.canEdit, + ); const uploadedModel = selectedDrawing - ? upload.uploadedModels[selectedDrawing.id] ?? null - : null - const modelUrl = uploadedModel?.modelUrl ?? selectedDrawing?.modelUrl ?? null + ? (upload.uploadedModels[selectedDrawing.id] ?? null) + : null; + const modelUrl = uploadedModel?.modelUrl ?? selectedDrawing?.modelUrl ?? null; const confirmedNumberingSnapshotBatch = useMemo( () => selectedDrawingId - ? confirmedNumberingSnapshotBatchesByDrawingId[selectedDrawingId] ?? null + ? (confirmedNumberingSnapshotBatchesByDrawingId[selectedDrawingId] ?? + null) : null, [confirmedNumberingSnapshotBatchesByDrawingId, selectedDrawingId], - ) + ); const hasConfirmedNumberingSnapshotBatch = Boolean( confirmedNumberingSnapshotBatch && - installationNumberingSnapshotMatchesModel(confirmedNumberingSnapshotBatch, modelUrl), - ) + installationNumberingSnapshotMatchesModel( + confirmedNumberingSnapshotBatch, + modelUrl, + ), + ); const numberingSnapshotDraft = useMemo( - () => (selectedDrawingId ? numberingSnapshotDraftsByDrawingId[selectedDrawingId] ?? null : null), - [numberingSnapshotDraftsByDrawingId, selectedDrawingId], - ) - const numberingSnapshotUrlsByPartId = useMemo( () => - ({ - ...(hasConfirmedNumberingSnapshotBatch - ? confirmedNumberingSnapshotBatch?.snapshotUrls ?? {} - : {}), - ...(numberingSnapshotDraft && - installationNumberingSnapshotMatchesModel(numberingSnapshotDraft, modelUrl) - ? numberingSnapshotDraft.snapshotUrls - : {}), - }), + selectedDrawingId + ? (numberingSnapshotDraftsByDrawingId[selectedDrawingId] ?? null) + : null, + [numberingSnapshotDraftsByDrawingId, selectedDrawingId], + ); + const numberingSnapshotUrlsByPartId = useMemo( + () => ({ + ...(hasConfirmedNumberingSnapshotBatch + ? (confirmedNumberingSnapshotBatch?.snapshotUrls ?? {}) + : {}), + ...(numberingSnapshotDraft && + installationNumberingSnapshotMatchesModel( + numberingSnapshotDraft, + modelUrl, + ) + ? numberingSnapshotDraft.snapshotUrls + : {}), + }), [ confirmedNumberingSnapshotBatch?.snapshotUrls, hasConfirmedNumberingSnapshotBatch, modelUrl, numberingSnapshotDraft, ], - ) + ); const confirmedNumberingSnapshotUrlsByPartId = useMemo( () => hasConfirmedNumberingSnapshotBatch - ? confirmedNumberingSnapshotBatch?.snapshotUrls ?? {} + ? (confirmedNumberingSnapshotBatch?.snapshotUrls ?? {}) : {}, - [confirmedNumberingSnapshotBatch?.snapshotUrls, hasConfirmedNumberingSnapshotBatch], - ) + [ + confirmedNumberingSnapshotBatch?.snapshotUrls, + hasConfirmedNumberingSnapshotBatch, + ], + ); + const numberingSnapshotLineArtUrlsByPartId = useMemo( + () => ({ + ...(hasConfirmedNumberingSnapshotBatch + ? (confirmedNumberingSnapshotBatch?.snapshotLineArtUrls ?? {}) + : {}), + ...(numberingSnapshotDraft && + installationNumberingSnapshotMatchesModel( + numberingSnapshotDraft, + modelUrl, + ) + ? (numberingSnapshotDraft.snapshotLineArtUrls ?? {}) + : {}), + }), + [ + confirmedNumberingSnapshotBatch?.snapshotLineArtUrls, + hasConfirmedNumberingSnapshotBatch, + modelUrl, + numberingSnapshotDraft, + ], + ); const numberingToolSnapshotUrlsByToolId = useMemo( - () => - ({ - ...(hasConfirmedNumberingSnapshotBatch - ? confirmedNumberingSnapshotBatch?.toolSnapshotUrls ?? {} - : {}), - ...(numberingSnapshotDraft && - installationNumberingSnapshotMatchesModel(numberingSnapshotDraft, modelUrl) - ? numberingSnapshotDraft.toolSnapshotUrls ?? {} - : {}), - }), + () => ({ + ...(hasConfirmedNumberingSnapshotBatch + ? (confirmedNumberingSnapshotBatch?.toolSnapshotUrls ?? {}) + : {}), + ...(numberingSnapshotDraft && + installationNumberingSnapshotMatchesModel( + numberingSnapshotDraft, + modelUrl, + ) + ? (numberingSnapshotDraft.toolSnapshotUrls ?? {}) + : {}), + }), [ confirmedNumberingSnapshotBatch?.toolSnapshotUrls, hasConfirmedNumberingSnapshotBatch, modelUrl, numberingSnapshotDraft, ], - ) + ); const confirmedNumberingToolSnapshotUrlsByToolId = useMemo( () => hasConfirmedNumberingSnapshotBatch - ? confirmedNumberingSnapshotBatch?.toolSnapshotUrls ?? {} + ? (confirmedNumberingSnapshotBatch?.toolSnapshotUrls ?? {}) : {}, - [confirmedNumberingSnapshotBatch?.toolSnapshotUrls, hasConfirmedNumberingSnapshotBatch], + [ + confirmedNumberingSnapshotBatch?.toolSnapshotUrls, + hasConfirmedNumberingSnapshotBatch, + ], + ); + const numberingToolSnapshotLineArtUrlsByToolId = useMemo( + () => ({ + ...(hasConfirmedNumberingSnapshotBatch + ? (confirmedNumberingSnapshotBatch?.toolSnapshotLineArtUrls ?? {}) + : {}), + ...(numberingSnapshotDraft && + installationNumberingSnapshotMatchesModel( + numberingSnapshotDraft, + modelUrl, + ) + ? (numberingSnapshotDraft.toolSnapshotLineArtUrls ?? {}) + : {}), + }), + [ + confirmedNumberingSnapshotBatch?.toolSnapshotLineArtUrls, + hasConfirmedNumberingSnapshotBatch, + modelUrl, + numberingSnapshotDraft, + ], + ); + const drawingCapture = useInstallationDrawingCapture({ + drawingId: selectedDrawingId, + steps: stepEditor.steps, + parts: partCatalog.parts, + numberingParts: numberingCatalog.parts, + tools: drawingTools.selectedTools, + captureRef: previewCaptureRef, + numberingMarkersRef: drawingCaptureMarkersRef, + setActiveTab, + setDetailView, + setMode, + setActiveStepId: stepEditor.setActiveStepId, + }); + const drawingOverviewCaptureActive = + isInstallationDrawingOverviewCaptureTarget(drawingCapture.captureTarget); + const drawingActionCaptureTarget = isInstallationDrawingActionCaptureTarget( + drawingCapture.captureTarget, ) + ? drawingCapture.captureTarget + : null; + const drawingActionCaptureActive = Boolean(drawingActionCaptureTarget); + const drawingCapturePresentationActive = + drawingOverviewCaptureActive || drawingActionCaptureActive; const stepActionWorkspace = useInstallationStepActionWorkspace({ activeStep: stepEditor.activeStep, steps: stepEditor.steps, @@ -251,8 +419,21 @@ export default function InstallationDemoWorkbenchClient({ modelNodeNames, engine: previewEngine, canEdit: catalog.access.canEdit, + captureTarget: drawingActionCaptureTarget, updateStepWith: stepEditor.updateStepWith, - }) + }); + const drawingCaptureFingerprint = useMemo( + () => + selectedDrawingId && modelUrl + ? buildInstallationDrawingCaptureFingerprint({ + drawingId: selectedDrawingId, + modelUrl, + steps: stepEditor.steps, + tools: drawingTools.selectedTools, + }) + : null, + [drawingTools.selectedTools, modelUrl, selectedDrawingId, stepEditor.steps], + ); const preview = useInstallationPreviewController({ activeTab, detailView, @@ -265,180 +446,272 @@ export default function InstallationDemoWorkbenchClient({ drawingId: selectedDrawing?.id ?? null, modelNodeNames, engine: previewEngine, - immediate: explosionTransforming && detailView === 'exploded', + immediate: + drawingOverviewCaptureActive || + drawingActionCaptureActive || + (explosionTransforming && detailView === "exploded"), stepWorkspaceMode: stepActionWorkspace.mode, stepEditScene: stepActionWorkspace.editScene, stepEditTarget: stepActionWorkspace.editTarget, activeActionId: stepActionWorkspace.activeActionId, localWorkflowPlaying: playing, localWorkflowResetKey: stepPlaybackResetKey, - }) + }); + useInstallationDrawingOverviewCaptureFrame({ + target: drawingCapture.captureTarget, + engine: previewEngine, + modelNodeNames, + stageView: preview.stageView, + onReadyChange: drawingCapture.setCaptureFrameReady, + }); + const drawingActionCaptureFrame = useInstallationDrawingActionCaptureFrame({ + target: drawingActionCaptureTarget, + steps: stepEditor.steps, + numberingParts: numberingCatalog.parts, + modelNodeNames, + engine: previewEngine, + captureRef: previewCaptureRef, + stageView: preview.stageView, + activeStepId: stepEditor.activeStepId, + activeActionId: stepActionWorkspace.activeActionId, + selectedNodeNames: stepActionWorkspace.selectedNodeNames, + onReadyChange: drawingCapture.setCaptureFrameReady, + }); + const drawingAutoCapture = useInstallationDrawingAutoCapture({ + drawingId: selectedDrawingId, + modelUrl, + configFingerprint: drawingCaptureFingerprint, + captures: drawingCapture.captures, + captureTarget: drawingCapture.captureTarget, + captureReady: drawingCapture.captureReady, + beginCapture: drawingCapture.beginCapture, + cancelCapture: drawingCapture.cancelCapture, + captureCurrentScene: drawingCapture.captureCurrentScene, + applyPersistedBatch: drawingCapture.applyPersistedBatch, + }); const selectedExplosionPartCount = partCatalog.parts.filter((part) => - getPartNodeNames(part).some((nodeName) => explosionSelectedNodeNames.includes(nodeName)), - ).length - const numberingCategoryCounts = useMemo>( + getPartNodeNames(part).some((nodeName) => + explosionSelectedNodeNames.includes(nodeName), + ), + ).length; + const numberingCategoryCounts = useMemo< + Record + >( () => ({ - part: buildInstallationNumberingDisplayParts(numberingCatalog.parts, 'part').length, - hardware: buildInstallationNumberingDisplayParts(numberingCatalog.parts, 'hardware').length, + part: buildInstallationNumberingDisplayParts( + numberingCatalog.parts, + "part", + ).length, + hardware: buildInstallationNumberingDisplayParts( + numberingCatalog.parts, + "hardware", + ).length, tool: drawingTools.selectedTools.length, }), [drawingTools.selectedTools.length, numberingCatalog.parts], - ) + ); const numberingCategoryParts = useMemo( () => - numberingCategory === 'tool' + numberingCategory === "tool" ? [] - : buildInstallationNumberingDisplayParts(numberingCatalog.parts, numberingCategory), + : buildInstallationNumberingDisplayParts( + numberingCatalog.parts, + numberingCategory, + ), [numberingCatalog.parts, numberingCategory], - ) + ); const numberingPreviewTool = useMemo(() => { - if (numberingCategory !== 'tool') return null + if (numberingCategory !== "tool") return null; return ( - drawingTools.selectedTools.find((tool) => tool.id === numberingPreviewToolId) ?? + drawingTools.selectedTools.find( + (tool) => tool.id === numberingPreviewToolId, + ) ?? drawingTools.selectedTools[0] ?? null - ) - }, [drawingTools.selectedTools, numberingCategory, numberingPreviewToolId]) + ); + }, [drawingTools.selectedTools, numberingCategory, numberingPreviewToolId]); const numberingPreviewToolIndex = useMemo( () => numberingPreviewTool - ? Math.max(0, drawingTools.selectedTools.findIndex((tool) => tool.id === numberingPreviewTool.id)) + ? Math.max( + 0, + drawingTools.selectedTools.findIndex( + (tool) => tool.id === numberingPreviewTool.id, + ), + ) : -1, [drawingTools.selectedTools, numberingPreviewTool], - ) + ); const explosionNumberingParts = useMemo( () => - explosionNumberingCategory === 'tool' + explosionNumberingCategory === "tool" ? [] - : resolveExplosionNumberingParts(numberingCatalog.parts, explosionNumberingCategory), + : resolveExplosionNumberingParts( + numberingCatalog.parts, + explosionNumberingCategory, + ), [explosionNumberingCategory, numberingCatalog.parts], - ) - const explosionOverlayNumberingParts = useMemo( - () => { - if (explosionNumberingCategory === 'tool') return [] - return resolveExplosionOverlayNumberingParts({ - parts: numberingCatalog.parts, - category: explosionNumberingCategory, - currentPartId: numberingCatalog.selectedPartId, - }) - }, - [ - explosionNumberingCategory, - numberingCatalog.parts, - numberingCatalog.selectedPartId, - ], - ) + ); + const explosionOverlayNumberingParts = useMemo(() => { + if (explosionNumberingCategory === "tool") return []; + return resolveExplosionOverlayNumberingParts({ + parts: numberingCatalog.parts, + category: explosionNumberingCategory, + currentPartId: numberingCatalog.selectedPartId, + }); + }, [ + explosionNumberingCategory, + numberingCatalog.parts, + numberingCatalog.selectedPartId, + ]); const explosionBoardNodeNames = useMemo( () => resolveExplosionBoardNodeNames(numberingCatalog.parts), [numberingCatalog.parts], - ) + ); const explosionHardwareNodeNames = useMemo( () => - buildInstallationNumberingDisplayParts(numberingCatalog.parts, 'hardware').flatMap((part) => - getPartNodeNames(part), - ), + buildInstallationNumberingDisplayParts( + numberingCatalog.parts, + "hardware", + ).flatMap((part) => getPartNodeNames(part)), [numberingCatalog.parts], - ) + ); const explosionCurrentHardwareNodeNames = useMemo( () => - explosionNumberingCategory === 'hardware' && + explosionNumberingCategory === "hardware" && numberingCatalog.selectedPart && - resolveInstallationNumberingCategory(numberingCatalog.selectedPart) === 'hardware' + resolveInstallationNumberingCategory(numberingCatalog.selectedPart) === + "hardware" ? getPartNodeNames(numberingCatalog.selectedPart!) : [], [explosionNumberingCategory, numberingCatalog.selectedPart], - ) - const explosionHiddenNodeNames = useMemo( - () => { - if (explosionNumberingCategory === 'tool') return [] - return resolveExplosionHiddenNodeNames({ - modelNodeNames, - boardNodeNames: explosionBoardNodeNames, - hardwareNodeNames: explosionHardwareNodeNames, - currentHardwareNodeNames: explosionCurrentHardwareNodeNames, - category: explosionNumberingCategory, - }) - }, - [ - explosionBoardNodeNames, - explosionCurrentHardwareNodeNames, - explosionHardwareNodeNames, - explosionNumberingCategory, + ); + const explosionHiddenNodeNames = useMemo(() => { + if (explosionNumberingCategory === "tool") return []; + return resolveExplosionHiddenNodeNames({ modelNodeNames, - ], - ) + boardNodeNames: explosionBoardNodeNames, + hardwareNodeNames: explosionHardwareNodeNames, + currentHardwareNodeNames: explosionCurrentHardwareNodeNames, + category: explosionNumberingCategory, + }); + }, [ + explosionBoardNodeNames, + explosionCurrentHardwareNodeNames, + explosionHardwareNodeNames, + explosionNumberingCategory, + modelNodeNames, + ]); const selectedNumberingCategoryPart = - numberingCategory !== 'tool' && + numberingCategory !== "tool" && numberingCatalog.selectedPart && - resolveInstallationNumberingCategory(numberingCatalog.selectedPart) === numberingCategory + resolveInstallationNumberingCategory(numberingCatalog.selectedPart) === + numberingCategory ? numberingCatalog.selectedPart - : null + : null; const numberingModelParts = useMemo( - () => numberingCategoryParts.filter((part) => getPartNodeNames(part).length > 0), + () => + numberingCategoryParts.filter( + (part) => getPartNodeNames(part).length > 0, + ), [numberingCategoryParts], - ) + ); const allNumberingModelParts = useMemo( - () => numberingCatalog.parts.filter((part) => getPartNodeNames(part).length > 0), + () => + numberingCatalog.parts.filter( + (part) => getPartNodeNames(part).length > 0, + ), [numberingCatalog.parts], - ) - const numberingPartSnapshotCount = allNumberingModelParts.filter( - (part) => Boolean(numberingSnapshotUrlsByPartId[part.id]), - ).length - const numberingToolSnapshotCount = drawingTools.selectedTools.filter( - (tool) => Boolean(numberingToolSnapshotUrlsByToolId[tool.id]), - ).length - const numberingSnapshotCount = numberingPartSnapshotCount + numberingToolSnapshotCount - const numberingSnapshotTotal = allNumberingModelParts.length + drawingTools.selectedTools.length + ); + const numberingPartSnapshotCount = allNumberingModelParts.filter((part) => + Boolean(numberingSnapshotUrlsByPartId[part.id]), + ).length; + const numberingToolSnapshotCount = drawingTools.selectedTools.filter((tool) => + Boolean(numberingToolSnapshotUrlsByToolId[tool.id]), + ).length; + const numberingSnapshotCount = + numberingPartSnapshotCount + numberingToolSnapshotCount; + const missingDrawingToolLineArt = drawingSheetOpen + ? (drawingTools.selectedTools.find( + (tool) => !numberingToolSnapshotLineArtUrlsByToolId[tool.id], + ) ?? null) + : null; + const numberingSnapshotTotal = + allNumberingModelParts.length + drawingTools.selectedTools.length; const numberingSnapshotsConfirmed = numberingSnapshotTotal > 0 && - allNumberingModelParts.every((part) => Boolean(confirmedNumberingSnapshotUrlsByPartId[part.id])) && + allNumberingModelParts.every((part) => + Boolean(confirmedNumberingSnapshotUrlsByPartId[part.id]), + ) && + allNumberingModelParts.every((part) => + Boolean(confirmedNumberingSnapshotBatch?.snapshotLineArtUrls?.[part.id]), + ) && drawingTools.selectedTools.every((tool) => Boolean(confirmedNumberingToolSnapshotUrlsByToolId[tool.id]), - ) - const hasNumberingBoundParts = numberingModelParts.length > 0 + ) && + drawingTools.selectedTools.every((tool) => + Boolean( + confirmedNumberingSnapshotBatch?.toolSnapshotLineArtUrls?.[tool.id], + ), + ); + const hasNumberingBoundParts = numberingModelParts.length > 0; const numberingActiveIndex = numberingModelParts.length > 0 - ? Math.min(Math.max(numberingRevealCount, 1), numberingModelParts.length) - 1 - : -1 - const numberingCurrentIndex = numberingActiveIndex >= 0 ? numberingActiveIndex + 1 : 0 + ? Math.min( + Math.max(numberingRevealCount, 1), + numberingModelParts.length, + ) - 1 + : -1; + const numberingCurrentIndex = + numberingActiveIndex >= 0 ? numberingActiveIndex + 1 : 0; const numberingToolbarCurrentIndex = - numberingCategory === 'tool' + numberingCategory === "tool" ? numberingPreviewToolIndex >= 0 ? numberingPreviewToolIndex + 1 : 0 - : numberingCurrentIndex + : numberingCurrentIndex; const numberingToolbarTotalCount = - numberingCategory === 'tool' ? drawingTools.selectedTools.length : numberingModelParts.length + numberingCategory === "tool" + ? drawingTools.selectedTools.length + : numberingModelParts.length; const numberingVisibleModelParts = useMemo( () => numberingActiveIndex >= 0 && numberingModelParts[numberingActiveIndex] ? [numberingModelParts[numberingActiveIndex]] : [], [numberingActiveIndex, numberingModelParts], - ) + ); const numberingVisiblePartIds = useMemo( () => numberingVisibleModelParts.map((part) => part.id), [numberingVisibleModelParts], - ) + ); const numberingVisibleNodeNames = useMemo( () => Array.from( new Set( numberingVisibleModelParts.flatMap((part) => - resolveRepresentativeNumberingNodeNames({ engine: previewEngine, part }), + resolveRepresentativeNumberingNodeNames({ + engine: previewEngine, + part, + }), ), ), ), [numberingVisibleModelParts, previewEngine], - ) + ); const numberingHideCandidateNodeNames = useMemo( () => modelNodeNames.length > 0 ? modelNodeNames - : Array.from(new Set(numberingCatalog.parts.flatMap((part) => getPartNodeNames(part)))), + : Array.from( + new Set( + numberingCatalog.parts.flatMap((part) => getPartNodeNames(part)), + ), + ), [modelNodeNames, numberingCatalog.parts], - ) - const numberingVisibleNodeSignature = numberingVisibleNodeNames.join('\u001f') + ); + const numberingVisibleNodeSignature = + numberingVisibleNodeNames.join("\u001f"); const numberingHiddenNodeNames = useMemo( () => hasNumberingBoundParts @@ -454,25 +727,30 @@ export default function InstallationDemoWorkbenchClient({ numberingVisibleNodeNames, previewEngine, ], - ) - const numberingHiddenNodeSignature = numberingHiddenNodeNames.join('\u001f') - const previewRuntimePresentation = resolveInstallationPreviewRuntimePresentation({ - stageView: preview.stageView, - selectedNodeNames: preview.selectedNodeNames, - highlightedNodeNames: preview.highlightedNodeNames, - previewHiddenNodeNames: preview.previewHiddenNodeNames, - localWorkflowActive: preview.localWorkflowActive, - localPlaybackShowingActionEntrance: preview.localPlaybackShowingActionEntrance, - localPlaybackActionNodeNames: preview.localPlaybackActionNodeNames, - }) + ); + const numberingHiddenNodeSignature = numberingHiddenNodeNames.join("\u001f"); + const previewRuntimePresentation = + resolveInstallationPreviewRuntimePresentation({ + stageView: preview.stageView, + selectedNodeNames: preview.selectedNodeNames, + highlightedNodeNames: preview.highlightedNodeNames, + previewHiddenNodeNames: preview.previewHiddenNodeNames, + localWorkflowActive: preview.localWorkflowActive, + localPlaybackShowingActionEntrance: + preview.localPlaybackShowingActionEntrance, + localPlaybackActionNodeNames: preview.localPlaybackActionNodeNames, + }); const numberingHighlightedNodeNames = - preview.stageView === 'numbering' + preview.stageView === "numbering" ? numberingVisibleNodeNames - : previewRuntimePresentation.highlightedNodeNames + : previewRuntimePresentation.highlightedNodeNames; const previewSelectedPart = - preview.stageView === 'numbering' ? numberingCatalog.selectedPart : partCatalog.selectedPart - const savedNumberingView = - selectedDrawing?.id ? numberingViewsByDrawingId[selectedDrawing.id] ?? null : null + preview.stageView === "numbering" + ? numberingCatalog.selectedPart + : partCatalog.selectedPart; + const savedNumberingView = selectedDrawing?.id + ? (numberingViewsByDrawingId[selectedDrawing.id] ?? null) + : null; const stepMaterialItems = useMemo( () => buildInstallationStepMaterialItems({ @@ -481,12 +759,17 @@ export default function InstallationDemoWorkbenchClient({ stepParts: partCatalog.parts, numberingParts: numberingCatalog.parts, }), - [numberingCatalog.parts, partCatalog.parts, preview.activeStep, stepEditor.steps], - ) + [ + numberingCatalog.parts, + partCatalog.parts, + preview.activeStep, + stepEditor.steps, + ], + ); const stepMaterialMarkerParts = useMemo( () => buildInstallationStepMaterialMarkerParts(stepMaterialItems), [stepMaterialItems], - ) + ); const stepBoardAttentionParts = useMemo( () => buildInstallationStepBoardAttentionParts({ @@ -495,228 +778,282 @@ export default function InstallationDemoWorkbenchClient({ stepParts: partCatalog.parts, numberingParts: numberingCatalog.parts, }), - [numberingCatalog.parts, partCatalog.parts, preview.activeStep, stepEditor.steps], - ) + [ + numberingCatalog.parts, + partCatalog.parts, + preview.activeStep, + stepEditor.steps, + ], + ); const previewStepMaterialMarkerParts = (() => { if ( - preview.stageView !== 'preview' || + preview.stageView !== "preview" || !preview.activeStep || !previewEngine || !shouldUseSingleRepresentativeHardwareFocus(preview.activeStep) ) { - return stepMaterialMarkerParts + return stepMaterialMarkerParts; } - const representativeNodeNames = resolveInstallationStepHardwareFocusNodeNames({ - engine: previewEngine, - step: preview.activeStep, - parts: partCatalog.parts, - currentCameraPosition: previewEngine.getCamera().position.clone(), - currentCameraTarget: previewEngine.getControls().target.clone(), - }) - const representativeNodeNameSet = new Set(representativeNodeNames) + const representativeNodeNames = + resolveInstallationStepHardwareFocusNodeNames({ + engine: previewEngine, + step: preview.activeStep, + parts: partCatalog.parts, + currentCameraPosition: previewEngine.getCamera().position.clone(), + currentCameraTarget: previewEngine.getControls().target.clone(), + }); + const representativeNodeNameSet = new Set(representativeNodeNames); if (representativeNodeNameSet.size === 0) { - return stepMaterialMarkerParts + return stepMaterialMarkerParts; } - const filteredMarkerParts: InstallationPartItem[] = stepMaterialItems.flatMap((item) => { - if (!item.roles.includes('hardware')) { - return item.markerParts - } - - return item.markerParts.flatMap((markerPart) => { - const focusedNodeNames = markerPart.nodeNames.filter((nodeName) => - representativeNodeNameSet.has(nodeName), - ) - if (focusedNodeNames.length === 0) { - return [] + const filteredMarkerParts: InstallationPartItem[] = + stepMaterialItems.flatMap((item) => { + if (!item.roles.includes("hardware")) { + return item.markerParts; } - return [ - { - ...markerPart, - id: `${markerPart.id}:representative`, - nodeName: focusedNodeNames[0] ?? markerPart.nodeName, - nodeNames: focusedNodeNames, - overlayAnchorMode: 'center', - }, - ] - }) - }) - return filteredMarkerParts.length > 0 ? filteredMarkerParts : stepMaterialMarkerParts - })() + return item.markerParts.flatMap((markerPart) => { + const focusedNodeNames = markerPart.nodeNames.filter((nodeName) => + representativeNodeNameSet.has(nodeName), + ); + if (focusedNodeNames.length === 0) { + return []; + } + return [ + { + ...markerPart, + id: `${markerPart.id}:representative`, + nodeName: focusedNodeNames[0] ?? markerPart.nodeName, + nodeNames: focusedNodeNames, + overlayAnchorMode: "center", + }, + ]; + }); + }); + + return filteredMarkerParts.length > 0 + ? filteredMarkerParts + : stepMaterialMarkerParts; + })(); const stepMaterialThumbnailUrls = useInstallationStepMaterialThumbnails({ engine: previewEngine, items: stepMaterialItems, scopeKey: [ - selectedDrawingId ?? 'no-drawing', - modelUrl ?? 'no-model', - preview.activeStep?.id ?? 'no-step', - ].join(':'), - }) + selectedDrawingId ?? "no-drawing", + modelUrl ?? "no-model", + preview.activeStep?.id ?? "no-step", + ].join(":"), + }); const resolvedStepMaterialThumbnailUrls = useMemo( () => stepMaterialItems.reduce>((result, item) => { - const snapshotUrl = - item.snapshotPartId ? confirmedNumberingSnapshotUrlsByPartId[item.snapshotPartId] ?? null : null - const fallbackUrl = stepMaterialThumbnailUrls[item.key] ?? null - const resolvedUrl = snapshotUrl ?? fallbackUrl + const snapshotUrl = item.snapshotPartId + ? (confirmedNumberingSnapshotUrlsByPartId[item.snapshotPartId] ?? + null) + : null; + const fallbackUrl = stepMaterialThumbnailUrls[item.key] ?? null; + const resolvedUrl = snapshotUrl ?? fallbackUrl; if (resolvedUrl) { - result[item.key] = resolvedUrl + result[item.key] = resolvedUrl; } - return result + return result; }, {}), - [confirmedNumberingSnapshotUrlsByPartId, stepMaterialItems, stepMaterialThumbnailUrls], - ) + [ + confirmedNumberingSnapshotUrlsByPartId, + stepMaterialItems, + stepMaterialThumbnailUrls, + ], + ); const shareDisabledReason = !catalog.access.canShare - ? '没有分享权限' + ? "没有分享权限" : !selectedDrawing - ? '请先选择图纸 ID' + ? "请先选择图纸 ID" : !modelUrl - ? '当前图纸还没有模型' + ? "当前图纸还没有模型" : stepEditor.steps.length === 0 - ? '当前还没有安装步骤' - : null + ? "当前还没有安装步骤" + : null; useEffect(() => { - setQrShareUrl(null) - setQrShareMessage(null) - }, [selectedDrawingId]) + setQrShareUrl(null); + setQrShareMessage(null); + }, [selectedDrawingId]); useEffect(() => { - if (!catalog.access.canEdit) return + if (!catalog.access.canEdit) return; const next = applySingleToolDefaultBindings( numberingCatalog.parts, drawingTools.selectedTools, - ) + ); if (next.changed) { - numberingCatalog.replaceParts(next.parts) + numberingCatalog.replaceParts(next.parts); } }, [ catalog.access.canEdit, drawingTools.selectedTools, numberingCatalog.parts, numberingCatalog.replaceParts, - ]) + ]); useEffect(() => { - numberingViewsByDrawingIdRef.current = numberingViewsByDrawingId - writeStoredInstallationNumberingViews(numberingViewsByDrawingId) - }, [numberingViewsByDrawingId]) + numberingViewsByDrawingIdRef.current = numberingViewsByDrawingId; + writeStoredInstallationNumberingViews(numberingViewsByDrawingId); + }, [numberingViewsByDrawingId]); useEffect(() => { - writeStoredInstallationNumberingSnapshotDrafts(numberingSnapshotDraftsByDrawingId) - }, [numberingSnapshotDraftsByDrawingId]) + writeStoredInstallationNumberingSnapshotDrafts( + numberingSnapshotDraftsByDrawingId, + ); + }, [numberingSnapshotDraftsByDrawingId]); useEffect(() => { - if (!selectedDrawingId) return + if (!selectedDrawingId) return; - let cancelled = false - serverLoadedNumberingViewDrawingIdsRef.current.delete(selectedDrawingId) + let cancelled = false; + serverLoadedNumberingViewDrawingIdsRef.current.delete(selectedDrawingId); fetchInstallationDemoNumberingConfig(selectedDrawingId) .then((config) => { - if (cancelled) return + if (cancelled) return; - serverLoadedNumberingViewDrawingIdsRef.current.add(selectedDrawingId) - const localView = numberingViewsByDrawingIdRef.current[selectedDrawingId] ?? null + serverLoadedNumberingViewDrawingIdsRef.current.add(selectedDrawingId); + const localView = + numberingViewsByDrawingIdRef.current[selectedDrawingId] ?? null; if (config?.view) { setNumberingViewsByDrawingId((current) => ({ ...current, [selectedDrawingId]: config.view as CameraViewSnapshot, - })) + })); } - if (catalog.access.canEdit && localView && !config?.viewUpdatedAt && !config?.view) { + if ( + catalog.access.canEdit && + localView && + !config?.viewUpdatedAt && + !config?.view + ) { void saveInstallationDemoNumberingConfig({ drawingId: selectedDrawingId, view: localView, - }) + }); } }) .catch(() => { if (!cancelled) { - serverLoadedNumberingViewDrawingIdsRef.current.delete(selectedDrawingId) + serverLoadedNumberingViewDrawingIdsRef.current.delete( + selectedDrawingId, + ); } - }) + }); return () => { - cancelled = true - } - }, [catalog.access.canEdit, selectedDrawingId]) + cancelled = true; + }; + }, [catalog.access.canEdit, selectedDrawingId]); useEffect(() => { - if (!selectedDrawingId || !modelUrl) return + if (!selectedDrawingId || !modelUrl) return; - let cancelled = false - const setSnapshot = (snapshot: InstallationNumberingSnapshotBatch | null) => { - if (cancelled) return + let cancelled = false; + const setSnapshot = ( + snapshot: InstallationNumberingSnapshotBatch | null, + ) => { + if (cancelled) return; setConfirmedNumberingSnapshotBatchesByDrawingId((current) => { - const next = { ...current } + const next = { ...current }; if (snapshot) { - next[selectedDrawingId] = snapshot + next[selectedDrawingId] = snapshot; } else { - delete next[selectedDrawingId] + delete next[selectedDrawingId]; } - return next - }) - } + return next; + }); + }; const loadSnapshot = async () => { - const localRuntime = isInstallationDemoLocalRuntime() - const localSnapshot = await readStoredInstallationNumberingSnapshotBatch(selectedDrawingId).catch(() => null) + const localRuntime = isInstallationDemoLocalRuntime(); + const localSnapshot = await readStoredInstallationNumberingSnapshotBatch( + selectedDrawingId, + ).catch(() => null); - if (localRuntime) { - setSnapshot( - localSnapshot && installationNumberingSnapshotMatchesModel(localSnapshot, modelUrl) - ? localSnapshot - : null, - ) - return + if ( + localRuntime && + localSnapshot && + installationNumberingSnapshotMatchesModel(localSnapshot, modelUrl) + ) { + setSnapshot(localSnapshot); + return; } - const serverSnapshot = await fetchInstallationNumberingSnapshotBatch(selectedDrawingId, modelUrl) + const serverSnapshot = await fetchInstallationNumberingSnapshotBatch( + selectedDrawingId, + modelUrl, + ); if (serverSnapshot) { - const runtimeSnapshot = bindInstallationNumberingSnapshotToRuntimeModel(serverSnapshot, modelUrl) - await writeStoredInstallationNumberingSnapshotBatch(selectedDrawingId, runtimeSnapshot) - setSnapshot(runtimeSnapshot) - return + const runtimeSnapshot = bindInstallationNumberingSnapshotToRuntimeModel( + serverSnapshot, + modelUrl, + ); + await writeStoredInstallationNumberingSnapshotBatch( + selectedDrawingId, + runtimeSnapshot, + ); + setSnapshot(runtimeSnapshot); + return; } // A previous release kept the confirmed batch only in IndexedDB. Migrate it // once when it still belongs to the selected model, without deleting it first. - if (localSnapshot && installationNumberingSnapshotMatchesModel(localSnapshot, modelUrl)) { - const migratedSnapshot = await saveInstallationNumberingSnapshotBatchToServer( + if ( + !localRuntime && + localSnapshot && + installationNumberingSnapshotMatchesModel(localSnapshot, modelUrl) + ) { + const migratedSnapshot = + await saveInstallationNumberingSnapshotBatchToServer( + selectedDrawingId, + localSnapshot, + ); + const runtimeSnapshot = bindInstallationNumberingSnapshotToRuntimeModel( + migratedSnapshot, + modelUrl, + ); + await writeStoredInstallationNumberingSnapshotBatch( selectedDrawingId, - localSnapshot, - ) - const runtimeSnapshot = bindInstallationNumberingSnapshotToRuntimeModel(migratedSnapshot, modelUrl) - await writeStoredInstallationNumberingSnapshotBatch(selectedDrawingId, runtimeSnapshot) - setSnapshot(runtimeSnapshot) - setNumberingSnapshotMessage('旧快照已迁移到正式站') - return + runtimeSnapshot, + ); + setSnapshot(runtimeSnapshot); + setNumberingSnapshotMessage("旧快照已迁移到正式站"); + return; } - setSnapshot(null) - } + setSnapshot(null); + }; void loadSnapshot().catch((error) => { if (!cancelled) { - setNumberingSnapshotMessage(error instanceof Error ? error.message : '正式快照读取失败') + setNumberingSnapshotMessage( + error instanceof Error ? error.message : "正式快照读取失败", + ); } - }) + }); return () => { - cancelled = true - } - }, [modelUrl, selectedDrawingId]) + cancelled = true; + }; + }, [modelUrl, selectedDrawingId]); useEffect(() => { - if (!catalog.access.canEdit) return - if (!selectedDrawingId || !serverLoadedNumberingViewDrawingIdsRef.current.has(selectedDrawingId)) { - return + if (!catalog.access.canEdit) return; + if ( + !selectedDrawingId || + !serverLoadedNumberingViewDrawingIdsRef.current.has(selectedDrawingId) + ) { + return; } const timer = window.setTimeout(() => { @@ -725,298 +1062,366 @@ export default function InstallationDemoWorkbenchClient({ view: savedNumberingView ?? null, }).catch(() => { // Keep the current browser's saved camera view as the fallback. - }) - }, 400) + }); + }, 400); - return () => window.clearTimeout(timer) - }, [catalog.access.canEdit, savedNumberingView, selectedDrawingId]) + return () => window.clearTimeout(timer); + }, [catalog.access.canEdit, savedNumberingView, selectedDrawingId]); const syncModelGeometry = useCallback( (engine: ThreeDEngine, nextNodeNames?: string[]) => { const resolvedNodeNames = - nextNodeNames && nextNodeNames.length > 0 ? nextNodeNames : engine.getNamedNodeNames() - setModelNodeNames(resolvedNodeNames) - setModelNodeSnapshots(collectInstallationModelNodeSnapshots(engine, resolvedNodeNames)) + nextNodeNames && nextNodeNames.length > 0 + ? nextNodeNames + : engine.getNamedNodeNames(); + setModelNodeNames(resolvedNodeNames); + setModelNodeSnapshots( + collectInstallationModelNodeSnapshots(engine, resolvedNodeNames), + ); }, [], - ) + ); const selectNumberingPart = useCallback( (partId: string) => { - numberingCatalog.setSelectedPartId(partId) + numberingCatalog.setSelectedPartId(partId); const selectableParts = - preview.stageView === 'exploded' ? explosionNumberingParts : numberingModelParts - const partIndex = selectableParts.findIndex((part) => part.id === partId) + preview.stageView === "exploded" + ? explosionNumberingParts + : numberingModelParts; + const partIndex = selectableParts.findIndex((part) => part.id === partId); if (partIndex >= 0) { - if (preview.stageView === 'numbering') { - setNumberingRevealCount(partIndex + 1) + if (preview.stageView === "numbering") { + setNumberingRevealCount(partIndex + 1); } - setNumberingSelectedNodeNames(getPartNodeNames(selectableParts[partIndex])) - return + setNumberingSelectedNodeNames( + getPartNodeNames(selectableParts[partIndex]), + ); + return; } - setNumberingSelectedNodeNames([]) + setNumberingSelectedNodeNames([]); }, - [explosionNumberingParts, numberingCatalog, numberingModelParts, preview.stageView], - ) + [ + explosionNumberingParts, + numberingCatalog, + numberingModelParts, + preview.stageView, + ], + ); const restoreSavedNumberingView = useCallback( (view: CameraViewSnapshot | null) => { - if (!view) return false - const engine = previewEngineRef.current ?? previewEngine - return applyInstallationCameraView(engine, view) + if (!view) return false; + const engine = previewEngineRef.current ?? previewEngine; + return applyInstallationCameraView(engine, view); }, [previewEngine], - ) + ); const captureNumberingSnapshotForPart = useCallback( (part: InstallationPartItem, engineOverride?: ThreeDEngine | null) => { - const engine = engineOverride ?? previewEngineRef.current ?? previewEngine - if (!engine || !selectedDrawingId || !modelUrl) return false + const engine = + engineOverride ?? previewEngineRef.current ?? previewEngine; + if (!engine || !selectedDrawingId || !modelUrl) return false; - const nodeNames = resolveRepresentativeNumberingNodeNames({ engine, part }) - const snapshotUrl = engine.captureNodeThumbnailDataUrl(nodeNames, { - padding: part.kind === 'hardware' ? 1.35 : 1.18, + const nodeNames = resolveRepresentativeNumberingNodeNames({ + engine, + part, + }); + const captureOptions = { + padding: part.kind === "hardware" ? 1.35 : 1.18, clearColor: 0xffffff, clearAlpha: 0, sceneGridVisible: false, sceneBackground: null, - captureProfile: 'studio-product', + captureProfile: "studio-product", hideHelpers: true, - }) - if (!snapshotUrl) return false + } as const; + const snapshotUrl = engine.captureNodeThumbnailDataUrl( + nodeNames, + captureOptions, + ); + const snapshotLineArtUrl = engine.captureNodeThumbnailDataUrl(nodeNames, { + ...captureOptions, + renderMode: "technical-line", + technicalLineStyle: part.kind === "hardware" ? "detail" : "clean", + }); + if (!snapshotUrl || !snapshotLineArtUrl) return false; setNumberingSnapshotDraftsByDrawingId((current) => { - const currentDraft = current[selectedDrawingId] + const currentDraft = current[selectedDrawingId]; return { ...current, [selectedDrawingId]: { modelUrl, snapshotUrls: { - ...(currentDraft?.modelUrl === modelUrl ? currentDraft.snapshotUrls : {}), + ...(currentDraft?.modelUrl === modelUrl + ? currentDraft.snapshotUrls + : {}), [part.id]: snapshotUrl, }, + snapshotLineArtUrls: { + ...(currentDraft?.modelUrl === modelUrl + ? (currentDraft.snapshotLineArtUrls ?? {}) + : {}), + [part.id]: snapshotLineArtUrl, + }, toolSnapshotUrls: - currentDraft?.modelUrl === modelUrl ? currentDraft.toolSnapshotUrls ?? {} : {}, + currentDraft?.modelUrl === modelUrl + ? (currentDraft.toolSnapshotUrls ?? {}) + : {}, + toolSnapshotLineArtUrls: + currentDraft?.modelUrl === modelUrl + ? (currentDraft.toolSnapshotLineArtUrls ?? {}) + : {}, }, - } - }) - return true + }; + }); + return true; }, [modelUrl, previewEngine, selectedDrawingId], - ) + ); const captureNumberingSnapshotForTool = useCallback( (tool: InstallationToolItem, capture: ModelPreviewCapture | null) => { - if (!capture || !selectedDrawingId || !modelUrl) return false - const snapshotUrl = capture({ + if (!capture || !selectedDrawingId || !modelUrl) return false; + const captureOptions = { clearColor: 0xffffff, clearAlpha: 0, sceneGridVisible: false, sceneBackground: null, - captureProfile: 'studio-product', + captureProfile: "studio-product", hideHelpers: true, - }) - if (!snapshotUrl) return false + } as const; + const snapshotUrl = capture(captureOptions); + const snapshotLineArtUrl = capture({ + ...captureOptions, + renderMode: "technical-line", + technicalLineStyle: "detail", + }); + if (!snapshotUrl || !snapshotLineArtUrl) return false; setNumberingSnapshotDraftsByDrawingId((current) => { - const currentDraft = current[selectedDrawingId] + const currentDraft = current[selectedDrawingId]; return { ...current, [selectedDrawingId]: { modelUrl, - snapshotUrls: currentDraft?.modelUrl === modelUrl ? currentDraft.snapshotUrls : {}, + snapshotUrls: + currentDraft?.modelUrl === modelUrl + ? currentDraft.snapshotUrls + : {}, + snapshotLineArtUrls: + currentDraft?.modelUrl === modelUrl + ? (currentDraft.snapshotLineArtUrls ?? {}) + : {}, toolSnapshotUrls: { - ...(currentDraft?.modelUrl === modelUrl ? currentDraft.toolSnapshotUrls ?? {} : {}), + ...(currentDraft?.modelUrl === modelUrl + ? (currentDraft.toolSnapshotUrls ?? {}) + : {}), [tool.id]: snapshotUrl, }, + toolSnapshotLineArtUrls: { + ...(currentDraft?.modelUrl === modelUrl + ? (currentDraft.toolSnapshotLineArtUrls ?? {}) + : {}), + [tool.id]: snapshotLineArtUrl, + }, }, - } - }) - return true + }; + }); + return true; }, [modelUrl, selectedDrawingId], - ) + ); const retakeCurrentNumberingSnapshot = useCallback(() => { - const currentPart = numberingVisibleModelParts[0] ?? null - if (!currentPart) return - captureNumberingSnapshotForPart(currentPart) - }, [captureNumberingSnapshotForPart, numberingVisibleModelParts]) + const currentPart = numberingVisibleModelParts[0] ?? null; + if (!currentPart) return; + captureNumberingSnapshotForPart(currentPart); + }, [captureNumberingSnapshotForPart, numberingVisibleModelParts]); const saveNumberingSnapshotBatch = useCallback(async () => { if (!catalog.access.canEdit) { - setNumberingSnapshotMessage('当前账号没有编辑权限,无法保存快照') - return + setNumberingSnapshotMessage("当前账号没有编辑权限,无法保存快照"); + return; } if (!selectedDrawingId || !modelUrl) { - setNumberingSnapshotMessage('图纸或模型尚未加载完成,无法保存快照') - return + setNumberingSnapshotMessage("图纸或模型尚未加载完成,无法保存快照"); + return; } if (numberingSnapshotSaving) { - setNumberingSnapshotMessage('快照正在保存,请稍候') - return + setNumberingSnapshotMessage("快照正在保存,请稍候"); + return; } - if (numberingSnapshotTotal === 0) { - setNumberingSnapshotMessage('当前没有可保存的快照') - return + const batchResult = buildInstallationNumberingSnapshotBatch({ + modelUrl, + partIds: allNumberingModelParts.map((part) => part.id), + toolIds: drawingTools.selectedTools.map((tool) => tool.id), + snapshotUrls: numberingSnapshotUrlsByPartId, + snapshotLineArtUrls: numberingSnapshotLineArtUrlsByPartId, + toolSnapshotUrls: numberingToolSnapshotUrlsByToolId, + toolSnapshotLineArtUrls: numberingToolSnapshotLineArtUrlsByToolId, + }); + if (!batchResult.ok) { + setNumberingSnapshotMessage(batchResult.message); + return; } - const snapshotUrls = Object.fromEntries( - allNumberingModelParts.flatMap((part) => { - const snapshotUrl = numberingSnapshotUrlsByPartId[part.id] - return snapshotUrl ? [[part.id, snapshotUrl]] : [] - }), - ) - if (Object.keys(snapshotUrls).length !== allNumberingModelParts.length) { - setNumberingSnapshotMessage('部件快照尚未生成完整,请补齐后保存') - return - } - const toolSnapshotUrls = Object.fromEntries( - drawingTools.selectedTools.flatMap((tool) => { - const snapshotUrl = numberingToolSnapshotUrlsByToolId[tool.id] - return snapshotUrl ? [[tool.id, snapshotUrl]] : [] - }), - ) - if (Object.keys(toolSnapshotUrls).length !== drawingTools.selectedTools.length) { - setNumberingSnapshotMessage('工具快照尚未生成完整,请补齐后保存') - return - } - - setNumberingSnapshotSaving(true) - setNumberingSnapshotMessage(null) + setNumberingSnapshotSaving(true); + setNumberingSnapshotMessage(null); try { - const snapshotBatch: InstallationNumberingSnapshotBatch = { - modelUrl, - modelIdentity: resolveInstallationNumberingSnapshotModelIdentity(modelUrl), - snapshotUrls, - toolSnapshotUrls, - savedAt: new Date().toISOString(), - } + const snapshotBatch = batchResult.batch; const savedSnapshot = isInstallationDemoLocalRuntime() ? snapshotBatch - : await saveInstallationNumberingSnapshotBatchToServer(selectedDrawingId, snapshotBatch) + : await saveInstallationNumberingSnapshotBatchToServer( + selectedDrawingId, + snapshotBatch, + ); const runtimeSnapshot = isInstallationDemoLocalRuntime() ? savedSnapshot - : bindInstallationNumberingSnapshotToRuntimeModel(savedSnapshot, modelUrl) - await writeStoredInstallationNumberingSnapshotBatch(selectedDrawingId, runtimeSnapshot) + : bindInstallationNumberingSnapshotToRuntimeModel( + savedSnapshot, + modelUrl, + ); + await writeStoredInstallationNumberingSnapshotBatch( + selectedDrawingId, + runtimeSnapshot, + ); setConfirmedNumberingSnapshotBatchesByDrawingId((current) => ({ ...current, [selectedDrawingId]: runtimeSnapshot, - })) + })); setNumberingSnapshotDraftsByDrawingId((current) => { - const next = { ...current } - delete next[selectedDrawingId] - return next - }) + const next = { ...current }; + delete next[selectedDrawingId]; + return next; + }); setNumberingSnapshotMessage( - isInstallationDemoLocalRuntime() ? '本批配件快照已保存到本机' : '本批配件快照已保存到正式站', - ) + isInstallationDemoLocalRuntime() + ? "本批配件快照已保存到本机" + : "本批配件快照已保存到正式站", + ); } catch (error) { - setNumberingSnapshotMessage(error instanceof Error ? error.message : '保存配件快照失败') + setNumberingSnapshotMessage( + error instanceof Error ? error.message : "保存配件快照失败", + ); } finally { - setNumberingSnapshotSaving(false) + setNumberingSnapshotSaving(false); } }, [ - allNumberingModelParts, - catalog.access.canEdit, - drawingTools.selectedTools, - modelUrl, - numberingSnapshotSaving, - numberingSnapshotTotal, - numberingSnapshotUrlsByPartId, - numberingToolSnapshotUrlsByToolId, - selectedDrawingId, - ]) + allNumberingModelParts, + catalog.access.canEdit, + drawingTools.selectedTools, + modelUrl, + numberingSnapshotSaving, + numberingSnapshotTotal, + numberingSnapshotUrlsByPartId, + numberingSnapshotLineArtUrlsByPartId, + numberingToolSnapshotUrlsByToolId, + numberingToolSnapshotLineArtUrlsByToolId, + selectedDrawingId, + ]); const handleNodeSelect = useCallback( (payload) => { - if (!catalog.access.canEdit) return - const nodeName = payload.nodeName ?? payload.rawNodeName ?? payload.candidateNodeName + if (!catalog.access.canEdit) return; + const nodeName = + payload.nodeName ?? payload.rawNodeName ?? payload.candidateNodeName; - if (activeTab === 'steps' && detailView === 'exploded') { - if (explosionNumberingCategory === 'tool') return + if (activeTab === "steps" && detailView === "exploded") { + if (explosionNumberingCategory === "tool") return; if (hardwareCompositeDefinitionPartId) { if (!nodeName) { if (!payload.hasRaycastHit && !payload.appendToSelection) { - setExplosionSelectedNodeNames([]) - setNumberingSelectedNodeNames([]) + setExplosionSelectedNodeNames([]); + setNumberingSelectedNodeNames([]); } - return + return; } const updateCompositeDraft = (current: string[]) => { if (payload.appendToSelection && current.includes(nodeName)) { - return current.filter((item) => item !== nodeName) + return current.filter((item) => item !== nodeName); } - return current.includes(nodeName) ? current : [...current, nodeName] - } - setHardwareCompositeDraftNodeNames(updateCompositeDraft) - setExplosionSelectedNodeNames(updateCompositeDraft) - setNumberingSelectedNodeNames(updateCompositeDraft) - return + return current.includes(nodeName) + ? current + : [...current, nodeName]; + }; + setHardwareCompositeDraftNodeNames(updateCompositeDraft); + setExplosionSelectedNodeNames(updateCompositeDraft); + setNumberingSelectedNodeNames(updateCompositeDraft); + return; } if (!nodeName) { if (!payload.hasRaycastHit && !payload.appendToSelection) { - setExplosionSelectedNodeNames([]) - setNumberingSelectedNodeNames([]) - partCatalog.setSelectedPartId(null) + setExplosionSelectedNodeNames([]); + setNumberingSelectedNodeNames([]); + partCatalog.setSelectedPartId(null); } - return + return; } - const part = partCatalog.parts.find((item) => getPartNodeNames(item).includes(nodeName)) + const part = partCatalog.parts.find((item) => + getPartNodeNames(item).includes(nodeName), + ); if (!part) { - partCatalog.addPartFromNode(nodeName) + partCatalog.addPartFromNode(nodeName); } else { - partCatalog.setSelectedPartId(part.id) + partCatalog.setSelectedPartId(part.id); } - const nextSelectedNodeNames = part ? getPartNodeNames(part) : [nodeName] + const nextSelectedNodeNames = part + ? getPartNodeNames(part) + : [nodeName]; const updateSelectedNodeNames = (current: string[]) => payload.appendToSelection ? Array.from(new Set([...current, ...nextSelectedNodeNames])) - : nextSelectedNodeNames - setExplosionSelectedNodeNames(updateSelectedNodeNames) - setNumberingSelectedNodeNames(updateSelectedNodeNames) - return + : nextSelectedNodeNames; + setExplosionSelectedNodeNames(updateSelectedNodeNames); + setNumberingSelectedNodeNames(updateSelectedNodeNames); + return; } - if (activeTab === 'steps' && detailView === 'numbering') { - if (numberingCategory === 'tool') return + if (activeTab === "steps" && detailView === "numbering") { + if (numberingCategory === "tool") return; if (!nodeName) { if (!payload.hasRaycastHit && !payload.appendToSelection) { - setNumberingSelectedNodeNames([]) + setNumberingSelectedNodeNames([]); } - return + return; } setNumberingSelectedNodeNames((current) => { if (!payload.appendToSelection) { - return [nodeName] + return [nodeName]; } return current.includes(nodeName) ? current.filter((item) => item !== nodeName) - : [...current, nodeName] - }) - return + : [...current, nodeName]; + }); + return; } - if (activeTab === 'steps' && detailView === 'steps') { - if (stepActionWorkspace.handleNodeSelect(payload)) return + if (activeTab === "steps" && detailView === "steps") { + if (stepActionWorkspace.handleNodeSelect(payload)) return; } - if (!nodeName) return + if (!nodeName) return; - if (activeTab !== 'steps' || detailView !== 'steps' || mode !== 'edit') return - const part = partCatalog.parts.find((item) => getPartNodeNames(item).includes(nodeName)) + if (activeTab !== "steps" || detailView !== "steps" || mode !== "edit") + return; + const part = partCatalog.parts.find((item) => + getPartNodeNames(item).includes(nodeName), + ); if (!part) { - partCatalog.addPartFromNode(nodeName) + partCatalog.addPartFromNode(nodeName); } - const token = part ? buildPartToken(part) : nodeName + const token = part ? buildPartToken(part) : nodeName; if (!stepEditor.activeStep) { - stepEditor.createStep({ role: selectionRole, token }) - return + stepEditor.createStep({ role: selectionRole, token }); + return; } - stepEditor.addTokenToActiveStep(selectionRole, token) + stepEditor.addTokenToActiveStep(selectionRole, token); }, [ activeTab, @@ -1031,58 +1436,63 @@ export default function InstallationDemoWorkbenchClient({ stepActionWorkspace, stepEditor, ], - ) + ); const beginHardwareCompositeDefinition = useCallback( (partId: string) => { - setHardwareCompositeDefinitionPartId(partId) - setHardwareCompositeDraftNodeNames([]) - setHardwareCompositeCandidate(null) - setExplosionSelectedNodeNames([]) - setNumberingSelectedNodeNames([]) - numberingCatalog.setSelectedPartId(partId) + setHardwareCompositeDefinitionPartId(partId); + setHardwareCompositeDraftNodeNames([]); + setHardwareCompositeCandidate(null); + setExplosionSelectedNodeNames([]); + setNumberingSelectedNodeNames([]); + numberingCatalog.setSelectedPartId(partId); }, [numberingCatalog], - ) + ); const cancelHardwareCompositeDefinition = useCallback(() => { - setHardwareCompositeDefinitionPartId(null) - setHardwareCompositeDraftNodeNames([]) - setExplosionSelectedNodeNames([]) - setNumberingSelectedNodeNames([]) - }, []) + setHardwareCompositeDefinitionPartId(null); + setHardwareCompositeDraftNodeNames([]); + setExplosionSelectedNodeNames([]); + setNumberingSelectedNodeNames([]); + }, []); const confirmHardwareCompositeDefinition = useCallback( (partId: string) => { - const engine = previewEngineRef.current ?? previewEngine - if (!engine || hardwareCompositeDefinitionPartId !== partId) return - if (hardwareCompositeDraftNodeNames.length < 2) return + const engine = previewEngineRef.current ?? previewEngine; + if (!engine || hardwareCompositeDefinitionPartId !== partId) return; + if (hardwareCompositeDraftNodeNames.length < 2) return; - const template = buildHardwareCompositeTemplate(engine, hardwareCompositeDraftNodeNames) - if (!template) return + const template = buildHardwareCompositeTemplate( + engine, + hardwareCompositeDraftNodeNames, + ); + if (!template) return; const assignedNodeNames = Array.from( new Set([ ...numberingCatalog.parts.flatMap((part) => getPartNodeNames(part)), ...hardwareCompositeDraftNodeNames, ]), - ) + ); const groups = resolveHardwareCompositeCandidateGroups({ engine, modelNodeNames, assignedNodeNames, template, - }) + }); numberingCatalog.defineHardwareCompositeGroup({ partId, nodeNames: hardwareCompositeDraftNodeNames, template, - }) - setHardwareCompositeCandidate(groups.length > 0 ? { partId, groups } : null) - setHardwareCompositeDefinitionPartId(null) - setHardwareCompositeDraftNodeNames([]) - setExplosionSelectedNodeNames([]) - setNumberingSelectedNodeNames([]) + }); + setHardwareCompositeCandidate( + groups.length > 0 ? { partId, groups } : null, + ); + setHardwareCompositeDefinitionPartId(null); + setHardwareCompositeDraftNodeNames([]); + setExplosionSelectedNodeNames([]); + setNumberingSelectedNodeNames([]); }, [ hardwareCompositeDefinitionPartId, @@ -1091,46 +1501,58 @@ export default function InstallationDemoWorkbenchClient({ numberingCatalog, previewEngine, ], - ) + ); const applyHardwareCompositeCandidates = useCallback( (partId: string) => { - if (!hardwareCompositeCandidate || hardwareCompositeCandidate.partId !== partId) return - numberingCatalog.appendHardwareCompositeGroups(partId, hardwareCompositeCandidate.groups) - setHardwareCompositeCandidate(null) + if ( + !hardwareCompositeCandidate || + hardwareCompositeCandidate.partId !== partId + ) + return; + numberingCatalog.appendHardwareCompositeGroups( + partId, + hardwareCompositeCandidate.groups, + ); + setHardwareCompositeCandidate(null); }, [hardwareCompositeCandidate, numberingCatalog], - ) + ); const findHardwareCompositeCandidates = useCallback( (partId: string) => { - const engine = previewEngineRef.current ?? previewEngine - const part = numberingCatalog.parts.find((item) => item.id === partId) - if (!engine || !part || part.kind !== 'hardware') return + const engine = previewEngineRef.current ?? previewEngine; + const part = numberingCatalog.parts.find((item) => item.id === partId); + if (!engine || !part || part.kind !== "hardware") return; - const representativeNodeName = part.nodeName ?? getPartNodeNames(part)[0] ?? null + const representativeNodeName = + part.nodeName ?? getPartNodeNames(part)[0] ?? null; const template = part.compositeTemplate ?? (representativeNodeName ? buildHardwareCompositeTemplate(engine, [representativeNodeName]) - : null) - if (!template) return + : null); + if (!template) return; const assignedNodeNames = Array.from( - new Set(numberingCatalog.parts.flatMap((item) => getPartNodeNames(item))), - ) + new Set( + numberingCatalog.parts.flatMap((item) => getPartNodeNames(item)), + ), + ); const groups = resolveHardwareCompositeCandidateGroups({ engine, modelNodeNames, assignedNodeNames, template, - }) + }); - numberingCatalog.setSelectedPartId(partId) - setHardwareCompositeCandidate(groups.length > 0 ? { partId, groups } : null) + numberingCatalog.setSelectedPartId(partId); + setHardwareCompositeCandidate( + groups.length > 0 ? { partId, groups } : null, + ); }, [modelNodeNames, numberingCatalog, previewEngine], - ) + ); const hardwareCompositeCandidateSummary = hardwareCompositeCandidate ? { @@ -1138,191 +1560,384 @@ export default function InstallationDemoWorkbenchClient({ groupCount: hardwareCompositeCandidate.groups.length, nodeCount: hardwareCompositeCandidate.groups.flat().length, } - : null + : null; const handleUploadModel = useCallback( (file: File) => { - if (!catalog.access.canEdit || !selectedDrawing) return - void upload.uploadModelForDrawing(selectedDrawing.id, file) + if (!catalog.access.canEdit || !selectedDrawing) return; + void upload.uploadModelForDrawing(selectedDrawing.id, file); }, [catalog.access.canEdit, selectedDrawing, upload], - ) + ); - const buildCurrentShareInput = useCallback((): CreateInstallationDemoShareInput | null => { - if (!selectedDrawing || !modelUrl || shareDisabledReason) return null + const prepareNumberingSnapshotBatchForShare = useCallback(async () => { + if (!selectedDrawingId || !modelUrl) return null; - const currentShareEngine = previewEngineRef.current ?? previewEngine - const normalizedSteps = normalizeInstallationStepDrafts(stepEditor.steps) - const currentCameraView = currentShareEngine - ? captureInstallationCameraView(currentShareEngine) - : null - const currentDetailView: InstallationDetailView = - preview.stageView === 'structure' - ? 'finished' - : preview.stageView === 'preview' - ? 'steps' - : preview.stageView - const currentMode: InstallationWorkbenchMode = - preview.stageView === 'preview' - ? 'preview' - : currentDetailView === 'steps' - ? mode - : 'edit' - const currentPlaying = - preview.stageView === 'preview' - ? playing - : preview.stageView === 'numbering' - ? numberingPlaying - : false - const stepMaterialsByStepId = buildInstallationStepMaterialShareSnapshotsByStepId({ - engine: currentShareEngine, - scopeKeyBase: [selectedDrawing.id, modelUrl].join(':'), - steps: normalizedSteps, - stepParts: partCatalog.parts, - numberingParts: numberingCatalog.parts, - numberingSnapshotUrls: confirmedNumberingSnapshotUrlsByPartId, - }) - - return { - drawing: { - id: selectedDrawing.id, - code: selectedDrawing.code, - modelCode: selectedDrawing.modelCode, - spuCode: selectedDrawing.spuCode, - }, - modelUrl, - uploadedModel, - parts: partCatalog.parts, - numberingParts: numberingCatalog.parts, - drawingTools: drawingTools.selectedTools, - numberingView: savedNumberingView, - numberingSnapshotUrls: confirmedNumberingSnapshotUrlsByPartId, - numberingToolSnapshotUrls: confirmedNumberingToolSnapshotUrlsByToolId, - stepMaterialsByStepId, - steps: normalizedSteps, - initialView: { - detailView: currentDetailView, - mode: currentMode, - activeStepId: preview.activeStep?.id ?? stepEditor.activeStepId ?? null, - numberingRevealCount: numberingModelParts.length > 0 ? numberingCurrentIndex : 0, - selectedNumberingPartId: - preview.stageView === 'numbering' - ? numberingVisibleModelParts[0]?.id ?? numberingCatalog.selectedPartId - : numberingCatalog.selectedPartId, - cameraView: currentCameraView, - playing: currentPlaying, - }, + const partIds = allNumberingModelParts.map((part) => part.id); + const toolIds = drawingTools.selectedTools.map((tool) => tool.id); + const reusableSnapshot = confirmedNumberingSnapshotBatch + ? restoreInstallationNumberingSnapshotBatchForShare({ + modelUrl, + partIds, + toolIds, + snapshot: confirmedNumberingSnapshotBatch, + }) + : null; + if ( + hasConfirmedNumberingSnapshotBatch && + reusableSnapshot && + !numberingSnapshotDraft + ) { + return reusableSnapshot; } + + const batchResult = buildInstallationNumberingSnapshotBatch({ + modelUrl, + partIds, + toolIds, + snapshotUrls: numberingSnapshotUrlsByPartId, + snapshotLineArtUrls: numberingSnapshotLineArtUrlsByPartId, + toolSnapshotUrls: numberingToolSnapshotUrlsByToolId, + toolSnapshotLineArtUrls: numberingToolSnapshotLineArtUrlsByToolId, + }); + if (!batchResult.ok) { + if (numberingSnapshotDraft) return null; + + const serverSnapshot = await fetchInstallationNumberingSnapshotBatch( + selectedDrawingId, + modelUrl, + ); + const restoredSnapshot = serverSnapshot + ? restoreInstallationNumberingSnapshotBatchForShare({ + modelUrl, + partIds, + toolIds, + snapshot: serverSnapshot, + }) + : null; + if (!restoredSnapshot) return null; + + await writeStoredInstallationNumberingSnapshotBatch( + selectedDrawingId, + restoredSnapshot, + ); + setConfirmedNumberingSnapshotBatchesByDrawingId((current) => ({ + ...current, + [selectedDrawingId]: restoredSnapshot, + })); + return restoredSnapshot; + } + + const savedSnapshot = await saveInstallationNumberingSnapshotBatchToServer( + selectedDrawingId, + batchResult.batch, + ); + const runtimeSnapshot = bindInstallationNumberingSnapshotToRuntimeModel( + savedSnapshot, + modelUrl, + ); + await writeStoredInstallationNumberingSnapshotBatch( + selectedDrawingId, + runtimeSnapshot, + ); + setConfirmedNumberingSnapshotBatchesByDrawingId((current) => ({ + ...current, + [selectedDrawingId]: runtimeSnapshot, + })); + setNumberingSnapshotDraftsByDrawingId((current) => { + const next = { ...current }; + delete next[selectedDrawingId]; + return next; + }); + return runtimeSnapshot; }, [ - modelUrl, - numberingCatalog.parts, - numberingCatalog.selectedPartId, - numberingCurrentIndex, - numberingModelParts.length, - numberingPlaying, - confirmedNumberingSnapshotUrlsByPartId, - confirmedNumberingToolSnapshotUrlsByToolId, + allNumberingModelParts, + confirmedNumberingSnapshotBatch, drawingTools.selectedTools, - numberingVisibleModelParts, - playing, - partCatalog.parts, - preview.activeStep, - preview.stageView, - previewEngine, - savedNumberingView, - selectedDrawing, - shareDisabledReason, - stepEditor.activeStepId, - stepEditor.steps, - uploadedModel, - mode, - ]) + hasConfirmedNumberingSnapshotBatch, + modelUrl, + numberingSnapshotDraft, + numberingSnapshotLineArtUrlsByPartId, + numberingSnapshotUrlsByPartId, + numberingToolSnapshotLineArtUrlsByToolId, + numberingToolSnapshotUrlsByToolId, + selectedDrawingId, + ]); + + const buildCurrentShareInput = useCallback( + ( + shareNumberingSnapshotBatch: InstallationNumberingSnapshotBatch | null, + includeDrawing: boolean, + drawingImageMode: InstallationDrawingImageMode | null = null, + ): CreateInstallationDemoShareInput | null => { + if (!selectedDrawing || !modelUrl || shareDisabledReason) return null; + + const currentShareEngine = previewEngineRef.current ?? previewEngine; + const normalizedSteps = normalizeInstallationStepDrafts(stepEditor.steps); + const currentCameraView = currentShareEngine + ? captureInstallationCameraView(currentShareEngine) + : null; + const currentDetailView: InstallationDetailView = + preview.stageView === "structure" + ? "finished" + : preview.stageView === "preview" + ? "steps" + : preview.stageView; + const currentMode: InstallationWorkbenchMode = + preview.stageView === "preview" + ? "preview" + : currentDetailView === "steps" + ? mode + : "edit"; + const currentPlaying = + preview.stageView === "preview" + ? playing + : preview.stageView === "numbering" + ? numberingPlaying + : false; + const shareNumberingSnapshotUrls = + shareNumberingSnapshotBatch?.snapshotUrls ?? {}; + const shareNumberingToolSnapshotUrls = + shareNumberingSnapshotBatch?.toolSnapshotUrls ?? {}; + const stepMaterialsByStepId = + buildInstallationStepMaterialShareSnapshotsByStepId({ + engine: currentShareEngine, + scopeKeyBase: [selectedDrawing.id, modelUrl].join(":"), + steps: normalizedSteps, + stepParts: partCatalog.parts, + numberingParts: numberingCatalog.parts, + numberingSnapshotUrls: shareNumberingSnapshotUrls, + }); + + return { + drawing: { + id: selectedDrawing.id, + code: selectedDrawing.code, + modelCode: selectedDrawing.modelCode, + spuCode: selectedDrawing.spuCode, + }, + modelUrl, + uploadedModel, + parts: partCatalog.parts, + numberingParts: numberingCatalog.parts, + drawingTools: drawingTools.selectedTools, + numberingView: savedNumberingView, + numberingSnapshotUrls: shareNumberingSnapshotUrls, + numberingToolSnapshotUrls: shareNumberingToolSnapshotUrls, + stepMaterialsByStepId, + steps: normalizedSteps, + drawingDocument: + includeDrawing && shareNumberingSnapshotBatch + ? { + templateId: "set-1", + imageMode: drawingImageMode ?? "line-art", + captures: drawingCapture.captures, + snapshotLineArtUrls: + shareNumberingSnapshotBatch.snapshotLineArtUrls ?? {}, + toolSnapshotLineArtUrls: + shareNumberingSnapshotBatch.toolSnapshotLineArtUrls ?? {}, + } + : null, + initialView: { + detailView: currentDetailView, + mode: currentMode, + activeStepId: + preview.activeStep?.id ?? stepEditor.activeStepId ?? null, + numberingRevealCount: + numberingModelParts.length > 0 ? numberingCurrentIndex : 0, + selectedNumberingPartId: + preview.stageView === "numbering" + ? (numberingVisibleModelParts[0]?.id ?? + numberingCatalog.selectedPartId) + : numberingCatalog.selectedPartId, + cameraView: currentCameraView, + playing: currentPlaying, + }, + }; + }, + [ + modelUrl, + numberingCatalog.parts, + numberingCatalog.selectedPartId, + numberingCurrentIndex, + numberingModelParts.length, + numberingPlaying, + drawingCapture.captures, + drawingTools.selectedTools, + numberingVisibleModelParts, + playing, + partCatalog.parts, + preview.activeStep, + preview.stageView, + previewEngine, + savedNumberingView, + selectedDrawing, + shareDisabledReason, + stepEditor.activeStepId, + stepEditor.steps, + uploadedModel, + mode, + ], + ); + + const handleConfirmedQrShare = useCallback( + (share: InstallationDemoQrShareDTO) => { + setQrShareUrl(share.url); + setQrShareMessage("二维码内容已同步为当前确认图纸"); + }, + [], + ); + const buildConfirmedShareInput = useCallback( + ( + batch: InstallationNumberingSnapshotBatch, + imageMode: InstallationDrawingImageMode, + ) => buildCurrentShareInput(batch, true, imageMode), + [buildCurrentShareInput], + ); + const drawingConfirmation = useInstallationDrawingConfirmation({ + prepareNumberingSnapshotBatch: prepareNumberingSnapshotBatchForShare, + confirmCaptureBatch: drawingAutoCapture.confirmDrawing, + buildShareInput: buildConfirmedShareInput, + onQrShareSynced: handleConfirmedQrShare, + }); const handleCreateShare = useCallback(async () => { - const input = buildCurrentShareInput() - if (!input) { - setShareMessage(shareDisabledReason ?? '当前演示还不能分享') - return + if (shareDisabledReason) { + setShareMessage(shareDisabledReason ?? "当前演示还不能分享"); + return; } - setShareLoading(true) - setShareMessage(null) - setShareCopied(false) + setShareLoading(true); + setShareMessage(null); + setShareCopied(false); try { - const share = await createInstallationDemoShareLink(input) - setShareUrl(share.url) - setShareExpiresAt(share.expiresAt) - setShareMessage('分享链接已生成,24 小时后自动过期') + const includeDrawing = Boolean(drawingAutoCapture.confirmedAt); + const numberingSnapshotBatch = includeDrawing + ? await prepareNumberingSnapshotBatchForShare() + : null; + if (includeDrawing && !numberingSnapshotBatch) { + throw new Error("已确认图纸的部件或工具图读取失败,请重新确认图纸"); + } + const input = buildCurrentShareInput( + numberingSnapshotBatch, + includeDrawing, + drawingAutoCapture.confirmedImageMode, + ); + if (!input) throw new Error("当前演示还不能分享"); + const share = await createInstallationDemoShareLink(input); + setShareUrl(share.url); + setShareExpiresAt(share.expiresAt); + setShareMessage( + input.drawingDocument + ? "分享链接已生成,24 小时后自动过期" + : "分享链接已生成;图纸尚未确认,当前仅开放安装动画", + ); } catch (error) { - setShareMessage(error instanceof Error ? error.message : '分享链接生成失败') + setShareMessage( + error instanceof Error ? error.message : "分享链接生成失败", + ); } finally { - setShareLoading(false) + setShareLoading(false); } - }, [buildCurrentShareInput, shareDisabledReason]) + }, [ + buildCurrentShareInput, + drawingAutoCapture.confirmedAt, + drawingAutoCapture.confirmedImageMode, + prepareNumberingSnapshotBatchForShare, + shareDisabledReason, + ]); const handleSyncQrShare = useCallback(async () => { - const input = buildCurrentShareInput() - if (!input) { - setQrShareMessage(shareDisabledReason ?? '当前演示还不能分享') - return + if (shareDisabledReason) { + setQrShareMessage(shareDisabledReason ?? "当前演示还不能分享"); + return; } - setQrShareLoading(true) - setQrShareMessage(null) + setQrShareLoading(true); + setQrShareMessage(null); try { - const share = await syncInstallationDemoQrShare(input) - setQrShareUrl(share.url) + const includeDrawing = Boolean(drawingAutoCapture.confirmedAt); + const numberingSnapshotBatch = includeDrawing + ? await prepareNumberingSnapshotBatchForShare() + : null; + if (includeDrawing && !numberingSnapshotBatch) { + throw new Error("已确认图纸的部件或工具图读取失败,请重新确认图纸"); + } + const input = buildCurrentShareInput( + numberingSnapshotBatch, + includeDrawing, + drawingAutoCapture.confirmedImageMode, + ); + if (!input) throw new Error("当前演示还不能分享"); + const share = await syncInstallationDemoQrShare(input); + setQrShareUrl(share.url); + setQrShareMessage( + input.drawingDocument + ? "二维码内容已同步" + : "二维码内容已同步;图纸尚未确认,当前仅开放安装动画", + ); } catch (error) { - setQrShareMessage(error instanceof Error ? error.message : '二维码加载失败') + setQrShareMessage( + error instanceof Error ? error.message : "二维码加载失败", + ); } finally { - setQrShareLoading(false) + setQrShareLoading(false); } - }, [buildCurrentShareInput, shareDisabledReason]) + }, [ + buildCurrentShareInput, + drawingAutoCapture.confirmedAt, + drawingAutoCapture.confirmedImageMode, + prepareNumberingSnapshotBatchForShare, + shareDisabledReason, + ]); const handleCopyShare = useCallback(() => { - if (!shareUrl) return + if (!shareUrl) return; void navigator.clipboard .writeText(shareUrl) .then(() => { - setShareCopied(true) - window.setTimeout(() => setShareCopied(false), 1600) + setShareCopied(true); + window.setTimeout(() => setShareCopied(false), 1600); }) .catch(() => { - setShareMessage('复制失败,可以手动选中链接复制') - }) - }, [shareUrl]) + setShareMessage("复制失败,可以手动选中链接复制"); + }); + }, [shareUrl]); const selectRelativeStep = useCallback( (offset: -1 | 1) => { - const nextStep = stepEditor.steps[preview.activeStepIndex + offset] + const nextStep = stepEditor.steps[preview.activeStepIndex + offset]; if (nextStep) { - stepEditor.setActiveStepId(nextStep.id) + stepEditor.setActiveStepId(nextStep.id); } }, [preview.activeStepIndex, stepEditor], - ) + ); - const activeStep = preview.activeStep - const steps = stepEditor.steps - const nextPreviewStep = steps[preview.activeStepIndex + 1] ?? null - const setActiveStepId = stepEditor.setActiveStepId + const activeStep = preview.activeStep; + const steps = stepEditor.steps; + const nextPreviewStep = steps[preview.activeStepIndex + 1] ?? null; + const setActiveStepId = stepEditor.setActiveStepId; useInstallationStepCameraPlayback({ enabled: - preview.stageView === 'preview' && - mode === 'preview' && + preview.stageView === "preview" && + mode === "preview" && !preview.localWorkflowActive, - playing: preview.stageView === 'preview' && mode === 'preview' ? playing : false, + playing: + preview.stageView === "preview" && mode === "preview" ? playing : false, engine: previewEngine, activeStep, parts: partCatalog.parts, - }) + }); useInstallationStepActionCamera({ enabled: + !drawingCapturePresentationActive && preview.localWorkflowActive && - (stepActionWorkspace.mode === 'edit' || stepActionWorkspace.mode === 'preview'), + (stepActionWorkspace.mode === "edit" || + stepActionWorkspace.mode === "preview"), engine: previewEngine, activeStep, nextStep: nextPreviewStep, @@ -1334,33 +1949,33 @@ export default function InstallationDemoWorkbenchClient({ playbackComplete: preview.localPlaybackComplete, playbackBridgeActive: preview.localPlaybackBridgeActive, playbackBridgeDurationMs: preview.localPlaybackBridgeDurationMs, - }) + }); useEffect(() => { - if (!playing || mode !== 'preview' || !activeStep) return + if (!playing || mode !== "preview" || !activeStep) return; const advancePreviewStep = () => { - const nextStep = steps[preview.activeStepIndex + 1] + const nextStep = steps[preview.activeStepIndex + 1]; if (nextStep) { - setActiveStepId(nextStep.id) - return + setActiveStepId(nextStep.id); + return; } - setPlaying(false) - } + setPlaying(false); + }; // Local-action steps can include a tail bridge. Advance only after the // playback engine reports that the full step, including that bridge, ended. if (preview.localWorkflowActive) { - if (!preview.localPlaybackFinished) return - advancePreviewStep() - return + if (!preview.localPlaybackFinished) return; + advancePreviewStep(); + return; } const timer = window.setTimeout(() => { - advancePreviewStep() - }, preview.localPlaybackDurationMs) + advancePreviewStep(); + }, preview.localPlaybackDurationMs); - return () => window.clearTimeout(timer) + return () => window.clearTimeout(timer); }, [ activeStep, mode, @@ -1372,38 +1987,40 @@ export default function InstallationDemoWorkbenchClient({ preview.screwDriveNodeNames, setActiveStepId, steps, - ]) + ]); useEffect(() => { - if (!numberingPlaying || preview.stageView !== 'numbering') return + if (!numberingPlaying || preview.stageView !== "numbering") return; - if (numberingCategory === 'tool') { - if (drawingTools.selectedTools.length === 0) return + if (numberingCategory === "tool") { + if (drawingTools.selectedTools.length === 0) return; const timer = window.setTimeout(() => { - const currentIndex = Math.max(0, numberingPreviewToolIndex) + const currentIndex = Math.max(0, numberingPreviewToolIndex); if (currentIndex >= drawingTools.selectedTools.length - 1) { - setNumberingPlaying(false) - return + setNumberingPlaying(false); + return; } - setNumberingPreviewToolId(drawingTools.selectedTools[currentIndex + 1]?.id ?? null) - }, NUMBERING_ITEM_PLAYBACK_DURATION_MS) + setNumberingPreviewToolId( + drawingTools.selectedTools[currentIndex + 1]?.id ?? null, + ); + }, NUMBERING_ITEM_PLAYBACK_DURATION_MS); - return () => window.clearTimeout(timer) + return () => window.clearTimeout(timer); } - if (numberingModelParts.length === 0) return + if (numberingModelParts.length === 0) return; const timer = window.setTimeout(() => { setNumberingRevealCount((current) => { if (current >= numberingModelParts.length) { - setNumberingPlaying(false) - return current + setNumberingPlaying(false); + return current; } - return current + 1 - }) - }, NUMBERING_ITEM_PLAYBACK_DURATION_MS) + return current + 1; + }); + }, NUMBERING_ITEM_PLAYBACK_DURATION_MS); - return () => window.clearTimeout(timer) + return () => window.clearTimeout(timer); }, [ drawingTools.selectedTools, numberingCategory, @@ -1412,39 +2029,97 @@ export default function InstallationDemoWorkbenchClient({ numberingPreviewToolIndex, numberingRevealCount, preview.stageView, - ]) + ]); useEffect(() => { if ( - preview.stageView !== 'numbering' || + !drawingSheetOpen || !previewEngine || - numberingVisibleNodeNames.length === 0 - ) return - const activeNumberingPart = numberingVisibleModelParts[0] ?? null - if (!activeNumberingPart) return + !selectedDrawingId || + !modelUrl || + numberingLineArtGenerationRef.current + ) + return; - let captureTimer: number | null = null - const timer = window.setTimeout(() => { - const framed = previewEngine.fitToNodeNames(numberingVisibleNodeNames, 1.08) - if (!framed) { - previewEngine.fitToView(1.08) - } + const missingParts = allNumberingModelParts.filter( + (part) => !numberingSnapshotLineArtUrlsByPartId[part.id], + ); + if (missingParts.length === 0) return; - if (!numberingSnapshotUrlsByPartId[activeNumberingPart.id]) { - captureTimer = window.setTimeout(() => { - const currentPart = numberingVisibleModelParts[0] ?? null - if (!currentPart || currentPart.id !== activeNumberingPart.id) return - if (numberingSnapshotUrlsByPartId[currentPart.id]) return - captureNumberingSnapshotForPart(currentPart, previewEngine) - }, 760) + numberingLineArtGenerationRef.current = true; + setNumberingLineArtGenerating(true); + let cancelled = false; + let index = 0; + const generateNext = () => { + if (cancelled) return; + const part = missingParts[index]; + if (!part) { + numberingLineArtGenerationRef.current = false; + setNumberingLineArtGenerating(false); + return; } - }, 180) + index += 1; + captureNumberingSnapshotForPart(part, previewEngine); + window.setTimeout(generateNext, 40); + }; + generateNext(); return () => { - window.clearTimeout(timer) - if (captureTimer !== null) window.clearTimeout(captureTimer) - previewEngine.cancelCameraAnimation() - } + cancelled = true; + numberingLineArtGenerationRef.current = false; + setNumberingLineArtGenerating(false); + }; + }, [ + allNumberingModelParts, + captureNumberingSnapshotForPart, + drawingSheetOpen, + modelUrl, + previewEngine, + selectedDrawingId, + ]); + + useEffect(() => { + if ( + preview.stageView !== "numbering" || + !previewEngine || + numberingVisibleNodeNames.length === 0 + ) + return; + const activeNumberingPart = numberingVisibleModelParts[0] ?? null; + if (!activeNumberingPart) return; + + let captureTimer: number | null = null; + const timer = window.setTimeout(() => { + const framed = previewEngine.fitToNodeNames( + numberingVisibleNodeNames, + 1.08, + ); + if (!framed) { + previewEngine.fitToView(1.08); + } + + if ( + !numberingSnapshotUrlsByPartId[activeNumberingPart.id] || + !numberingSnapshotLineArtUrlsByPartId[activeNumberingPart.id] + ) { + captureTimer = window.setTimeout(() => { + const currentPart = numberingVisibleModelParts[0] ?? null; + if (!currentPart || currentPart.id !== activeNumberingPart.id) return; + if ( + numberingSnapshotUrlsByPartId[currentPart.id] && + numberingSnapshotLineArtUrlsByPartId[currentPart.id] + ) + return; + captureNumberingSnapshotForPart(currentPart, previewEngine); + }, 760); + } + }, 180); + + return () => { + window.clearTimeout(timer); + if (captureTimer !== null) window.clearTimeout(captureTimer); + previewEngine.cancelCameraAnimation(); + }; }, [ captureNumberingSnapshotForPart, modelUrl, @@ -1454,29 +2129,31 @@ export default function InstallationDemoWorkbenchClient({ numberingVisibleNodeNames.length, numberingVisibleNodeSignature, numberingSnapshotUrlsByPartId, + numberingSnapshotLineArtUrlsByPartId, preview.stageView, previewEngine, selectedDrawingId, - ]) + ]); useEffect(() => { const timer = window.setTimeout(() => { setNumberingRevealCount((current) => { - if (numberingModelParts.length === 0) return 0 - if (current <= 0) return 1 - return Math.min(current, numberingModelParts.length) - }) - }, 0) + if (numberingModelParts.length === 0) return 0; + if (current <= 0) return 1; + return Math.min(current, numberingModelParts.length); + }); + }, 0); - return () => window.clearTimeout(timer) - }, [numberingModelParts.length, numberingPlaying, preview.stageView]) + return () => window.clearTimeout(timer); + }, [numberingModelParts.length, numberingPlaying, preview.stageView]); useEffect(() => { - if (preview.stageView !== 'numbering') return - const fallbackPart = numberingVisibleModelParts[0] ?? numberingModelParts[0] ?? null - if (!fallbackPart) return + if (preview.stageView !== "numbering") return; + const fallbackPart = + numberingVisibleModelParts[0] ?? numberingModelParts[0] ?? null; + if (!fallbackPart) return; if (numberingCatalog.selectedPartId !== fallbackPart.id) { - numberingCatalog.setSelectedPartId(fallbackPart.id) + numberingCatalog.setSelectedPartId(fallbackPart.id); } }, [ numberingCatalog, @@ -1484,104 +2161,111 @@ export default function InstallationDemoWorkbenchClient({ numberingModelParts, numberingVisibleModelParts, preview.stageView, - ]) + ]); useEffect(() => { - if (!selectedDrawing?.id) return - autoAppliedNumberingViewKeyRef.current = null - numberingEntryCameraViewRef.current = null - previousStageViewRef.current = null - previewEngineRef.current = null + if (!selectedDrawing?.id) return; + autoAppliedNumberingViewKeyRef.current = null; + numberingEntryCameraViewRef.current = null; + previousStageViewRef.current = null; + previewEngineRef.current = null; const timer = window.setTimeout(() => { - setPreviewEngine(null) - setDetailView('finished') - setMode('edit') - setPlaying(false) - setNumberingPlaying(false) - setExplosionSelectedNodeNames([]) - setNumberingSelectedNodeNames([]) - setExplosionNumberingCategory('part') - setHardwareCompositeDefinitionPartId(null) - setHardwareCompositeDraftNodeNames([]) - setHardwareCompositeCandidate(null) - setNumberingRevealCount(0) - setModelNodeNames([]) - setModelNodeSnapshots([]) - }, 0) + setPreviewEngine(null); + setDetailView("finished"); + setMode("edit"); + setPlaying(false); + setNumberingPlaying(false); + setExplosionSelectedNodeNames([]); + setNumberingSelectedNodeNames([]); + setExplosionNumberingCategory("part"); + setHardwareCompositeDefinitionPartId(null); + setHardwareCompositeDraftNodeNames([]); + setHardwareCompositeCandidate(null); + setNumberingRevealCount(0); + setModelNodeNames([]); + setModelNodeSnapshots([]); + }, 0); - return () => window.clearTimeout(timer) - }, [selectedDrawing?.id]) + return () => window.clearTimeout(timer); + }, [selectedDrawing?.id]); useEffect(() => { - const previousStageView = previousStageViewRef.current - const engine = previewEngineRef.current ?? previewEngine + const previousStageView = previousStageViewRef.current; + const engine = previewEngineRef.current ?? previewEngine; - if (previousStageView !== 'numbering' && preview.stageView === 'numbering') { - numberingEntryCameraViewRef.current = engine ? captureInstallationCameraView(engine) : null + if ( + previousStageView !== "numbering" && + preview.stageView === "numbering" + ) { + numberingEntryCameraViewRef.current = engine + ? captureInstallationCameraView(engine) + : null; } - if (previousStageView === 'numbering' && preview.stageView !== 'numbering') { - const entryView = numberingEntryCameraViewRef.current - numberingEntryCameraViewRef.current = null - autoAppliedNumberingViewKeyRef.current = null + if ( + previousStageView === "numbering" && + preview.stageView !== "numbering" + ) { + const entryView = numberingEntryCameraViewRef.current; + numberingEntryCameraViewRef.current = null; + autoAppliedNumberingViewKeyRef.current = null; if (engine) { - engine.cancelCameraAnimation() + engine.cancelCameraAnimation(); if (entryView) { - applyInstallationCameraView(engine, entryView) + applyInstallationCameraView(engine, entryView); } } } - previousStageViewRef.current = preview.stageView - }, [preview.stageView, previewEngine]) + previousStageViewRef.current = preview.stageView; + }, [preview.stageView, previewEngine]); useEffect(() => { - if (preview.stageView !== 'numbering') { - autoAppliedNumberingViewKeyRef.current = null - return + if (preview.stageView !== "numbering") { + autoAppliedNumberingViewKeyRef.current = null; + return; } - if (!selectedDrawing?.id || !savedNumberingView) return + if (!selectedDrawing?.id || !savedNumberingView) return; - const nextKey = `${selectedDrawing.id}:${buildInstallationCameraViewKey(savedNumberingView)}` - if (autoAppliedNumberingViewKeyRef.current === nextKey) return + const nextKey = `${selectedDrawing.id}:${buildInstallationCameraViewKey(savedNumberingView)}`; + if (autoAppliedNumberingViewKeyRef.current === nextKey) return; - const restored = restoreSavedNumberingView(savedNumberingView) + const restored = restoreSavedNumberingView(savedNumberingView); if (restored) { - autoAppliedNumberingViewKeyRef.current = nextKey + autoAppliedNumberingViewKeyRef.current = nextKey; } }, [ preview.stageView, restoreSavedNumberingView, savedNumberingView, selectedDrawing?.id, - ]) + ]); const stepStageSelectedNodeNames = - preview.stageView === 'steps' - ? stepActionWorkspace.mode === 'edit' + preview.stageView === "steps" + ? stepActionWorkspace.mode === "edit" ? stepActionWorkspace.transformNodeNames : stepActionWorkspace.selectedNodeNames - : previewRuntimePresentation.selectedNodeNames + : previewRuntimePresentation.selectedNodeNames; const activeStageHiddenNodeNames = - preview.stageView === 'exploded' + preview.stageView === "exploded" ? explosionHiddenNodeNames - : preview.stageView === 'numbering' - ? numberingHiddenNodeNames - : preview.stageView === 'preview' - ? previewRuntimePresentation.hiddenNodeNames - : preview.stageView === 'steps' && stepActionWorkspace.mode !== 'preview' - ? stepActionWorkspace.hiddenNodeNames - : [] + : preview.stageView === "numbering" + ? numberingHiddenNodeNames + : preview.stageView === "preview" + ? previewRuntimePresentation.hiddenNodeNames + : preview.stageView === "steps" && + stepActionWorkspace.mode !== "preview" + ? stepActionWorkspace.hiddenNodeNames + : []; const enableStepTransformGizmo = - preview.stageView === 'steps' && - stepActionWorkspace.mode === 'edit' && - stepActionWorkspace.editTarget !== 'fixed' && - ( - stepActionWorkspace.editTarget !== 'action' || - Boolean(stepActionWorkspace.activeActionId) - ) && - stepActionWorkspace.transformNodeNames.length > 0 + preview.stageView === "steps" && + stepActionWorkspace.mode === "edit" && + stepActionWorkspace.editTarget !== "fixed" && + (stepActionWorkspace.editTarget !== "action" || + Boolean(stepActionWorkspace.activeActionId)) && + stepActionWorkspace.transformNodeNames.length > 0; const stepActionEditor = activeStep ? ( - ) : null + ) : null; return (
@@ -1633,25 +2319,55 @@ export default function InstallationDemoWorkbenchClient({ drawing={catalog.selectedDrawing} modelUrl={modelUrl} previewTool={numberingPreviewTool} - selectedNodeNames={stepStageSelectedNodeNames} - explosionSelectedNodeNames={explosionSelectedNodeNames} - numberingSelectedNodeNames={numberingSelectedNodeNames} - completedNodeNames={preview.completedNodeNames} - highlightedNodeNames={numberingHighlightedNodeNames} - hiddenNodeNames={activeStageHiddenNodeNames} + selectedNodeNames={ + drawingCapturePresentationActive ? [] : stepStageSelectedNodeNames + } + explosionSelectedNodeNames={ + drawingCapturePresentationActive ? [] : explosionSelectedNodeNames + } + numberingSelectedNodeNames={ + drawingCapturePresentationActive ? [] : numberingSelectedNodeNames + } + completedNodeNames={ + drawingCapturePresentationActive ? [] : preview.completedNodeNames + } + highlightedNodeNames={ + drawingCapturePresentationActive ? [] : numberingHighlightedNodeNames + } + hiddenNodeNames={ + drawingOverviewCaptureActive + ? [] + : (drawingActionCaptureFrame?.hiddenNodeNames ?? + activeStageHiddenNodeNames) + } modelNodeNames={modelNodeNames} - nodePositionOffsets={preview.animatedNodePositionOffsets} - nodeRotationStates={preview.animatedNodeRotationStates} - nodeLocalTransformStates={preview.localNodeTransformStates} - localTransformPlaybackActive={preview.localWorkflowActive} - selectedPart={previewSelectedPart} + nodePositionOffsets={ + drawingActionCaptureActive ? [] : preview.animatedNodePositionOffsets + } + nodeRotationStates={ + drawingActionCaptureActive ? [] : preview.animatedNodeRotationStates + } + nodeLocalTransformStates={ + drawingOverviewCaptureActive + ? [] + : (drawingActionCaptureFrame?.localTransforms ?? + preview.localNodeTransformStates) + } + localTransformPlaybackActive={ + !drawingCapturePresentationActive && preview.localWorkflowActive + } + selectedPart={ + drawingCapturePresentationActive ? null : previewSelectedPart + } numberingParts={numberingCatalog.parts} - explodedNumberingParts={explosionOverlayNumberingParts} + explodedNumberingParts={ + drawingCapturePresentationActive ? [] : explosionOverlayNumberingParts + } hardwareParts={numberingCatalog.parts} drawingTools={drawingTools.selectedTools} selectedNumberingPartId={numberingCatalog.selectedPartId} previewEngine={previewEngine} - activeStep={preview.activeStep} + activeStep={drawingActionCaptureFrame?.step ?? preview.activeStep} steps={stepEditor.steps} partCount={partCatalog.parts.length} selectionRole={selectionRole} @@ -1674,166 +2390,303 @@ export default function InstallationDemoWorkbenchClient({ stepMaterialThumbnailUrls={resolvedStepMaterialThumbnailUrls} stepMaterialMarkerParts={previewStepMaterialMarkerParts} boardAttentionParts={stepBoardAttentionParts} + drawingCaptureTarget={drawingCapture.captureTarget} playbackActionId={preview.localPlaybackActionId} playbackActionNodeNames={preview.localPlaybackActionNodeNames} playbackActionProgress={preview.localPlaybackActionProgress} playbackScrewDriveProgress={preview.localPlaybackScrewDriveProgress} playbackShowingFixedGroup={preview.localPlaybackShowingFixedGroup} playbackFinalizingAssembly={preview.localPlaybackFinalizingAssembly} - stepWorkspaceMode={stepActionWorkspace.mode} - editingActionId={stepActionWorkspace.activeActionId} - enableStepTransformGizmo={enableStepTransformGizmo} - canEdit={catalog.access.canEdit} + stepWorkspaceMode={ + drawingActionCaptureActive ? "edit" : stepActionWorkspace.mode + } + editingActionId={ + drawingActionCaptureFrame?.editingActionId ?? + stepActionWorkspace.activeActionId + } + enableStepTransformGizmo={ + !drawingCapturePresentationActive && enableStepTransformGizmo + } + canEdit={!drawingCapturePresentationActive && catalog.access.canEdit} + showCameraActionButtons={!drawingCapturePresentationActive} + showStatusBar={!drawingCapturePresentationActive} gizmoMode={explosionGizmoMode} onSelectionRoleChange={setSelectionRole} onNodeSelect={handleNodeSelect} onRemoveActionTool={stepActionWorkspace.removeActiveActionTool} onSelectPart={selectNumberingPart} onSelectDrawingTool={(toolId) => { - setNumberingCategory('tool') - setNumberingPreviewToolId(toolId) - setNumberingPlaying(false) - setNumberingRevealCount(0) - setNumberingSelectedNodeNames([]) - numberingCatalog.setSelectedPartId(null) + setNumberingCategory("tool"); + setNumberingPreviewToolId(toolId); + setNumberingPlaying(false); + setNumberingRevealCount(0); + setNumberingSelectedNodeNames([]); + numberingCatalog.setSelectedPartId(null); }} onModelNodesChange={(nextNodeNames) => { - const previewEngine = previewEngineRef.current + const previewEngine = previewEngineRef.current; if (previewEngine) { - syncModelGeometry(previewEngine, nextNodeNames) - return + syncModelGeometry(previewEngine, nextNodeNames); + return; } - setModelNodeNames(nextNodeNames) - setModelNodeSnapshots([]) + setModelNodeNames(nextNodeNames); + setModelNodeSnapshots([]); }} onEngineReady={({ engine }) => { - previewEngineRef.current = engine - setPreviewEngine(engine) - syncModelGeometry(engine) + previewEngineRef.current = engine; + setPreviewEngine(engine); + syncModelGeometry(engine); + }} + onCaptureReady={handlePreviewCaptureReady} + onDrawingCaptureMarkersChange={(snapshot) => { + drawingCaptureMarkersRef.current = snapshot; }} onNodePositionOffsetChange={({ nodeName, offset }) => { - if (detailView !== 'exploded') return - partCatalog.updatePartTransformByNodeName(nodeName, { offset }) + if (detailView !== "exploded") return; + partCatalog.updatePartTransformByNodeName(nodeName, { offset }); }} onNodeRotationChange={({ nodeName, rotation }) => { - if (detailView !== 'exploded') return - partCatalog.updatePartTransformByNodeName(nodeName, { rotation }) + if (detailView !== "exploded") return; + partCatalog.updatePartTransformByNodeName(nodeName, { rotation }); }} onNodeLocalTransformChange={(transform) => { - if (detailView !== 'steps' || stepActionWorkspace.mode !== 'edit') return - stepActionWorkspace.updateTransform(transform) + if (detailView !== "steps" || stepActionWorkspace.mode !== "edit") + return; + stepActionWorkspace.updateTransform(transform); }} onGizmoModeChange={setExplosionGizmoMode} onGizmoInteractionStart={() => { - if (detailView === 'exploded') { - setExplosionTransforming(true) - return + if (detailView === "exploded") { + setExplosionTransforming(true); + return; } - if (detailView === 'steps' && stepActionWorkspace.mode === 'edit') { - stepActionWorkspace.beginTransformInteraction() + if (detailView === "steps" && stepActionWorkspace.mode === "edit") { + stepActionWorkspace.beginTransformInteraction(); } }} onGizmoInteractionEnd={() => { - if (detailView === 'exploded') { - setExplosionTransforming(false) - return + if (detailView === "exploded") { + setExplosionTransforming(false); + return; } - if (detailView === 'steps' && stepActionWorkspace.mode === 'edit') { - stepActionWorkspace.endTransformInteraction() + if (detailView === "steps" && stepActionWorkspace.mode === "edit") { + stepActionWorkspace.endTransformInteraction(); } }} onModeChange={(nextMode) => { - setMode(nextMode) - setPlaying(false) + setMode(nextMode); + setPlaying(false); }} onStepWorkspaceModeChange={(nextMode) => { - stepActionWorkspace.setMode(nextMode) - setMode(nextMode === 'preview' ? 'preview' : 'edit') - setPlaying(false) - if (nextMode === 'preview') { - setStepPlaybackResetKey((current) => current + 1) + stepActionWorkspace.setMode(nextMode); + setMode(nextMode === "preview" ? "preview" : "edit"); + setPlaying(false); + if (nextMode === "preview") { + setStepPlaybackResetKey((current) => current + 1); } }} onTogglePlaying={() => { if (!playing && preview.localPlaybackComplete) { - setStepPlaybackResetKey((current) => current + 1) + setStepPlaybackResetKey((current) => current + 1); } - setPlaying((current) => !current) + setPlaying((current) => !current); }} onPreviousStep={() => { - setPlaying(false) - selectRelativeStep(-1) + setPlaying(false); + selectRelativeStep(-1); }} onNextStep={() => { - setPlaying(false) - selectRelativeStep(1) + setPlaying(false); + selectRelativeStep(1); }} onResetPreview={() => { - setPlaying(false) - setStepPlaybackResetKey((current) => current + 1) - if (stepEditor.steps[0]) stepEditor.setActiveStepId(stepEditor.steps[0].id) + setPlaying(false); + setStepPlaybackResetKey((current) => current + 1); + if (stepEditor.steps[0]) + stepEditor.setActiveStepId(stepEditor.steps[0].id); }} onToggleNumberingPlaying={() => { - if (numberingCategory === 'tool') { - if (drawingTools.selectedTools.length === 0) return - if (!numberingPlaying && numberingPreviewToolIndex >= drawingTools.selectedTools.length - 1) { - setNumberingPreviewToolId(drawingTools.selectedTools[0]?.id ?? null) + if (numberingCategory === "tool") { + if (drawingTools.selectedTools.length === 0) return; + if ( + !numberingPlaying && + numberingPreviewToolIndex >= drawingTools.selectedTools.length - 1 + ) { + setNumberingPreviewToolId( + drawingTools.selectedTools[0]?.id ?? null, + ); } - setNumberingPlaying((current) => !current) - return + setNumberingPlaying((current) => !current); + return; } - if (numberingModelParts.length === 0) return - if (!numberingPlaying && numberingCurrentIndex >= numberingModelParts.length) { - setNumberingRevealCount(1) + if (numberingModelParts.length === 0) return; + if ( + !numberingPlaying && + numberingCurrentIndex >= numberingModelParts.length + ) { + setNumberingRevealCount(1); } - setNumberingPlaying((current) => !current) + setNumberingPlaying((current) => !current); }} onPreviousNumbering={() => { - setNumberingPlaying(false) - if (numberingCategory === 'tool') { - const previousIndex = Math.max(0, numberingPreviewToolIndex - 1) - setNumberingPreviewToolId(drawingTools.selectedTools[previousIndex]?.id ?? null) - return + setNumberingPlaying(false); + if (numberingCategory === "tool") { + const previousIndex = Math.max(0, numberingPreviewToolIndex - 1); + setNumberingPreviewToolId( + drawingTools.selectedTools[previousIndex]?.id ?? null, + ); + return; } - setNumberingRevealCount((current) => Math.max(1, current - 1)) + setNumberingRevealCount((current) => Math.max(1, current - 1)); }} onNextNumbering={() => { - setNumberingPlaying(false) - if (numberingCategory === 'tool') { + setNumberingPlaying(false); + if (numberingCategory === "tool") { const nextIndex = Math.min( drawingTools.selectedTools.length - 1, Math.max(0, numberingPreviewToolIndex + 1), - ) - setNumberingPreviewToolId(drawingTools.selectedTools[nextIndex]?.id ?? null) - return + ); + setNumberingPreviewToolId( + drawingTools.selectedTools[nextIndex]?.id ?? null, + ); + return; } - setNumberingRevealCount((current) => Math.min(numberingModelParts.length, current + 1)) + setNumberingRevealCount((current) => + Math.min(numberingModelParts.length, current + 1), + ); }} onResetNumbering={() => { - setNumberingPlaying(false) - if (numberingCategory === 'tool') { - setNumberingPreviewToolId(drawingTools.selectedTools[0]?.id ?? null) - return + setNumberingPlaying(false); + if (numberingCategory === "tool") { + setNumberingPreviewToolId( + drawingTools.selectedTools[0]?.id ?? null, + ); + return; } - setNumberingRevealCount(numberingModelParts.length > 0 ? 1 : 0) + setNumberingRevealCount(numberingModelParts.length > 0 ? 1 : 0); }} onRetakeNumberingSnapshot={retakeCurrentNumberingSnapshot} onCaptureNumberingToolSnapshot={captureNumberingSnapshotForTool} onSaveNumberingSnapshotBatch={() => { - void saveNumberingSnapshotBatch() + void saveNumberingSnapshotBatch(); }} /> + {drawingCapture.captureTarget && !drawingAutoCapture.running ? ( + { + void drawingAutoCapture + .captureManualTarget() + .then((returnToTemplate) => { + if (returnToTemplate) setDrawingSheetOpen(true); + }); + }} + onReturnToTemplate={() => { + drawingCapture.cancelCapture(); + setDrawingSheetOpen(true); + }} + onCancel={drawingCapture.cancelCapture} + /> + ) : null} + + + + {drawingSheetOpen ? ( + { + drawingCapture.updateImageLayout( + target, + transform, + numberingMarkers, + numberingMarkerCountOverrides, + ); + void drawingAutoCapture.persistImageLayout( + target, + transform, + numberingMarkers, + numberingMarkerCountOverrides, + ); + }} + autoCapture={{ + phase: drawingAutoCapture.phase, + error: drawingAutoCapture.error, + hasActiveBatch: drawingAutoCapture.hasActiveBatch, + hasDraftBatch: drawingAutoCapture.hasDraftBatch, + isConfigurationChanged: drawingAutoCapture.isConfigurationChanged, + completedCount: drawingAutoCapture.completedCount, + totalCount: drawingAutoCapture.totalCount, + running: drawingAutoCapture.running, + onStart: () => { + void drawingAutoCapture.start(); + }, + onPause: drawingAutoCapture.pause, + }} + confirmation={{ + confirmedAt: drawingAutoCapture.confirmedAt, + confirmedImageMode: drawingAutoCapture.confirmedImageMode, + confirming: + drawingAutoCapture.confirming || drawingConfirmation.confirming, + message: drawingConfirmation.message, + onConfirm: (imageMode) => { + void drawingConfirmation.confirm(imageMode); + }, + }} + onCaptureTarget={(target, scrollTop) => { + setDrawingSheetScrollPosition({ + drawingId: selectedDrawingId, + scrollTop, + }); + setDrawingSheetOpen(false); + if (isInstallationDrawingOverviewCaptureTarget(target)) { + setPlaying(false); + } + drawingCapture.beginCapture(target); + }} + onClose={() => setDrawingSheetOpen(false)} + /> + ) : null} + { - setActiveTab(nextTab) - if (nextTab === 'share') { - setShareMessage(null) - return + setActiveTab(nextTab); + if (nextTab === "share") { + setShareMessage(null); + return; } - setPlaying(false) - if (nextTab === 'structure') setMode('edit') + setPlaying(false); + if (nextTab === "structure") setMode("edit"); }} spus={catalog.spus} selectedSpu={catalog.selectedSpu} @@ -1843,7 +2696,7 @@ export default function InstallationDemoWorkbenchClient({ catalogMessage={ numberingSnapshotMessage ?? catalog.message ?? - (preview.timeline.length > 0 && mode === 'preview' + (preview.timeline.length > 0 && mode === "preview" ? `预览时间线 ${Math.round(preview.timeline.reduce((total, item) => total + item.durationMs, 0) / 100) / 10}s` : null) } @@ -1862,7 +2715,9 @@ export default function InstallationDemoWorkbenchClient({ explosionNumberingParts={explosionNumberingParts} explosionSelectedNumberingPartId={ numberingCatalog.selectedPart && - resolveInstallationNumberingCategory(numberingCatalog.selectedPart) === explosionNumberingCategory + resolveInstallationNumberingCategory( + numberingCatalog.selectedPart, + ) === explosionNumberingCategory ? numberingCatalog.selectedPart.id : null } @@ -1888,25 +2743,25 @@ export default function InstallationDemoWorkbenchClient({ qrShareLoading={qrShareLoading} qrShareMessage={qrShareMessage} onDetailViewChange={(nextView) => { - setActiveTab('steps') - setDetailView(nextView) - setPlaying(false) - setNumberingPlaying(false) - if (nextView === 'numbering') { - setNumberingCategory('part') - setNumberingPreviewToolId(null) - setNumberingRevealCount(numberingCategoryCounts.part > 0 ? 1 : 0) + setActiveTab("steps"); + setDetailView(nextView); + setPlaying(false); + setNumberingPlaying(false); + if (nextView === "numbering") { + setNumberingCategory("part"); + setNumberingPreviewToolId(null); + setNumberingRevealCount(numberingCategoryCounts.part > 0 ? 1 : 0); } - if (nextView === 'exploded') { - setExplosionNumberingCategory('part') - setNumberingSelectedNodeNames([]) - numberingCatalog.setSelectedPartId(null) + if (nextView === "exploded") { + setExplosionNumberingCategory("part"); + setNumberingSelectedNodeNames([]); + numberingCatalog.setSelectedPartId(null); } - if (nextView !== 'steps') { - setMode('edit') + if (nextView !== "steps") { + setMode("edit"); } else { - stepActionWorkspace.setMode('scope') - setMode('edit') + stepActionWorkspace.setMode("scope"); + setMode("edit"); } }} onCreateShare={handleCreateShare} @@ -1918,53 +2773,62 @@ export default function InstallationDemoWorkbenchClient({ onUploadModel={handleUploadModel} onRefreshCatalog={catalog.refresh} onApplyAutoExplode={partCatalog.applyAutoExplode} - onApplyControllerStyleAutoExplode={partCatalog.applyControllerStyleAutoExplode} + onApplyControllerStyleAutoExplode={ + partCatalog.applyControllerStyleAutoExplode + } onResetSelectedPart={() => { - if (explosionSelectedNodeNames.length === 0) return - partCatalog.resetPartTransformsByNodeNames(explosionSelectedNodeNames) + if (explosionSelectedNodeNames.length === 0) return; + partCatalog.resetPartTransformsByNodeNames( + explosionSelectedNodeNames, + ); }} onResetAllParts={partCatalog.resetAllPartTransforms} onSelectNumberingPart={selectNumberingPart} onNumberingCategoryChange={(nextCategory) => { - setNumberingCategory(nextCategory) - setNumberingPlaying(false) - if (nextCategory === 'tool') { - setNumberingPreviewToolId(drawingTools.selectedTools[0]?.id ?? null) - setNumberingRevealCount(0) - setNumberingSelectedNodeNames([]) - numberingCatalog.setSelectedPartId(null) - return + setNumberingCategory(nextCategory); + setNumberingPlaying(false); + if (nextCategory === "tool") { + setNumberingPreviewToolId( + drawingTools.selectedTools[0]?.id ?? null, + ); + setNumberingRevealCount(0); + setNumberingSelectedNodeNames([]); + numberingCatalog.setSelectedPartId(null); + return; } const nextPartCount = buildInstallationNumberingDisplayParts( numberingCatalog.parts, nextCategory, - ).filter((part) => getPartNodeNames(part).length > 0).length - setNumberingRevealCount(nextPartCount > 0 ? 1 : 0) - setNumberingPreviewToolId(null) + ).filter((part) => getPartNodeNames(part).length > 0).length; + setNumberingRevealCount(nextPartCount > 0 ? 1 : 0); + setNumberingPreviewToolId(null); }} onSelectNumberingTool={(toolId) => { - setNumberingPreviewToolId(toolId) + setNumberingPreviewToolId(toolId); }} onExplosionNumberingCategoryChange={(nextCategory) => { - setExplosionNumberingCategory(nextCategory) - setNumberingSelectedNodeNames([]) - numberingCatalog.setSelectedPartId(null) + setExplosionNumberingCategory(nextCategory); + setNumberingSelectedNodeNames([]); + numberingCatalog.setSelectedPartId(null); }} onUpdateNumberingPart={numberingCatalog.updatePart} onToggleDrawingTool={drawingTools.toggleToolId} onCreateNumberingPartFromSelection={() => numberingCatalog.createPartFromNodes( numberingSelectedNodeNames, - detailView === 'exploded' && explosionNumberingCategory !== 'tool' + detailView === "exploded" && explosionNumberingCategory !== "tool" ? explosionNumberingCategory - : 'part', + : "part", ) } onAssignSelectionToNumberingPart={(partId) => numberingCatalog.assignNodesToPart(partId, numberingSelectedNodeNames) } onRemoveSelectionFromNumberingPart={(partId) => - numberingCatalog.removeNodesFromPart(partId, numberingSelectedNodeNames) + numberingCatalog.removeNodesFromPart( + partId, + numberingSelectedNodeNames, + ) } onMoveNumberingPart={numberingCatalog.movePart} onDeleteNumberingPart={numberingCatalog.deletePart} @@ -1972,11 +2836,15 @@ export default function InstallationDemoWorkbenchClient({ hardwareCompositeDraftNodeNames={hardwareCompositeDraftNodeNames} hardwareCompositeCandidate={hardwareCompositeCandidateSummary} onBeginHardwareCompositeDefinition={beginHardwareCompositeDefinition} - onConfirmHardwareCompositeDefinition={confirmHardwareCompositeDefinition} + onConfirmHardwareCompositeDefinition={ + confirmHardwareCompositeDefinition + } onCancelHardwareCompositeDefinition={cancelHardwareCompositeDefinition} onFindHardwareCompositeCandidates={findHardwareCompositeCandidates} onApplyHardwareCompositeCandidates={applyHardwareCompositeCandidates} - onDiscardHardwareCompositeCandidates={() => setHardwareCompositeCandidate(null)} + onDiscardHardwareCompositeCandidates={() => + setHardwareCompositeCandidate(null) + } onSelectStep={stepEditor.setActiveStepId} onCreateStep={stepEditor.createStep} onMoveStep={stepEditor.moveStep} @@ -1987,7 +2855,11 @@ export default function InstallationDemoWorkbenchClient({ onAddPartToken={stepEditor.addTokenToActiveStep} onAddStepGroupRef={stepEditor.addStepGroupRefToActiveStep} onRemoveStepGroupRef={stepEditor.removeStepGroupRefFromActiveStep} + onGenerateDrawing={() => { + drawingCapture.openTemplate(); + setDrawingSheetOpen(true); + }} />
- ) + ); } diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDetailsPanel.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDetailsPanel.tsx index f0a0a504..0f9e8108 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDetailsPanel.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDetailsPanel.tsx @@ -75,6 +75,7 @@ interface InstallationDetailsPanelProps { onAddPartToken: (role: InstallationPartRole, token: string) => void onAddStepGroupRef: (role: InstallationPartRole, sourceStepId: string) => void onRemoveStepGroupRef: (groupRefId: string) => void + onGenerateDrawing: () => void stepActionEditor?: ReactNode } @@ -247,6 +248,7 @@ export function InstallationDetailsPanel({ onAddPartToken, onAddStepGroupRef, onRemoveStepGroupRef, + onGenerateDrawing, stepActionEditor, }: InstallationDetailsPanelProps) { return ( @@ -409,6 +411,25 @@ export function InstallationDetailsPanel({ actionEditor={stepActionEditor} /> ) : null} + +
+
+
+

安装图纸

+

+ 先打开固定模板,再手动截取成品、爆炸图和每个动作画面。 +

+
+ +
+
) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx new file mode 100644 index 00000000..9db6a7cb --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingActionsPage.tsx @@ -0,0 +1,184 @@ +import { ListChecks, Maximize2 } from 'lucide-react' +import type { + InstallationDrawingActionCapture, + InstallationDrawingCaptureTarget, + InstallationDrawingImageMode, + InstallationDrawingNumberMarker, +} from '../lib/installationDrawingCaptureTypes' +import { isInstallationDrawingImageCaptured } from '../lib/installationDrawingCaptureTypes' +import type { InstallationDrawingActionGroup } from '../lib/installationDrawingLayout' +import { InstallationDrawingImageFrame } from './InstallationDrawingImageFrame' +import { InstallationDrawingNumberMarkers } from './InstallationDrawingNumberMarkers' + +interface Props { + groups: InstallationDrawingActionGroup[] + captures: InstallationDrawingActionCapture[] + imageMode: InstallationDrawingImageMode + snapshotUrls: Record + snapshotLineArtUrls: Record + onCaptureTarget?: (target: InstallationDrawingCaptureTarget) => void + onAdjustImage?: (target: InstallationDrawingCaptureTarget) => void + onManageNumberingMarkers?: (target: InstallationDrawingCaptureTarget) => void + onCommitNumberingMarkers?: ( + target: InstallationDrawingCaptureTarget, + markers: InstallationDrawingNumberMarker[], + ) => void + allowManualCapture: boolean + readOnly?: boolean +} + +interface ActionMaterialProps { + capture: InstallationDrawingActionCapture + snapshotUrls: Record + snapshotLineArtUrls: Record + imageMode: InstallationDrawingImageMode +} + +function ActionMaterials({ capture, snapshotUrls, snapshotLineArtUrls, imageMode }: ActionMaterialProps) { + if (capture.materials.length === 0) return null + + return ( +
+ {capture.materials.map((item) => { + const imageUrl = item.snapshotPartId + ? (imageMode === 'line-art' ? snapshotLineArtUrls[item.snapshotPartId] : snapshotUrls[item.snapshotPartId]) + : null + return ( +
+
+ {imageUrl ? ( + {item.name} + ) : ( + {imageMode === 'line-art' ? '未生成线稿' : item.kind === 'hardware' ? '五金' : '板件'} + )} +
+
+

{item.code || '-'}

+

x {item.quantity}

+
+
+ ) + })} +
+ ) +} + +export function InstallationDrawingActionsPage({ + groups, + captures, + imageMode, + snapshotUrls, + snapshotLineArtUrls, + onCaptureTarget, + onAdjustImage, + onManageNumberingMarkers, + onCommitNumberingMarkers, + allowManualCapture, + readOnly = false, +}: Props) { + const captureById = new Map(captures.map((capture) => [capture.id, capture])) + const actions = groups.flatMap((group) => group.actions) + + return ( +
+
+

安装动作分解

+

ASSEMBLY ACTIONS

+
+
+ {actions.map((action) => { + const capture = captureById.get(action.id) + const target: InstallationDrawingCaptureTarget = { + kind: 'action', + id: action.id, + stepId: action.stepId, + frameKind: action.frameKind, + actionId: action.actionId, + sequence: action.sequence, + } + const captured = capture ? isInstallationDrawingImageCaptured(capture.image) : false + return ( +
+
+ + {action.sequence} + + {capture ? : null} +
+ +
+ {capture && captured ? ( + + ) : ( +
待截取
+ )} + {capture && captured && capture.image.numberingMarkers.length > 0 ? ( + onCommitNumberingMarkers(target, markers) + : undefined} + /> + ) : null} +
+ + {capture && capture.tools.length > 0 ? ( +
+ {capture.tools.map((tool) => ( + 工具 {tool.name} + ))} +
+ ) : null} + + {!readOnly ?
+ {capture && captured && capture.materials.some((item) => item.kind === 'part') ? ( + + ) : null} + {capture && captured ? ( + + ) : null} + {allowManualCapture ? ( + + ) : null} +
: null} +
+ ) + })} +
+
+ ) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingAutoCaptureControl.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingAutoCaptureControl.tsx new file mode 100644 index 00000000..68920504 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingAutoCaptureControl.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Camera, CheckCircle2, LoaderCircle, Square } from "lucide-react"; + +interface InstallationDrawingAutoCaptureControlProps { + phase: + | "loading" + | "idle" + | "seeding" + | "running" + | "finalizing" + | "paused" + | "error"; + hasActiveBatch: boolean; + hasDraftBatch: boolean; + configurationChanged: boolean; + completedCount: number; + totalCount: number; + error: string | null; + onStart: () => void; + onPause: () => void; +} + +export function InstallationDrawingAutoCaptureControl({ + phase, + hasActiveBatch, + hasDraftBatch, + configurationChanged, + completedCount, + totalCount, + error, + onStart, + onPause, +}: InstallationDrawingAutoCaptureControlProps) { + const running = + phase === "seeding" || phase === "running" || phase === "finalizing"; + const loading = phase === "loading"; + const settled = hasActiveBatch && !hasDraftBatch && !running; + const startLabel = hasDraftBatch ? "继续抓取" : "自动抓取"; + + return ( +
+ {settled ? ( + + + 已带入 {completedCount}/{totalCount} + + ) : running ? ( + + ) : ( + + )} + {hasDraftBatch && !running ? ( + + 已完成 {completedCount}/{totalCount} + + ) : null} + {configurationChanged ? ( + + 配置已变更 + + ) : null} + {error && !running ? ( + + {error} + + ) : null} +
+ ); +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingCaptureToolbar.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingCaptureToolbar.tsx new file mode 100644 index 00000000..483fdc71 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingCaptureToolbar.tsx @@ -0,0 +1,38 @@ +import { Camera, LayoutTemplate, LoaderCircle, X } from 'lucide-react' +import type { InstallationDrawingCaptureTarget } from '../lib/installationDrawingCaptureTypes' + +interface InstallationDrawingCaptureToolbarProps { + target: InstallationDrawingCaptureTarget + capturing: boolean + captureReady: boolean + error: string | null + onCapture: () => void + onReturnToTemplate: () => void + onCancel: () => void +} + +function getTargetLabel(target: InstallationDrawingCaptureTarget) { + if (target.kind === 'finished') return '成品展示' + if (target.kind === 'exploded') return '产品爆炸图' + if (target.kind === 'inventory') return '部件图片' + return target.frameKind === 'fixed' + ? `画面 ${target.sequence} · 固定件起始状态` + : `画面 ${target.sequence} · 安装动作` +} + +export function InstallationDrawingCaptureToolbar({ target, capturing, captureReady, error, onCapture, onReturnToTemplate, onCancel }: InstallationDrawingCaptureToolbarProps) { + return ( +
+
+

当前图纸位置

{getTargetLabel(target)}

+ + + +
+ {error ?

{error}

: null} +
+ ) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingConfirmationControl.test.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingConfirmationControl.test.tsx new file mode 100644 index 00000000..0b00d687 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingConfirmationControl.test.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { InstallationDrawingConfirmationControl } from "./InstallationDrawingConfirmationControl"; + +describe("InstallationDrawingConfirmationControl", () => { + it("allows a complete unconfirmed drawing to be confirmed", () => { + const onConfirm = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "确认图纸样式" })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("shows a confirmed drawing as a fixed state", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "图纸已确认" })).toBeDisabled(); + }); +}); diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingConfirmationControl.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingConfirmationControl.tsx new file mode 100644 index 00000000..6fbf67e2 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingConfirmationControl.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { CheckCircle2, LoaderCircle } from "lucide-react"; + +interface InstallationDrawingConfirmationControlProps { + confirmedAt: string | null; + confirming: boolean; + disabledReason: string | null; + onConfirm: () => void; +} + +export function InstallationDrawingConfirmationControl({ + confirmedAt, + confirming, + disabledReason, + onConfirm, +}: InstallationDrawingConfirmationControlProps) { + const confirmed = Boolean(confirmedAt); + const disabled = confirming || confirmed || Boolean(disabledReason); + const label = confirming + ? "正在确认" + : confirmed + ? "图纸已确认" + : "确认图纸样式"; + + return ( + + ); +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageAdjustDialog.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageAdjustDialog.tsx new file mode 100644 index 00000000..e451ae0b --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageAdjustDialog.tsx @@ -0,0 +1,157 @@ +'use client' + +import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' +import { Check, Move, RotateCcw, X } from 'lucide-react' +import type { + InstallationDrawingCapturedImage, + InstallationDrawingCaptureTarget, + InstallationDrawingImageMode, + InstallationDrawingImageTransform, + InstallationDrawingNumberMarker, +} from '../lib/installationDrawingCaptureTypes' +import { DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM } from '../lib/installationDrawingCaptureTypes' +import { InstallationDrawingImageFrame } from './InstallationDrawingImageFrame' +import { InstallationDrawingNumberMarkers } from './InstallationDrawingNumberMarkers' + +interface InstallationDrawingImageAdjustDialogProps { + target: InstallationDrawingCaptureTarget + image: InstallationDrawingCapturedImage + initialMode: InstallationDrawingImageMode + onSave: ( + transform: InstallationDrawingImageTransform, + numberingMarkers: InstallationDrawingNumberMarker[], + ) => void + onClose: () => void +} + +interface DragState { + pointerId: number + clientX: number + clientY: number + offsetX: number + offsetY: number +} + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) + +export function InstallationDrawingImageAdjustDialog({ + target, + image, + initialMode, + onSave, + onClose, +}: InstallationDrawingImageAdjustDialogProps) { + const frameRef = useRef(null) + const dragRef = useRef(null) + const [mode, setMode] = useState(initialMode) + const [transform, setTransform] = useState(image.transform) + const [numberingMarkers, setNumberingMarkers] = useState( + () => image.numberingMarkers.map((marker) => ({ ...marker })), + ) + + const beginDrag = (event: ReactPointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId) + dragRef.current = { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + offsetX: transform.offsetX, + offsetY: transform.offsetY, + } + } + + const moveImage = (event: ReactPointerEvent) => { + const drag = dragRef.current + const frame = frameRef.current + if (!drag || drag.pointerId !== event.pointerId || !frame) return + const bounds = frame.getBoundingClientRect() + if (bounds.width <= 0 || bounds.height <= 0) return + setTransform((current) => ({ + ...current, + offsetX: clamp(drag.offsetX + ((event.clientX - drag.clientX) / bounds.width) * 100, -75, 75), + offsetY: clamp(drag.offsetY + ((event.clientY - drag.clientY) / bounds.height) * 100, -75, 75), + })) + } + + const endDrag = (event: ReactPointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return + dragRef.current = null + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + } + + const previewImage = { ...image, transform } + const frameRatioClass = target.kind === 'action' ? 'aspect-[5/6]' : 'aspect-[4/3]' + const numberingSize = target.kind === 'exploded' ? 'small' : target.kind === 'action' ? 'large' : null + + return ( +
+
+
+
+

调整画面

+

拖动空白处调整模型;红色编号可单独拖动

+
+ +
+ +
+
+ + {numberingSize && numberingMarkers.length > 0 ? ( + + ) : null} + 拖动定位 +
+ +
+
+

显示版本

+
+ + +
+
+ + + + + +
+ + +
+
+
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageFrame.test.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageFrame.test.tsx new file mode 100644 index 00000000..13532301 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageFrame.test.tsx @@ -0,0 +1,34 @@ +import { render } from '@testing-library/react' +import { InstallationDrawingImageFrame } from './InstallationDrawingImageFrame' + +describe('InstallationDrawingImageFrame', () => { + it('converts screen scaling into static print geometry', () => { + const { container } = render( + , + ) + + const screenImage = container.querySelector('.installation-drawing-screen-image') + const printImage = container.querySelector('.installation-drawing-print-image') + const printImageContent = printImage?.querySelector('img') + expect(screenImage?.style.transform).toBe('translate(-50%, -50%) scale(1.4)') + expect(printImage?.style.width).toBe('140%') + expect(printImage?.style.left).toBe('53%') + expect(printImage?.style.top).toBe('48%') + expect(printImage?.style.transform).toBe('translate(-50%, -50%)') + expect(printImage?.style.transform).not.toContain('scale') + expect(printImage).toHaveAttribute('aria-hidden', 'true') + expect(printImageContent).toHaveClass('block', 'h-full', 'w-full', 'object-fill') + expect(printImageContent).not.toHaveClass('absolute', 'inset-0') + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageFrame.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageFrame.tsx new file mode 100644 index 00000000..146a0dc5 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingImageFrame.tsx @@ -0,0 +1,95 @@ +import type { + InstallationDrawingCapturedImage, + InstallationDrawingImageMode, +} from '../lib/installationDrawingCaptureTypes' +import { resolveInstallationDrawingImageUrl } from '../lib/installationDrawingCaptureTypes' + +interface InstallationDrawingImageFrameProps { + image: InstallationDrawingCapturedImage + mode: InstallationDrawingImageMode + alt: string +} + +export function InstallationDrawingImageFrame({ + image, + mode, + alt, +}: InstallationDrawingImageFrameProps) { + const src = resolveInstallationDrawingImageUrl(image, mode) + if (!src) return null + + const transformStyle = { + left: `${50 + image.transform.offsetX}%`, + top: `${50 + image.transform.offsetY}%`, + transform: `translate(-50%, -50%) scale(${image.transform.scale})`, + transformOrigin: 'center', + } + const printPositionStyle = { + left: `${50 + image.transform.offsetX}%`, + top: `${50 + image.transform.offsetY}%`, + transform: 'translate(-50%, -50%)', + transformOrigin: 'center', + } + + if (image.captureAspectRatio) { + return ( + <> +
+ {alt} +
+ + + ) + } + + return ( + <> + {alt} + + + ) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingInventoryPage.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingInventoryPage.tsx new file mode 100644 index 00000000..655ed645 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingInventoryPage.tsx @@ -0,0 +1,98 @@ +import { Maximize2 } from 'lucide-react' +import type { + InstallationDrawingImageMode, + InstallationDrawingImageTransform, +} from '../lib/installationDrawingCaptureTypes' +import { DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM } from '../lib/installationDrawingCaptureTypes' +import { InstallationDrawingImageFrame } from './InstallationDrawingImageFrame' +import { + getInstallationDrawingInventoryItemKey, + type InstallationDrawingInventoryItem, +} from './InstallationDrawingOverviewInventory' + +interface Props { + items: InstallationDrawingInventoryItem[] + imageMode: InstallationDrawingImageMode + transforms: Record + onAdjustImage?: (target: { kind: 'inventory'; id: string }) => void + readOnly?: boolean +} + +function buildImage( + item: InstallationDrawingInventoryItem, + transform: InstallationDrawingImageTransform, +) { + return { + originalUrl: item.originalImageUrl ?? null, + lineArtUrl: item.lineArtImageUrl ?? null, + transform, + numberingMarkers: [], + numberingMarkerCountOverrides: {}, + captureAspectRatio: null, + } +} + +export function InstallationDrawingInventoryPage({ + items, + imageMode, + transforms, + onAdjustImage, + readOnly = false, +}: Props) { + return ( +
+
+

03 部件

+
+
+ {items.map((item) => { + const itemKey = getInstallationDrawingInventoryItemKey(item) + const image = buildImage( + item, + transforms[itemKey] ?? DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM, + ) + const imageUrl = imageMode === 'line-art' ? image.lineArtUrl : image.originalUrl + + return ( +
+
+

{item.code || '-'}

+

x{item.quantity}

+
+
+ {imageUrl ? ( + + ) : ( + + {imageMode === 'line-art' ? '未生成线稿' : '暂无图片'} + + )} + {imageUrl && !readOnly ? ( + + ) : null} +
+

+ {item.name || item.code || '未命名'} +

+
+ ) + })} +
+
+ ) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingMarkerManagerDialog.test.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingMarkerManagerDialog.test.tsx new file mode 100644 index 00000000..7571d193 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingMarkerManagerDialog.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { InstallationDrawingMarkerManagerDialog } from './InstallationDrawingMarkerManagerDialog' + +const image = { + originalUrl: 'original.png', + lineArtUrl: 'line-art.png', + transform: { scale: 1, offsetX: 0, offsetY: 0 }, + numberingMarkers: [ + { id: '27-1', partId: 'part-27', code: '27', x: 0.3, y: 0.4, placed: true }, + { id: '27-2', partId: 'part-27', code: '27', x: 0.5, y: 0.6, placed: true }, + ], + numberingMarkerCountOverrides: { 'part-27': 2 }, + captureAspectRatio: 1.5, +} + +const sources = [ + { id: 'part-27', code: '27', name: '斜实木脚', quantity: 5 }, +] + +describe('InstallationDrawingMarkerManagerDialog', () => { + it('keeps source quantity read-only while changing the drawing marker count', () => { + const onSave = jest.fn() + render( + , + ) + + expect(screen.getByText('产品用量 5')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '增加 27 标注' })) + expect(screen.getByText('待放置 1')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '保存编号' })) + + expect(onSave).toHaveBeenCalledTimes(1) + const [markers, overrides] = onSave.mock.calls[0] + expect(markers).toHaveLength(3) + expect(markers.filter((marker: { placed: boolean }) => !marker.placed)).toHaveLength(1) + expect(overrides).toEqual({ 'part-27': 3 }) + }) + + it('deletes one specific marker and lowers only the drawing marker count', () => { + const onSave = jest.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: '删除 27-1' })) + fireEvent.click(screen.getByRole('button', { name: '保存编号' })) + + const [markers, overrides] = onSave.mock.calls[0] + expect(markers).toHaveLength(1) + expect(markers[0].id).toBe('27-2') + expect(overrides).toEqual({ 'part-27': 1 }) + expect(screen.getByText('产品用量 5')).toBeInTheDocument() + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingMarkerManagerDialog.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingMarkerManagerDialog.tsx new file mode 100644 index 00000000..abeefc01 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingMarkerManagerDialog.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useMemo, useState } from 'react' +import { Check, Minus, Plus, Trash2, X } from 'lucide-react' +import { + countInstallationDrawingUnplacedMarkers, + reconcileInstallationDrawingNumberMarkers, +} from '../lib/installationDrawingMarkerInventory' +import type { + InstallationDrawingCapturedImage, + InstallationDrawingMarkerSource, + InstallationDrawingNumberMarker, +} from '../lib/installationDrawingCaptureTypes' + +interface Props { + title: string + image: InstallationDrawingCapturedImage + sources: InstallationDrawingMarkerSource[] + onSave: ( + markers: InstallationDrawingNumberMarker[], + countOverrides: Record, + ) => void + onClose: () => void +} + +function markerBelongsToSource( + marker: InstallationDrawingNumberMarker, + source: InstallationDrawingMarkerSource, +) { + return marker.partId === source.id || (!marker.partId && marker.code === source.code) +} + +export function InstallationDrawingMarkerManagerDialog({ + title, + image, + sources, + onSave, + onClose, +}: Props) { + const [countOverrides, setCountOverrides] = useState>( + () => ({ ...image.numberingMarkerCountOverrides }), + ) + const [markers, setMarkers] = useState( + () => reconcileInstallationDrawingNumberMarkers({ + markers: image.numberingMarkers, + sources, + countOverrides: image.numberingMarkerCountOverrides, + }), + ) + const totalUnplaced = useMemo( + () => countInstallationDrawingUnplacedMarkers(markers), + [markers], + ) + + const setSourceCount = (source: InstallationDrawingMarkerSource, count: number) => { + const nextOverrides = { + ...countOverrides, + [source.id]: Math.min(240, Math.max(0, count)), + } + setCountOverrides(nextOverrides) + setMarkers((current) => reconcileInstallationDrawingNumberMarkers({ + markers: current, + sources, + countOverrides: nextOverrides, + })) + } + + const deleteMarker = ( + source: InstallationDrawingMarkerSource, + markerId: string, + ) => { + const sourceCount = markers.filter((marker) => markerBelongsToSource(marker, source)).length + const nextOverrides = { + ...countOverrides, + [source.id]: Math.max(0, sourceCount - 1), + } + setCountOverrides(nextOverrides) + setMarkers((current) => reconcileInstallationDrawingNumberMarkers({ + markers: current.filter((marker) => marker.id !== markerId), + sources, + countOverrides: nextOverrides, + })) + } + + return ( +
+
+
+
+

编号管理 · {title}

+

+ 产品用量保持原值;图纸标注数可独立调整。待放置编号保存后可直接拖到模型上。 +

+
+ +
+ +
+
+ 共 {markers.length} 个图纸编号 + 0 ? 'text-[#b14b37]' : 'text-[#477b69]'}> + {totalUnplaced > 0 ? `待放置 ${totalUnplaced}` : '全部已放置'} + +
+
+ {sources.map((source) => { + const sourceMarkers = markers.filter((marker) => markerBelongsToSource(marker, source)) + const unplacedCount = sourceMarkers.filter((marker) => !marker.placed).length + return ( +
+
+

{source.code} · {source.name}

+

产品用量 {source.quantity}

+
+
+

待放置

+

0 ? 'text-[#b14b37]' : 'text-[#477b69]'}`}>{unplacedCount}

+
+
+ + {sourceMarkers.length} + +
+
+ {sourceMarkers.map((marker, index) => ( + + {source.code}-{index + 1}{marker.placed ? '' : ' 待放置'} + + + ))} + {sourceMarkers.length === 0 ? 不在图纸标注 : null} +
+
+ ) + })} +
+
+ +
+ + +
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingNumberMarkers.test.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingNumberMarkers.test.tsx new file mode 100644 index 00000000..22b5e9c5 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingNumberMarkers.test.tsx @@ -0,0 +1,174 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { InstallationDrawingNumberMarkers } from './InstallationDrawingNumberMarkers' + +describe('InstallationDrawingNumberMarkers', () => { + it('moves one marker in template coordinates', () => { + const onChange = jest.fn() + render( +
+ +
, + ) + const marker = screen.getByRole('button', { name: '移动编号 1' }) + const layer = marker.parentElement as HTMLDivElement + Object.defineProperty(layer, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 200, + bottom: 100, + width: 200, + height: 100, + toJSON: () => ({}), + }), + }) + Object.defineProperty(marker, 'setPointerCapture', { + configurable: true, + value: jest.fn(), + }) + + const pointerDown = new MouseEvent('pointerdown', { + bubbles: true, + clientX: 50, + clientY: 50, + }) + Object.defineProperty(pointerDown, 'pointerId', { value: 7 }) + const pointerMove = new MouseEvent('pointermove', { + bubbles: true, + clientX: 90, + clientY: 70, + }) + Object.defineProperty(pointerMove, 'pointerId', { value: 7 }) + + fireEvent(marker, pointerDown) + fireEvent(marker, pointerMove) + + expect(onChange).toHaveBeenLastCalledWith([ + { id: 'part-1:0', partId: 'part-1', code: '1', x: 0.45, y: 0.7, placed: true }, + ]) + }) + + it('commits a direct template drag when the pointer is released', () => { + const onCommit = jest.fn() + render( +
+ +
, + ) + const marker = screen.getByRole('button', { name: '移动编号 2' }) + const layer = marker.parentElement as HTMLDivElement + Object.defineProperty(layer, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 200, + bottom: 100, + width: 200, + height: 100, + toJSON: () => ({}), + }), + }) + Object.defineProperty(marker, 'setPointerCapture', { + configurable: true, + value: jest.fn(), + }) + Object.defineProperty(marker, 'hasPointerCapture', { + configurable: true, + value: jest.fn(() => false), + }) + + const dispatchPointer = (type: string, clientX: number, clientY: number) => { + const event = new MouseEvent(type, { bubbles: true, clientX, clientY }) + Object.defineProperty(event, 'pointerId', { value: 9 }) + fireEvent(marker, event) + } + dispatchPointer('pointerdown', 80, 30) + dispatchPointer('pointermove', 120, 50) + expect(Number.parseFloat(marker.style.left)).toBeCloseTo(60) + expect(Number.parseFloat(marker.style.top)).toBeCloseTo(50) + + dispatchPointer('pointerup', 120, 50) + expect(onCommit).toHaveBeenCalledTimes(1) + const committedMarkers = onCommit.mock.calls[0][0] + expect(committedMarkers[0]).toMatchObject({ id: 'part-2:0', code: '2' }) + expect(committedMarkers[0].x).toBeCloseTo(0.6) + expect(committedMarkers[0].y).toBeCloseTo(0.5) + }) + + it('marks a staged number as placed after it is dragged', () => { + const onChange = jest.fn() + render( +
+ +
, + ) + const marker = screen.getByRole('button', { name: '移动编号 8' }) + const layer = marker.parentElement as HTMLDivElement + Object.defineProperty(layer, 'getBoundingClientRect', { + configurable: true, + value: () => ({ width: 200, height: 100 }), + }) + Object.defineProperty(marker, 'setPointerCapture', { + configurable: true, + value: jest.fn(), + }) + + const pointerDown = new MouseEvent('pointerdown', { bubbles: true, clientX: 180, clientY: 10 }) + Object.defineProperty(pointerDown, 'pointerId', { value: 11 }) + const pointerMove = new MouseEvent('pointermove', { bubbles: true, clientX: 140, clientY: 40 }) + Object.defineProperty(pointerMove, 'pointerId', { value: 11 }) + fireEvent(marker, pointerDown) + fireEvent(marker, pointerMove) + + expect(onChange.mock.calls[0][0][0]).toMatchObject({ + id: 'part-8:0', + placed: true, + x: 0.7, + y: 0.4, + }) + }) + + it('renders a separate non-interactive marker for printing', () => { + const { container } = render( +
+ +
, + ) + + expect(screen.getByRole('button', { name: '移动编号 9' })).toHaveAttribute( + 'data-drawing-number-marker', + 'part-9:0', + ) + const printMarker = container.querySelector('[data-drawing-print-number-marker="part-9:0"]') + expect(printMarker).not.toBeNull() + expect(printMarker).not.toHaveAttribute('role') + expect(printMarker).not.toHaveAttribute('title') + expect(printMarker).toHaveClass('drawing-number-marker-print-small') + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingNumberMarkers.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingNumberMarkers.tsx new file mode 100644 index 00000000..89df9332 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingNumberMarkers.tsx @@ -0,0 +1,129 @@ +'use client' + +import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' +import type { InstallationDrawingNumberMarker } from '../lib/installationDrawingCaptureTypes' + +interface InstallationDrawingNumberMarkersProps { + markers: InstallationDrawingNumberMarker[] + size: 'small' | 'large' + editable?: boolean + onChange?: (markers: InstallationDrawingNumberMarker[]) => void + onCommit?: (markers: InstallationDrawingNumberMarker[]) => void +} + +interface MarkerDragState { + id: string + pointerId: number + clientX: number + clientY: number + x: number + y: number +} + +const clamp = (value: number) => Math.min(0.98, Math.max(0.02, value)) + +export function InstallationDrawingNumberMarkers({ + markers, + size, + editable = false, + onChange, + onCommit, +}: InstallationDrawingNumberMarkersProps) { + const layerRef = useRef(null) + const dragRef = useRef(null) + const latestMarkersRef = useRef(markers) + const [draggedMarkers, setDraggedMarkers] = useState(null) + const markerClassName = size === 'small' + ? 'h-3 w-3 border-[1.25px] text-[7px]' + : 'h-5 w-5 border-2 text-[11px]' + const currentMarkers = draggedMarkers ?? markers + + const beginDrag = ( + event: ReactPointerEvent, + marker: InstallationDrawingNumberMarker, + ) => { + if (!editable || (!onChange && !onCommit)) return + event.stopPropagation() + event.currentTarget.setPointerCapture(event.pointerId) + latestMarkersRef.current = markers + setDraggedMarkers(markers) + dragRef.current = { + id: marker.id, + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + x: marker.x, + y: marker.y, + } + } + + const moveMarker = (event: ReactPointerEvent) => { + const drag = dragRef.current + const layer = layerRef.current + if (!editable || !drag || drag.pointerId !== event.pointerId || !layer) return + event.stopPropagation() + const bounds = layer.getBoundingClientRect() + if (bounds.width <= 0 || bounds.height <= 0) return + const nextX = clamp(drag.x + (event.clientX - drag.clientX) / bounds.width) + const nextY = clamp(drag.y + (event.clientY - drag.clientY) / bounds.height) + const nextMarkers = latestMarkersRef.current.map((marker) => ( + marker.id === drag.id ? { ...marker, x: nextX, y: nextY, placed: true } : marker + )) + latestMarkersRef.current = nextMarkers + setDraggedMarkers(nextMarkers) + onChange?.(nextMarkers) + } + + const endDrag = (event: ReactPointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return + event.stopPropagation() + dragRef.current = null + onCommit?.(latestMarkersRef.current) + setDraggedMarkers(null) + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + } + + return ( + <> +
+ {currentMarkers.map((marker) => ( + beginDrag(event, marker)} + onPointerMove={moveMarker} + onPointerUp={endDrag} + onPointerCancel={endDrag} + className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full font-black leading-none text-[#ef3828] ${marker.placed ? 'border-[#ef3828] bg-white/90' : 'border-dashed border-[#d9782e] bg-[#fff4df] shadow-[0_0_0_2px_rgba(255,255,255,0.85)]'} ${markerClassName} ${editable ? 'pointer-events-auto touch-none cursor-move shadow-sm' : ''}`} + style={{ left: `${marker.x * 100}%`, top: `${marker.y * 100}%` }} + > + {marker.code} + + ))} +
+
+ {markers.map((marker) => ( + + {marker.code} + + ))} +
+ + ) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewActions.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewActions.tsx new file mode 100644 index 00000000..5401c36a --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewActions.tsx @@ -0,0 +1,50 @@ +import type { InstallationDrawingCaptures, InstallationDrawingCaptureTarget } from '../lib/installationDrawingCaptureTypes' +import { isInstallationDrawingImageCaptured } from '../lib/installationDrawingCaptureTypes' +import type { InstallationDrawingActionGroup } from '../lib/installationDrawingLayout' +import { InstallationDrawingImageFrame } from './InstallationDrawingImageFrame' + +interface InstallationDrawingOverviewActionsProps { + groups: InstallationDrawingActionGroup[] + captures: InstallationDrawingCaptures['actions'] + onCaptureTarget: (target: InstallationDrawingCaptureTarget) => void +} + +export function InstallationDrawingOverviewActions({ groups, captures, onCaptureTarget }: InstallationDrawingOverviewActionsProps) { + const actions = groups.flatMap((group) => group.actions) + const captureById = new Map(captures.map((capture) => [capture.id, capture])) + if (actions.length === 0) return null + + return ( +
+
+

04 安装

+

ASSEMBLY ACTIONS

+
+
+ {actions.map((action) => { + const capture = captureById.get(action.id) + const target: InstallationDrawingCaptureTarget = { + kind: 'action', + id: action.id, + stepId: action.stepId, + frameKind: action.frameKind, + actionId: action.actionId, + sequence: action.sequence, + } + const captured = capture ? isInstallationDrawingImageCaptured(capture.image) : false + return ( +
+
+ {capture && captured ? :
待截取
} +
+
+

安装动作 {action.sequence}

+ +
+
+ ) + })} +
+
+ ) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewInventory.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewInventory.tsx new file mode 100644 index 00000000..d0683734 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewInventory.tsx @@ -0,0 +1,79 @@ +import type { InstallationPartItem } from '../types' + +export interface InstallationDrawingOverviewTool { + id: string + code: string + name: string + quantity: number + imageUrl?: string + lineArtImageUrl?: string +} + +export interface InstallationDrawingInventoryItem { + id: string + code: string + name: string + quantity: number + originalImageUrl?: string + lineArtImageUrl?: string + kind: 'part' | 'hardware' | 'tool' +} + +export function getInstallationDrawingInventoryItemKey( + item: Pick, +) { + return `${item.kind}:${item.id}` +} + +interface Props { + parts: InstallationPartItem[] + numberingParts: InstallationPartItem[] + tools: InstallationDrawingOverviewTool[] + snapshotUrls: Record + snapshotLineArtUrls: Record + toolSnapshotUrls: Record + toolSnapshotLineArtUrls: Record +} + +const labels = { part: '板件', hardware: '五金', tool: '工具' } as const + +export function buildInstallationDrawingInventoryItems({ parts, numberingParts, tools, snapshotUrls, snapshotLineArtUrls, toolSnapshotUrls, toolSnapshotLineArtUrls }: Props): InstallationDrawingInventoryItem[] { + const sourceParts = numberingParts.length > 0 ? numberingParts : parts + return [ + ...sourceParts.map((part) => ({ id: part.id, code: part.code, name: part.name, quantity: part.quantity, originalImageUrl: snapshotUrls[part.id], lineArtImageUrl: snapshotLineArtUrls[part.id], kind: part.kind === 'hardware' ? 'hardware' as const : 'part' as const })), + ...tools.map((tool) => ({ id: tool.id, code: tool.code, name: tool.name, quantity: tool.quantity, originalImageUrl: tool.imageUrl ?? toolSnapshotUrls[tool.id], lineArtImageUrl: tool.lineArtImageUrl ?? toolSnapshotLineArtUrls[tool.id], kind: 'tool' as const })), + ] +} + +interface InventoryGridProps { + items: InstallationDrawingInventoryItem[] + title?: string + showCount?: boolean +} + +export function InstallationDrawingInventoryGrid({ items, title = '03 部件', showCount = true }: InventoryGridProps) { + return ( +
+
+

{title}

PARTS / HARDWARE / TOOLS

+ {showCount ? 共 {items.length} 项 : null} +
+ {items.length > 0 ? ( +
+ {items.map((item) => ( +
+
+ {item.originalImageUrl ? {item.name} : {item.code || '-'}} +
+

{item.code || '-'}

x {item.quantity}

{labels[item.kind]} · {item.name || '未命名'}

+
+ ))} +
+ ) :
暂无部件数据
} +
+ ) +} + +export function InstallationDrawingOverviewInventory(props: Props) { + return +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewPage.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewPage.tsx new file mode 100644 index 00000000..115ee4fb --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingOverviewPage.tsx @@ -0,0 +1,174 @@ +import { ListChecks, Maximize2 } from 'lucide-react' +import type { + InstallationDrawingCapturedImage, + InstallationDrawingCaptures, + InstallationDrawingCaptureTarget, + InstallationDrawingImageMode, + InstallationDrawingNumberMarker, +} from '../lib/installationDrawingCaptureTypes' +import { isInstallationDrawingImageCaptured } from '../lib/installationDrawingCaptureTypes' +import type { InstallationDrawingNode, InstallationSpuNode } from '../types' +import { InstallationDrawingImageFrame } from './InstallationDrawingImageFrame' +import { InstallationDrawingNumberMarkers } from './InstallationDrawingNumberMarkers' + +interface Props { + drawing: Pick | null + selectedSpu: Pick | null + captures: InstallationDrawingCaptures + imageMode: InstallationDrawingImageMode + onCaptureTarget?: (target: InstallationDrawingCaptureTarget) => void + onAdjustImage?: (target: InstallationDrawingCaptureTarget) => void + onManageNumberingMarkers?: (target: InstallationDrawingCaptureTarget) => void + onCommitNumberingMarkers?: ( + target: InstallationDrawingCaptureTarget, + markers: InstallationDrawingNumberMarker[], + ) => void + allowManualCapture: boolean + readOnly?: boolean +} + +interface DrawingImageProps { + image: InstallationDrawingCapturedImage + mode: InstallationDrawingImageMode + alt: string + target: InstallationDrawingCaptureTarget + emptyLabel: string + onCaptureTarget?: (target: InstallationDrawingCaptureTarget) => void + onAdjustImage?: (target: InstallationDrawingCaptureTarget) => void + onManageNumberingMarkers?: (target: InstallationDrawingCaptureTarget) => void + onCommitNumberingMarkers?: ( + target: InstallationDrawingCaptureTarget, + markers: InstallationDrawingNumberMarker[], + ) => void + allowManualCapture: boolean + numberingSize?: 'small' | 'large' + readOnly?: boolean +} + +function DrawingImage({ + image, + mode, + alt, + target, + emptyLabel, + onCaptureTarget, + onAdjustImage, + onManageNumberingMarkers, + onCommitNumberingMarkers, + allowManualCapture, + numberingSize, + readOnly = false, +}: DrawingImageProps) { + const captured = isInstallationDrawingImageCaptured(image) + return ( +
+ {captured ? ( + + ) : ( +
+ {emptyLabel} +
+ )} + {captured && numberingSize && image.numberingMarkers.length > 0 ? ( + onCommitNumberingMarkers(target, markers) + : undefined} + /> + ) : null} + {!readOnly ?
+ {captured && onManageNumberingMarkers ? ( + + ) : null} + {captured ? ( + + ) : null} + {allowManualCapture ? ( + + ) : null} +
: null} +
+ ) +} + +export function InstallationDrawingOverviewPage({ + drawing, + selectedSpu, + captures, + imageMode, + onCaptureTarget, + onAdjustImage, + onManageNumberingMarkers, + onCommitNumberingMarkers, + allowManualCapture, + readOnly = false, +}: Props) { + const drawingIdentity = [ + selectedSpu?.code ?? drawing?.spuCode, + drawing?.modelCode, + drawing?.code, + ].filter(Boolean).join(' / ') || '-' + + return ( +
+
+

{drawingIdentity}

+
+
+
+
+

01

+

成品展示

+

FINISHED

+
+ +
+
+
+

02

+

产品爆炸图

+

EXPLODED

+
+ +
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx new file mode 100644 index 00000000..527d0237 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheet.tsx @@ -0,0 +1,653 @@ +"use client"; + +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Printer, X } from "lucide-react"; +import type { + InstallationDrawingNode, + InstallationPartItem, + InstallationSpuNode, + InstallationStepDraft, + InstallationToolItem, +} from "../types"; +import type { + InstallationDrawingCaptures, + InstallationDrawingCaptureTarget, + InstallationDrawingCapturedImage, + InstallationDrawingImageMode, + InstallationDrawingImageTransform, + InstallationDrawingNumberMarker, +} from "../lib/installationDrawingCaptureTypes"; +import { + DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM, + isInstallationDrawingImageAvailable, + isInstallationDrawingImageCaptured, + resolveInstallationDrawingConfirmedAtForMode, + resolveInstallationDrawingImageMode, +} from "../lib/installationDrawingCaptureTypes"; +import { buildInstallationDrawingLayout } from "../lib/installationDrawingLayout"; +import { useInstallationDrawingMarkerInventory } from "../hooks/useInstallationDrawingMarkerInventory"; +import { + DEFAULT_INSTALLATION_DRAWING_TEMPLATE_ID, + INSTALLATION_DRAWING_TEMPLATES, + type InstallationDrawingTemplateId, +} from "../lib/installationDrawingTemplates"; +import { InstallationDrawingActionsPage } from "./InstallationDrawingActionsPage"; +import { InstallationDrawingAutoCaptureControl } from "./InstallationDrawingAutoCaptureControl"; +import { InstallationDrawingConfirmationControl } from "./InstallationDrawingConfirmationControl"; +import { InstallationDrawingInventoryPage } from "./InstallationDrawingInventoryPage"; +import { InstallationDrawingOverviewPage } from "./InstallationDrawingOverviewPage"; +import { InstallationDrawingImageAdjustDialog } from "./InstallationDrawingImageAdjustDialog"; +import { InstallationDrawingMarkerManagerDialog } from "./InstallationDrawingMarkerManagerDialog"; +import { + buildInstallationDrawingInventoryItems, + type InstallationDrawingOverviewTool, +} from "./InstallationDrawingOverviewInventory"; + +interface InstallationDrawingSheetProps { + drawing: InstallationDrawingNode | null; + selectedSpu: InstallationSpuNode | null; + steps: InstallationStepDraft[]; + parts: InstallationPartItem[]; + numberingParts: InstallationPartItem[]; + tools: InstallationToolItem[]; + snapshotUrls: Record; + snapshotLineArtUrls: Record; + toolSnapshotUrls: Record; + toolSnapshotLineArtUrls: Record; + lineArtGenerating?: boolean; + captures: InstallationDrawingCaptures; + initialScrollTop: number; + onCaptureTarget: ( + target: InstallationDrawingCaptureTarget, + scrollTop: number, + ) => void; + onUpdateImageLayout: ( + target: InstallationDrawingCaptureTarget, + transform: InstallationDrawingImageTransform, + numberingMarkers: InstallationDrawingNumberMarker[], + numberingMarkerCountOverrides: Record, + ) => void; + autoCapture: { + phase: + | "loading" + | "idle" + | "seeding" + | "running" + | "finalizing" + | "paused" + | "error"; + error: string | null; + hasActiveBatch: boolean; + hasDraftBatch: boolean; + isConfigurationChanged: boolean; + completedCount: number; + totalCount: number; + running: boolean; + onStart: () => void; + onPause: () => void; + }; + confirmation: { + confirmedAt: string | null; + confirmedImageMode: InstallationDrawingImageMode | null; + confirming: boolean; + message: string | null; + onConfirm: (imageMode: InstallationDrawingImageMode) => void; + }; + onClose: () => void; +} + +function resolveCapturedImage( + captures: InstallationDrawingCaptures, + target: InstallationDrawingCaptureTarget | null, +): InstallationDrawingCapturedImage | null { + if (!target) return null; + if (target.kind === "finished") return captures.finished; + if (target.kind === "exploded") return captures.exploded; + if (target.kind === "inventory") return null; + return ( + captures.actions.find((capture) => capture.id === target.id)?.image ?? null + ); +} + +function buildOverviewTools( + tools: InstallationToolItem[], + captures: InstallationDrawingCaptures, + toolSnapshotUrls: Record, + toolSnapshotLineArtUrls: Record, +): InstallationDrawingOverviewTool[] { + const capturedTools = captures.actions.flatMap((action) => action.tools); + return Array.from( + new Map([ + ...tools.map( + (tool) => + [ + tool.toolCode, + { + id: tool.id, + code: tool.toolCode, + name: tool.name, + quantity: 1, + imageUrl: toolSnapshotUrls[tool.id], + lineArtImageUrl: toolSnapshotLineArtUrls[tool.id], + }, + ] as const, + ), + ...capturedTools.map( + (tool) => + [ + tool.code, + { + id: `captured:${tool.code}`, + code: tool.code, + name: tool.name, + quantity: 1, + }, + ] as const, + ), + ]).values(), + ); +} + +export function InstallationDrawingSheet({ + drawing, + selectedSpu, + steps, + parts, + numberingParts, + tools, + snapshotUrls, + snapshotLineArtUrls, + toolSnapshotUrls, + toolSnapshotLineArtUrls, + lineArtGenerating = false, + captures, + initialScrollTop, + onCaptureTarget, + onUpdateImageLayout, + autoCapture, + confirmation, + onClose, +}: InstallationDrawingSheetProps) { + const [templateId, setTemplateId] = useState( + DEFAULT_INSTALLATION_DRAWING_TEMPLATE_ID, + ); + const [imageModeOverride, setImageModeOverride] = + useState(null); + const imageMode = resolveInstallationDrawingImageMode( + imageModeOverride, + confirmation.confirmedImageMode, + ); + const [adjustTarget, setAdjustTarget] = + useState(null); + const scrollContainerRef = useRef(null); + const { + markerManagerTarget, + setMarkerManagerTarget, + reconciledCaptures, + markerManagerImage, + markerManagerSources, + totalUnplacedMarkers, + } = useInstallationDrawingMarkerInventory(captures, numberingParts); + const overviewTools = useMemo( + () => + buildOverviewTools( + tools, + captures, + toolSnapshotUrls, + toolSnapshotLineArtUrls, + ), + [captures, toolSnapshotLineArtUrls, toolSnapshotUrls, tools], + ); + const inventoryItems = useMemo( + () => + buildInstallationDrawingInventoryItems({ + parts, + numberingParts, + tools: overviewTools, + snapshotUrls, + snapshotLineArtUrls, + toolSnapshotUrls, + toolSnapshotLineArtUrls, + }), + [ + numberingParts, + overviewTools, + parts, + snapshotLineArtUrls, + snapshotUrls, + toolSnapshotLineArtUrls, + toolSnapshotUrls, + ], + ); + const layout = useMemo( + () => + buildInstallationDrawingLayout({ + templateId, + steps, + inventoryItemCount: inventoryItems.length, + }), + [inventoryItems.length, steps, templateId], + ); + const activeTemplate = + INSTALLATION_DRAWING_TEMPLATES.find( + (template) => template.id === templateId, + ) ?? INSTALLATION_DRAWING_TEMPLATES[0]; + const completedCaptureCount = + Number(isInstallationDrawingImageCaptured(captures.finished)) + + Number(isInstallationDrawingImageCaptured(captures.exploded)) + + captures.actions.filter((action) => + isInstallationDrawingImageCaptured(action.image), + ).length; + const totalCaptureCount = captures.actions.length + 2; + const displayedCaptureCount = Math.min( + totalCaptureCount, + Math.max(completedCaptureCount, autoCapture.completedCount), + ); + const panelCount = layout.pages.reduce( + (count, page) => count + page.panels.length, + 0, + ); + const adjustImage = resolveCapturedImage(reconciledCaptures, adjustTarget); + const adjustInventoryItem = + adjustTarget?.kind === "inventory" + ? inventoryItems.find( + (item) => `${item.kind}:${item.id}` === adjustTarget.id, + ) + : null; + const adjustInventoryImage = + adjustInventoryItem && adjustTarget?.kind === "inventory" + ? { + originalUrl: adjustInventoryItem.originalImageUrl ?? null, + lineArtUrl: adjustInventoryItem.lineArtImageUrl ?? null, + transform: + captures.inventoryTransforms[adjustTarget.id] ?? + DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM, + numberingMarkers: [], + numberingMarkerCountOverrides: {}, + captureAspectRatio: null, + } + : null; + const activeAdjustImage = adjustInventoryImage ?? adjustImage; + const confirmationDisabledReason = autoCapture.running + ? "自动抓取进行中,完成后才能确认" + : !autoCapture.hasActiveBatch + ? "请先完成全部图纸画面" + : autoCapture.hasDraftBatch + ? "还有未完成的抓取批次" + : autoCapture.isConfigurationChanged + ? "安装配置已变化,请重新获取图纸" + : displayedCaptureCount < totalCaptureCount + ? `还有 ${totalCaptureCount - displayedCaptureCount} 张画面未完成` + : lineArtGenerating + ? "部件或工具线稿仍在生成" + : totalUnplacedMarkers > 0 + ? `还有 ${totalUnplacedMarkers} 个编号待放置` + : null; + const commitNumberingMarkers = ( + target: InstallationDrawingCaptureTarget, + numberingMarkers: InstallationDrawingNumberMarker[], + ) => { + const image = resolveCapturedImage(reconciledCaptures, target); + if (!image) return; + onUpdateImageLayout( + target, + image.transform, + numberingMarkers, + image.numberingMarkerCountOverrides, + ); + }; + const captureFromCurrentPosition = ( + target: InstallationDrawingCaptureTarget, + ) => { + onCaptureTarget( + target, + scrollContainerRef.current?.scrollTop ?? initialScrollTop, + ); + }; + + useLayoutEffect(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = Math.max(0, initialScrollTop); + } + }, [initialScrollTop]); + + if (typeof document === "undefined") return null; + + return createPortal( +
0 ? "drawing-print-blocked" : ""}`} + > + +
+
+

安装图纸制作

+

+ {drawing?.code ?? "未选择图纸"} · {layout.pages.length} 张纸 /{" "} + {panelCount} 个半页 · 已截取 {displayedCaptureCount}/ + {totalCaptureCount} + {lineArtGenerating ? " · 正在生成部件线稿" : ""} +

+ {confirmation.message ? ( +

+ {confirmation.message} +

+ ) : null} +
+
+ + +
+ + +
+ + 纸张 {activeTemplate.paper} + + + 方向 横向 + + {totalUnplacedMarkers > 0 ? ( + + 待放置 {totalUnplacedMarkers} + + ) : null} + confirmation.onConfirm(imageMode)} + /> + + +
+
+ +
+
+

图纸尚未完成

+

+ 还有 {totalUnplacedMarkers} 个编号待放置,完成后才能打印或保存 PDF。 +

+
+
+ +
+
+ {layout.pages.map((page) => ( +
+ {page.panels.map((panel, panelIndex) => ( +
0 ? "border-l border-[#202526]/20 pl-4" : "pr-4"}`} + > + {panel.kind === "overview" ? ( + + ) : null} + {panel.kind === "inventory" ? ( + + ) : null} + {panel.kind === "actions" ? ( + + ) : null} +
+ ))} + {page.panels.length === 1 ? ( +
+ ) : null} +
+ ))} +
+
+ {adjustTarget && + activeAdjustImage && + isInstallationDrawingImageAvailable(activeAdjustImage) ? ( + { + onUpdateImageLayout( + adjustTarget, + transform, + numberingMarkers, + activeAdjustImage.numberingMarkerCountOverrides, + ); + setAdjustTarget(null); + }} + onClose={() => setAdjustTarget(null)} + /> + ) : null} + {markerManagerTarget && + markerManagerImage && + markerManagerSources.length > 0 ? ( + { + onUpdateImageLayout( + markerManagerTarget, + markerManagerImage.transform, + numberingMarkers, + numberingMarkerCountOverrides, + ); + setMarkerManagerTarget(null); + }} + onClose={() => setMarkerManagerTarget(null)} + /> + ) : null} +
, + document.body, + ); +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx new file mode 100644 index 00000000..1fbf77f8 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingSheetScroll.test.tsx @@ -0,0 +1,130 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createEmptyInstallationDrawingCapturedImage } from "../lib/installationDrawingCaptureTypes"; +import { InstallationDrawingSheet } from "./InstallationDrawingSheet"; + +jest.mock("../hooks/useInstallationDrawingMarkerInventory", () => ({ + useInstallationDrawingMarkerInventory: (captures: unknown) => ({ + markerManagerTarget: null, + setMarkerManagerTarget: jest.fn(), + reconciledCaptures: captures, + markerManagerImage: null, + markerManagerSources: [], + totalUnplacedMarkers: 0, + }), +})); + +jest.mock("../lib/installationDrawingLayout", () => ({ + buildInstallationDrawingLayout: () => ({ + templateId: "set-1", + paper: "A4", + orientation: "landscape", + pages: [ + { + id: "page-1", + panels: [{ id: "overview", kind: "overview" }], + }, + ], + }), +})); + +jest.mock("./InstallationDrawingOverviewInventory", () => ({ + buildInstallationDrawingInventoryItems: () => [], +})); + +jest.mock("./InstallationDrawingOverviewPage", () => ({ + InstallationDrawingOverviewPage: ({ + onCaptureTarget, + }: { + onCaptureTarget: (target: { kind: "finished" }) => void; + }) => ( + + ), +})); + +jest.mock("./InstallationDrawingActionsPage", () => ({ + InstallationDrawingActionsPage: () => null, +})); +jest.mock("./InstallationDrawingAutoCaptureControl", () => ({ + InstallationDrawingAutoCaptureControl: () => null, +})); +jest.mock("./InstallationDrawingConfirmationControl", () => ({ + InstallationDrawingConfirmationControl: () => null, +})); +jest.mock("./InstallationDrawingInventoryPage", () => ({ + InstallationDrawingInventoryPage: () => null, +})); +jest.mock("./InstallationDrawingImageAdjustDialog", () => ({ + InstallationDrawingImageAdjustDialog: () => null, +})); +jest.mock("./InstallationDrawingMarkerManagerDialog", () => ({ + InstallationDrawingMarkerManagerDialog: () => null, +})); + +function buildProps(initialScrollTop: number, onCaptureTarget: jest.Mock) { + return { + drawing: null, + selectedSpu: null, + steps: [], + parts: [], + numberingParts: [], + tools: [], + snapshotUrls: {}, + snapshotLineArtUrls: {}, + toolSnapshotUrls: {}, + toolSnapshotLineArtUrls: {}, + captures: { + finished: createEmptyInstallationDrawingCapturedImage(), + exploded: createEmptyInstallationDrawingCapturedImage(), + actions: [], + inventoryTransforms: {}, + }, + initialScrollTop, + onCaptureTarget, + onUpdateImageLayout: jest.fn(), + autoCapture: { + phase: "idle" as const, + error: null, + hasActiveBatch: true, + hasDraftBatch: false, + isConfigurationChanged: false, + completedCount: 2, + totalCount: 2, + running: false, + onStart: jest.fn(), + onPause: jest.fn(), + }, + confirmation: { + confirmedAt: null, + confirmedImageMode: null, + confirming: false, + message: null, + onConfirm: jest.fn(), + }, + onClose: jest.fn(), + }; +} + +describe("InstallationDrawingSheet scroll restoration", () => { + it("captures the current template scroll position and restores it on remount", () => { + const onCaptureTarget = jest.fn(); + const firstRender = render( + , + ); + const firstScroller = document.querySelector( + ".installation-drawing-main", + ) as HTMLElement; + firstScroller.scrollTop = 6420; + + fireEvent.click(screen.getByRole("button", { name: "去截取" })); + expect(onCaptureTarget).toHaveBeenCalledWith({ kind: "finished" }, 6420); + + firstRender.unmount(); + render(); + const restoredScroller = document.querySelector( + ".installation-drawing-main", + ) as HTMLElement; + expect(restoredScroller.scrollTop).toBe(6420); + }); +}); diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingToolLineArtGenerator.test.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingToolLineArtGenerator.test.tsx new file mode 100644 index 00000000..02b6fcc9 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingToolLineArtGenerator.test.tsx @@ -0,0 +1,84 @@ +import { act, render } from '@testing-library/react' +import { InstallationDrawingToolLineArtGenerator } from './InstallationDrawingToolLineArtGenerator' + +const capturePngDataUrl = jest.fn((options?: { renderMode?: string }) => + options?.renderMode === 'technical-line' ? 'data:image/png;base64,line-art' : 'data:image/png;base64,original', +) + +jest.mock('@/components/platform/ModelPreviewPanel', () => ({ + ModelPreviewPanel: ({ + onEngineReady, + }: { + onEngineReady?: (payload: { engine: { capturePngDataUrl: typeof capturePngDataUrl } }) => void + }) => { + const { useEffect } = jest.requireActual('react') + useEffect(() => { + onEngineReady?.({ engine: { capturePngDataUrl } }) + }, [onEngineReady]) + return
+ }, +})) + +describe('InstallationDrawingToolLineArtGenerator', () => { + beforeEach(() => { + jest.useFakeTimers() + capturePngDataUrl.mockClear() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('captures the tool from the ready 3D engine', async () => { + const onCapture = jest.fn((_tool, capture) => { + expect(capture()).toBe('data:image/png;base64,original') + expect(capture({ renderMode: 'technical-line' })).toBe('data:image/png;base64,line-art') + return true + }) + + render( + , + ) + + await act(async () => { + jest.advanceTimersByTime(720) + }) + + expect(onCapture).toHaveBeenCalledTimes(1) + expect(capturePngDataUrl).toHaveBeenCalledTimes(2) + }) + + it('retries a temporarily unavailable capture', async () => { + const onCapture = jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) + + render( + , + ) + + await act(async () => { + jest.advanceTimersByTime(1080) + }) + + expect(onCapture).toHaveBeenCalledTimes(2) + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingToolLineArtGenerator.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingToolLineArtGenerator.tsx new file mode 100644 index 00000000..690d0761 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationDrawingToolLineArtGenerator.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import type { ModelPreviewCapture } from '@/components/platform/ModelPreviewPanel' +import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel' +import type { ThreeDEngine } from '@/lib/3d/engine' +import type { InstallationToolItem } from '../types' + +interface Props { + tool: InstallationToolItem | null + onCapture: (tool: InstallationToolItem, capture: ModelPreviewCapture) => boolean +} + +export function InstallationDrawingToolLineArtGenerator({ tool, onCapture }: Props) { + const [readyEngine, setReadyEngine] = useState<{ toolId: string; engine: ThreeDEngine } | null>(null) + const engine = readyEngine && readyEngine.toolId === tool?.id ? readyEngine.engine : null + + const handleEngineReady = useCallback(({ engine: readyEngine }: { engine: ThreeDEngine }) => { + if (!tool) return + setReadyEngine({ toolId: tool.id, engine: readyEngine }) + }, [tool]) + + useEffect(() => { + if (!tool || !engine) return + + let cancelled = false + let attempt = 0 + let timer: number | null = null + const capture: ModelPreviewCapture = (options) => { + try { + return engine.capturePngDataUrl(options) + } catch { + return null + } + } + const captureWhenStable = () => { + if (cancelled) return + attempt += 1 + if (onCapture(tool, capture) || attempt >= 3) return + timer = window.setTimeout(captureWhenStable, 360) + } + + timer = window.setTimeout(captureWhenStable, 720) + return () => { + cancelled = true + if (timer !== null) window.clearTimeout(timer) + } + }, [engine, onCapture, tool]) + + if (!tool) return null + + return ( + + ) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationInspector.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationInspector.tsx index f8278a0a..ce52926a 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationInspector.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationInspector.tsx @@ -106,6 +106,7 @@ interface InstallationInspectorProps { onAddPartToken: (role: InstallationPartRole, token: string) => void onAddStepGroupRef: (role: InstallationPartRole, sourceStepId: string) => void onRemoveStepGroupRef: (groupRefId: string) => void + onGenerateDrawing: () => void stepActionEditor?: ReactNode } @@ -201,6 +202,7 @@ export function InstallationInspector({ onAddPartToken, onAddStepGroupRef, onRemoveStepGroupRef, + onGenerateDrawing, stepActionEditor, }: InstallationInspectorProps) { return ( @@ -324,6 +326,7 @@ export function InstallationInspector({ onAddPartToken={onAddPartToken} onAddStepGroupRef={onAddStepGroupRef} onRemoveStepGroupRef={onRemoveStepGroupRef} + onGenerateDrawing={onGenerateDrawing} stepActionEditor={stepActionEditor} /> ) : null} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingMarkerLayer.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingMarkerLayer.tsx index d6ed660b..41d7a279 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingMarkerLayer.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingMarkerLayer.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react' import type { NumberingMarkerLayout } from '../lib/installationNumberingOverlayLayout' +import type { InstallationDrawingMarkerSnapshot } from '../lib/installationDrawingCaptureTypes' +import { buildInstallationDrawingCaptureMarkers } from '../lib/installationDrawingCaptureMarkers' interface InstallationNumberingMarkerLayerProps { markers: NumberingMarkerLayout[] @@ -9,6 +11,7 @@ interface InstallationNumberingMarkerLayerProps { onSelectPart: (partId: string) => void tone?: 'part' | 'hardware' | 'tool' interactive?: boolean + onMarkerPositionsChange?: (snapshot: InstallationDrawingMarkerSnapshot) => void } interface MarkerLayerSize { @@ -135,6 +138,7 @@ export function InstallationNumberingMarkerLayer({ onSelectPart, tone, interactive = true, + onMarkerPositionsChange, }: InstallationNumberingMarkerLayerProps) { const canvasRef = useRef(null) const [layerSize, setLayerSize] = useState({ width: 0, height: 0 }) @@ -166,10 +170,16 @@ export function InstallationNumberingMarkerLayer({ context.setTransform(dpr, 0, 0, dpr, 0, 0) context.clearRect(0, 0, rect.width, rect.height) - - markers.filter((marker) => shouldRenderMarker(marker, currentLayerSize)).forEach((marker) => { + const visibleMarkers = markers.filter((marker) => + shouldRenderMarker(marker, currentLayerSize), + ) + visibleMarkers.forEach((marker) => { drawMarkerLeader(context, marker, marker.partId === selectedPartId) }) + onMarkerPositionsChange?.({ + markers: buildInstallationDrawingCaptureMarkers(visibleMarkers, rect.width, rect.height), + aspectRatio: rect.width / rect.height, + }) } const scheduleDraw = () => { @@ -198,7 +208,7 @@ export function InstallationNumberingMarkerLayer({ } resizeObserver?.disconnect() } - }, [markers, selectedPartId]) + }, [markers, onMarkerPositionsChange, selectedPartId]) return ( <> @@ -207,7 +217,6 @@ export function InstallationNumberingMarkerLayer({ className="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true" /> - {markers.filter((marker) => shouldRenderMarker(marker, layerSize)).map((marker) => { const selected = marker.partId === selectedPartId const toneClasses = tone ? MARKER_TONE_CLASSES[tone] : null diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingOverlay.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingOverlay.tsx index f6a211cf..0b3a5734 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingOverlay.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingOverlay.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ThreeDEngine } from '@/lib/3d/engine' import type { InstallationPartItem } from '../types' +import type { InstallationDrawingMarkerSnapshot } from '../lib/installationDrawingCaptureTypes' import { getPartNodeNames } from '../lib/installationPartCatalogHelpers' import { buildInstallationNumberingMarkerLayouts, @@ -31,6 +32,7 @@ interface InstallationNumberingOverlayProps { tone?: 'part' | 'hardware' | 'tool' interactive?: boolean followSceneTransforms?: boolean + onMarkerPositionsChange?: (snapshot: InstallationDrawingMarkerSnapshot) => void } export function InstallationNumberingOverlay({ @@ -44,6 +46,7 @@ export function InstallationNumberingOverlay({ tone, interactive = true, followSceneTransforms = false, + onMarkerPositionsChange, }: InstallationNumberingOverlayProps) { const containerRef = useRef(null) const frameRef = useRef(null) @@ -226,6 +229,7 @@ export function InstallationNumberingOverlay({ onSelectPart={onSelectPart} tone={tone} interactive={interactive} + onMarkerPositionsChange={onMarkerPositionsChange} /> ) : null}
diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx index 2943600d..2740480d 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx @@ -43,6 +43,11 @@ import { collectInstallationToolInstanceNodeNames, isInstallationToolInstanceNodeName, } from '../lib/installationToolAttachmentIdentity' +import type { + InstallationDrawingCaptureTarget, + InstallationDrawingMarkerSnapshot, +} from '../lib/installationDrawingCaptureTypes' +import { buildInstallationDrawingCaptureNumberingParts } from '../lib/installationDrawingCaptureNumbering' type PreviewNodeSelectHandler = NonNullable['onNodeSelect']> type PreviewNodeContextMenuHandler = NonNullable['onNodeContextMenu']> @@ -109,6 +114,7 @@ interface InstallationPreviewStageProps { stepMaterialMarkerParts: InstallationPartItem[] stepMaterialCardSize?: 'default' | 'share' boardAttentionParts?: InstallationPartItem[] + drawingCaptureTarget?: InstallationDrawingCaptureTarget | null playbackActionId?: string | null playbackActionNodeNames?: string[] playbackActionProgress?: number @@ -134,6 +140,8 @@ interface InstallationPreviewStageProps { onSelectDrawingTool?: (toolId: string) => void onModelNodesChange: PreviewModelNodesChangeHandler onEngineReady?: PreviewEngineReadyHandler + onCaptureReady?: (capture: ModelPreviewCapture | null) => void + onDrawingCaptureMarkersChange?: (snapshot: InstallationDrawingMarkerSnapshot) => void onNodePositionOffsetChange: (payload: { nodeName: string; offset: [number, number, number] }) => void onNodeRotationChange: (payload: { nodeName: string; rotation: [number, number, number, number] }) => void onNodeLocalTransformChange?: (payload: NodeLocalTransformState) => void @@ -205,6 +213,7 @@ export function InstallationPreviewStage({ stepMaterialThumbnailUrls, stepMaterialCardSize = 'default', boardAttentionParts = [], + drawingCaptureTarget = null, playbackActionId = null, playbackActionNodeNames = [], playbackActionProgress = 0, @@ -230,6 +239,8 @@ export function InstallationPreviewStage({ onSelectDrawingTool, onModelNodesChange, onEngineReady, + onCaptureReady, + onDrawingCaptureMarkersChange, onNodePositionOffsetChange, onNodeRotationChange, onNodeLocalTransformChange, @@ -488,6 +499,21 @@ export function InstallationPreviewStage({ }, [activeStep, boardAttentionParts, playbackActionNodeNames, playbackShowingFixedGroup]) const showBoardAttentionOverlay = !isToolPreview && view === 'preview' && activeBoardAttentionParts.length > 0 + const drawingCaptureNumberingParts = useMemo( + () => buildInstallationDrawingCaptureNumberingParts({ + target: drawingCaptureTarget, + step: activeStep, + parts: drawingCaptureTarget?.kind === 'action' ? boardAttentionParts : numberingParts, + }), + [activeStep, boardAttentionParts, drawingCaptureTarget, numberingParts], + ) + const showDrawingCaptureNumberingOverlay = + !isToolPreview && drawingCaptureNumberingParts.length > 0 + useEffect(() => { + if (!showDrawingCaptureNumberingOverlay) { + onDrawingCaptureMarkersChange?.({ markers: [], aspectRatio: null }) + } + }, [onDrawingCaptureMarkersChange, showDrawingCaptureNumberingOverlay]) const showExplodedNumberingOverlay = !isToolPreview && view === 'exploded' && explodedNumberingParts.length > 0 const showNumberingPresentation = !isToolPreview && view === 'numbering' && numberingParts.length > 0 && (!canEdit || mode === 'preview') @@ -588,7 +614,7 @@ export function InstallationPreviewStage({ emptyBackdrop={} onModelNodesChange={isToolPreview ? () => {} : onModelNodesChange} onEngineReady={isToolPreview ? undefined : onEngineReady} - onCaptureReady={isToolPreview ? handleToolSnapshotCaptureReady : undefined} + onCaptureReady={isToolPreview ? handleToolSnapshotCaptureReady : onCaptureReady} onNodeSelect={isToolPreview ? () => {} : handleStageNodeSelect} onNodeContextMenu={isToolPreview ? undefined : handleToolContextMenu} onNodePositionOffsetChange={isToolPreview ? () => {} : onNodePositionOffsetChange} @@ -645,7 +671,19 @@ export function InstallationPreviewStage({ } overlayCenter={ - showNumberingPresentation ? ( + showDrawingCaptureNumberingOverlay ? ( + {}} + layoutVariant={drawingCaptureTarget?.kind === 'exploded' ? 'numbering' : 'materials'} + tone="part" + interactive={false} + followSceneTransforms={drawingCaptureTarget?.kind === 'action'} + onMarkerPositionsChange={onDrawingCaptureMarkersChange} + /> + ) : showNumberingPresentation ? ( + stageView: InstallationWorkbenchView + activeStepId: string | null + activeActionId: string | null + selectedNodeNames: string[] + onReadyChange: (ready: boolean) => void +} + +const MAX_FRAME_SYNC_ATTEMPTS = 120 +const CAPTURE_OVERLAY_SETTLE_DELAY_MS = 320 + +export function useInstallationDrawingActionCaptureFrame({ + target, + steps, + numberingParts, + modelNodeNames, + engine, + captureRef, + stageView, + activeStepId, + activeActionId, + selectedNodeNames, + onReadyChange, +}: UseInstallationDrawingActionCaptureFrameOptions) { + const frame = useMemo( + () => resolveInstallationDrawingActionCaptureFrame({ + target, + steps, + numberingParts, + modelNodeNames, + }), + [modelNodeNames, numberingParts, steps, target], + ) + + useEffect(() => { + if (!target) return + onReadyChange(false) + if (!engine || !frame || modelNodeNames.length === 0 || stageView !== 'steps') return + if (!isInstallationDrawingActionWorkspaceReady(frame, { + activeStepId, + activeActionId, + selectedNodeNames, + })) return + + let animationFrameId = 0 + let settledFrameId = 0 + let settleTimerId = 0 + let attempts = 0 + let cancelled = false + + const synchronizeFrame = () => { + if (cancelled) return + attempts += 1 + if (!captureRef.current) { + if (attempts < MAX_FRAME_SYNC_ATTEMPTS) { + animationFrameId = window.requestAnimationFrame(synchronizeFrame) + } + return + } + + applyInstallationDrawingActionCaptureFrame(engine, frame, modelNodeNames) + if (!isInstallationDrawingActionCaptureFrameApplied(engine, frame)) { + if (attempts < MAX_FRAME_SYNC_ATTEMPTS) { + animationFrameId = window.requestAnimationFrame(synchronizeFrame) + } + return + } + + applyInstallationDrawingActionCaptureCamera(engine, frame) + if (!isInstallationDrawingActionCaptureCameraApplied(engine, frame)) { + if (attempts < MAX_FRAME_SYNC_ATTEMPTS) { + animationFrameId = window.requestAnimationFrame(synchronizeFrame) + } + return + } + + settledFrameId = window.requestAnimationFrame(() => { + if (cancelled) return + if ( + !isInstallationDrawingActionCaptureFrameApplied(engine, frame) || + !isInstallationDrawingActionCaptureCameraApplied(engine, frame) + ) { + animationFrameId = window.requestAnimationFrame(synchronizeFrame) + return + } + settleTimerId = window.setTimeout(() => { + if (cancelled) return + if ( + !isInstallationDrawingActionCaptureFrameApplied(engine, frame) || + !isInstallationDrawingActionCaptureCameraApplied(engine, frame) + ) { + animationFrameId = window.requestAnimationFrame(synchronizeFrame) + return + } + onReadyChange(true) + }, CAPTURE_OVERLAY_SETTLE_DELAY_MS) + }) + } + + animationFrameId = window.requestAnimationFrame(synchronizeFrame) + + return () => { + cancelled = true + window.cancelAnimationFrame(animationFrameId) + window.cancelAnimationFrame(settledFrameId) + window.clearTimeout(settleTimerId) + engine.cancelCameraAnimation() + onReadyChange(false) + } + }, [ + activeActionId, + activeStepId, + captureRef, + engine, + frame, + modelNodeNames, + onReadyChange, + selectedNodeNames, + stageView, + target, + ]) + + return frame +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingAutoCapture.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingAutoCapture.ts new file mode 100644 index 00000000..dbb28766 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingAutoCapture.ts @@ -0,0 +1,563 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + beginInstallationDrawingCaptureBatch, + confirmInstallationDrawingCapture, + fetchInstallationDrawingCaptureSnapshot, + finalizeInstallationDrawingCaptureBatch, + replaceInstallationDrawingCapture, + updateInstallationDrawingCaptureLayout, + uploadInstallationDrawingCapture, +} from "../lib/installationDrawingCaptureApi"; +import { + buildInstallationDrawingCaptureTargets, + getInstallationDrawingCaptureTargetKey, + getInstallationDrawingTransformTargetKey, + isInstallationDrawingImageCaptured, + type InstallationDrawingCapturedImage, + type InstallationDrawingCaptures, + type InstallationDrawingCaptureBatch, + type InstallationDrawingCaptureSnapshot, + type InstallationDrawingCaptureTarget, + type InstallationDrawingImageTransform, + type InstallationDrawingImageMode, + type InstallationDrawingNumberMarker, +} from "../lib/installationDrawingCaptureTypes"; +import { resolveInstallationDrawingPendingTargets } from "../lib/installationDrawingAutoCapturePlan"; +import { preserveInstallationDrawingCaptureAnnotations } from "../lib/installationDrawingCapturePreservation"; + +type AutoCapturePhase = + | "loading" + | "idle" + | "seeding" + | "running" + | "finalizing" + | "paused" + | "error"; +type CaptureTarget = Exclude< + InstallationDrawingCaptureTarget, + { kind: "inventory" } +>; + +interface CaptureSession { + batchId: string; + targets: CaptureTarget[]; + index: number; +} + +interface UseInstallationDrawingAutoCaptureOptions { + drawingId: string | null; + modelUrl: string | null; + configFingerprint: string | null; + captures: InstallationDrawingCaptures; + captureTarget: InstallationDrawingCaptureTarget | null; + captureReady: boolean; + beginCapture: (target: InstallationDrawingCaptureTarget) => void; + cancelCapture: () => void; + captureCurrentScene: ( + commitToTemplate?: boolean, + ) => InstallationDrawingCapturedImage | null; + applyPersistedBatch: (batch: InstallationDrawingCaptureBatch | null) => void; +} + +function resolveCaptureImage( + captures: InstallationDrawingCaptures, + target: CaptureTarget, +) { + if (target.kind === "finished") return captures.finished; + if (target.kind === "exploded") return captures.exploded; + return ( + captures.actions.find((action) => action.id === target.id)?.image ?? null + ); +} + +function targetMatches( + current: InstallationDrawingCaptureTarget | null, + expected: CaptureTarget, +) { + if (!current || current.kind !== expected.kind) return false; + return ( + current.kind !== "action" || + (expected.kind === "action" && current.id === expected.id) + ); +} + +export function useInstallationDrawingAutoCapture({ + drawingId, + modelUrl, + configFingerprint, + captures, + captureTarget, + captureReady, + beginCapture, + cancelCapture, + captureCurrentScene, + applyPersistedBatch, +}: UseInstallationDrawingAutoCaptureOptions) { + const [snapshot, setSnapshot] = + useState(null); + const [phase, setPhase] = useState("loading"); + const [session, setSession] = useState(null); + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(false); + const startedTargetKeyRef = useRef(null); + const pauseRequestedRef = useRef(false); + const readinessRetryRef = useRef<{ key: string; attempts: number }>({ + key: "", + attempts: 0, + }); + + const targets = useMemo( + () => buildInstallationDrawingCaptureTargets(captures), + [captures], + ); + const targetKeys = useMemo( + () => targets.map(getInstallationDrawingCaptureTargetKey), + [targets], + ); + + useEffect(() => { + let cancelled = false; + setSession(null); + setError(null); + if (!drawingId || !modelUrl) { + setSnapshot(null); + setPhase("idle"); + return; + } + setPhase("loading"); + void fetchInstallationDrawingCaptureSnapshot(drawingId, modelUrl) + .then((loaded) => { + if (cancelled) return; + setSnapshot(loaded); + applyPersistedBatch(loaded?.active ?? null); + setPhase(loaded?.draft ? "paused" : "idle"); + }) + .catch((loadError) => { + if (cancelled) return; + setError( + loadError instanceof Error + ? loadError.message + : "安装图纸截图读取失败", + ); + setPhase("error"); + }); + return () => { + cancelled = true; + }; + }, [applyPersistedBatch, drawingId, modelUrl]); + + const finishBatch = useCallback( + async (batchId: string) => { + if (!drawingId || !modelUrl) return; + setPhase("finalizing"); + try { + const finalized = await finalizeInstallationDrawingCaptureBatch({ + drawingId, + modelUrl, + batchId, + }); + setSnapshot(finalized); + applyPersistedBatch(finalized?.active ?? null); + setSession(null); + setError(null); + setPhase("idle"); + } catch (finalizeError) { + setError( + finalizeError instanceof Error + ? finalizeError.message + : "自动抓取批次固定失败", + ); + setSession(null); + setPhase("error"); + } + }, + [applyPersistedBatch, drawingId, modelUrl], + ); + + const start = useCallback(async () => { + if ( + !drawingId || + !modelUrl || + !configFingerprint || + phase === "loading" || + (snapshot?.active && !snapshot.draft) + ) + return; + pauseRequestedRef.current = false; + setError(null); + setPhase("seeding"); + try { + let workingSnapshot = await beginInstallationDrawingCaptureBatch({ + drawingId, + modelUrl, + configFingerprint, + expectedTargetKeys: targetKeys, + inventoryTransforms: captures.inventoryTransforms, + mode: snapshot?.active ? "replace" : "fill", + }); + const draft = workingSnapshot?.draft; + if (!draft) throw new Error("自动抓取草稿批次创建失败"); + + if (!workingSnapshot?.active) { + for (const target of targets) { + if (pauseRequestedRef.current) { + setSnapshot(workingSnapshot); + setPhase("paused"); + return; + } + const key = getInstallationDrawingCaptureTargetKey(target); + if (workingSnapshot?.draft?.images[key]) continue; + const image = resolveCaptureImage(captures, target); + if (!image || !isInstallationDrawingImageCaptured(image)) continue; + workingSnapshot = await uploadInstallationDrawingCapture({ + drawingId, + modelUrl, + batchId: draft.batchId, + targetKey: key, + image, + }); + setSnapshot(workingSnapshot); + } + } + + const pendingTargets = resolveInstallationDrawingPendingTargets( + targets, + Object.keys(workingSnapshot?.draft?.images ?? {}), + ); + setSnapshot(workingSnapshot); + if (pendingTargets.length === 0) { + await finishBatch(draft.batchId); + return; + } + setSession({ batchId: draft.batchId, targets: pendingTargets, index: 0 }); + setPhase("running"); + } catch (startError) { + setError( + startError instanceof Error ? startError.message : "自动抓取启动失败", + ); + setSession(null); + setPhase("error"); + } + }, [ + captures, + configFingerprint, + drawingId, + finishBatch, + modelUrl, + phase, + snapshot, + targetKeys, + targets, + ]); + + const pause = useCallback(() => { + pauseRequestedRef.current = true; + if (phase === "running") { + cancelCapture(); + setSession(null); + setPhase("paused"); + } + }, [cancelCapture, phase]); + + useEffect(() => { + if (phase !== "running" || !session) return; + const target = session.targets[session.index]; + if (!target) { + void finishBatch(session.batchId); + return; + } + const targetKey = getInstallationDrawingCaptureTargetKey(target); + if (startedTargetKeyRef.current !== targetKey) { + startedTargetKeyRef.current = null; + } + if (readinessRetryRef.current.key !== targetKey) { + readinessRetryRef.current = { key: targetKey, attempts: 0 }; + } + if (!captureTarget && startedTargetKeyRef.current !== targetKey) + beginCapture(target); + if ( + !captureTarget || + captureReady || + startedTargetKeyRef.current === targetKey + ) + return; + + const timerId = window.setTimeout(() => { + if (readinessRetryRef.current.attempts >= 3) { + setError( + `画面 ${target.kind === "action" ? target.sequence : target.kind}(${targetKey})长时间未准备完成,请检查该步骤后重试`, + ); + cancelCapture(); + setSession(null); + setPhase("error"); + return; + } + readinessRetryRef.current.attempts += 1; + cancelCapture(); + beginCapture(target); + }, 12000); + return () => window.clearTimeout(timerId); + }, [ + beginCapture, + cancelCapture, + captureReady, + captureTarget, + finishBatch, + phase, + session, + ]); + + useEffect(() => { + if ( + phase !== "running" || + !session || + !drawingId || + !modelUrl || + !captureReady + ) + return; + const target = session.targets[session.index]; + if (!target || !targetMatches(captureTarget, target)) return; + const targetKey = getInstallationDrawingCaptureTargetKey(target); + if (startedTargetKeyRef.current === targetKey) return; + startedTargetKeyRef.current = targetKey; + const existingImage = resolveCaptureImage(captures, target); + const capturedImage = captureCurrentScene(false); + if (!capturedImage) { + setError("当前安装动作截图失败,请继续未完成项"); + setSession(null); + setPhase("error"); + return; + } + const image = preserveInstallationDrawingCaptureAnnotations( + capturedImage, + existingImage, + ); + void uploadInstallationDrawingCapture({ + drawingId, + modelUrl, + batchId: session.batchId, + targetKey: getInstallationDrawingCaptureTargetKey(target), + image, + }) + .then((nextSnapshot) => { + setSnapshot(nextSnapshot); + if (pauseRequestedRef.current) { + setSession(null); + setPhase("paused"); + return; + } + const nextIndex = session.index + 1; + if (nextIndex >= session.targets.length) { + setSession(null); + void finishBatch(session.batchId); + return; + } + setSession({ ...session, index: nextIndex }); + }) + .catch((uploadError) => { + setError( + uploadError instanceof Error + ? uploadError.message + : "当前画面保存失败", + ); + setSession(null); + setPhase("error"); + }) + .finally(() => {}); + }, [ + captureCurrentScene, + captureReady, + captureTarget, + captures, + drawingId, + finishBatch, + modelUrl, + phase, + session, + ]); + + const persistImageLayout = useCallback( + async ( + target: InstallationDrawingCaptureTarget, + transform: InstallationDrawingImageTransform, + numberingMarkers: InstallationDrawingNumberMarker[], + numberingMarkerCountOverrides: Record, + ) => { + if (!drawingId || !modelUrl || !snapshot?.active) return; + try { + const next = await updateInstallationDrawingCaptureLayout({ + drawingId, + modelUrl, + targetKey: getInstallationDrawingTransformTargetKey(target), + transform, + numberingMarkers, + numberingMarkerCountOverrides, + }); + setSnapshot(next); + setError(null); + } catch (saveError) { + setError( + saveError instanceof Error ? saveError.message : "画面位置保存失败", + ); + } + }, + [drawingId, modelUrl, snapshot?.active], + ); + + const replaceCapture = useCallback( + async (target: CaptureTarget, image: InstallationDrawingCapturedImage) => { + if ( + !drawingId || + !modelUrl || + !configFingerprint || + !snapshot?.active || + snapshot.draft + ) { + setError("当前截图批次尚未准备好,不能保存单张截图"); + return false; + } + try { + const next = await replaceInstallationDrawingCapture({ + drawingId, + modelUrl, + configFingerprint, + expectedTargetKeys: targetKeys, + targetKey: getInstallationDrawingCaptureTargetKey(target), + image, + }); + setSnapshot(next); + applyPersistedBatch(next?.active ?? null); + setError(null); + return true; + } catch (saveError) { + setError( + saveError instanceof Error + ? saveError.message + : "当前画面重新截取保存失败", + ); + return false; + } + }, + [ + applyPersistedBatch, + configFingerprint, + drawingId, + modelUrl, + snapshot?.active, + snapshot?.draft, + targetKeys, + ], + ); + + const captureManualTarget = useCallback(async () => { + if (!captureTarget || captureTarget.kind === "inventory") return false; + const persistToActiveBatch = Boolean(snapshot?.active); + const existingImage = resolveCaptureImage(captures, captureTarget); + const capturedImage = captureCurrentScene(!persistToActiveBatch); + if (!capturedImage) return false; + const image = preserveInstallationDrawingCaptureAnnotations( + capturedImage, + existingImage, + ); + if (persistToActiveBatch) await replaceCapture(captureTarget, image); + return true; + }, [ + captureCurrentScene, + captureTarget, + captures, + replaceCapture, + snapshot?.active, + ]); + + const confirmDrawing = useCallback( + async (imageMode: InstallationDrawingImageMode) => { + if ( + !drawingId || + !modelUrl || + !configFingerprint || + !snapshot?.active || + snapshot.draft + ) { + setError("当前图纸尚未完整固定,不能确认样式"); + return null; + } + setConfirming(true); + setError(null); + try { + const next = await confirmInstallationDrawingCapture({ + drawingId, + modelUrl, + configFingerprint, + expectedTargetKeys: targetKeys, + imageMode, + }); + setSnapshot(next); + return next?.active?.confirmedAt ?? null; + } catch (confirmError) { + setError( + confirmError instanceof Error + ? confirmError.message + : "图纸样式确认失败", + ); + return null; + } finally { + setConfirming(false); + } + }, + [ + configFingerprint, + drawingId, + modelUrl, + snapshot?.active, + snapshot?.draft, + targetKeys, + ], + ); + + const activeBatch = snapshot?.active ?? null; + const draftBatch = snapshot?.draft ?? null; + const progressBatch = draftBatch ?? activeBatch; + const isConfigurationChanged = Boolean( + activeBatch && + configFingerprint && + activeBatch.configFingerprint !== configFingerprint, + ); + const completedCount = progressBatch + ? targetKeys.filter((key) => + Boolean( + progressBatch.images[key]?.originalUrl && + progressBatch.images[key]?.lineArtUrl, + ), + ).length + : 0; + + return { + phase, + error, + hasActiveBatch: Boolean(activeBatch), + hasDraftBatch: Boolean(draftBatch), + isConfigurationChanged, + confirmedAt: + activeBatch && !draftBatch && !isConfigurationChanged + ? activeBatch.confirmedAt + : null, + confirmedImageMode: + activeBatch && !draftBatch && !isConfigurationChanged + ? activeBatch.confirmedImageMode + : null, + confirming, + completedCount, + totalCount: targets.length, + running: + phase === "seeding" || phase === "running" || phase === "finalizing", + start, + pause, + persistImageLayout, + replaceCapture, + captureManualTarget, + confirmDrawing, + }; +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingCapture.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingCapture.ts new file mode 100644 index 00000000..8999bdb9 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingCapture.ts @@ -0,0 +1,431 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react' +import type { ModelPreviewCapture } from '@/components/platform/ModelPreviewPanel' +import type { + InstallationDetailView, + InstallationInspectorTab, + InstallationStepDraft, + InstallationWorkbenchMode, +} from '../types' +import { buildInstallationStepMaterialItems } from '../lib/installationStepMaterials' +import { collectInstallationToolAttachmentIdentities } from '../lib/installationToolAttachmentIdentity' +import { preserveInstallationDrawingCaptureAnnotations } from '../lib/installationDrawingCapturePreservation' +import type { + InstallationDrawingActionCapture, + InstallationDrawingCaptures, + InstallationDrawingCaptureTarget, + InstallationDrawingMarkerSnapshot, +} from '../lib/installationDrawingCaptureTypes' +import { + createEmptyInstallationDrawingCapturedImage, + DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM, + getInstallationDrawingCaptureTargetKey, + isInstallationDrawingOverviewCaptureTarget, + isInstallationDrawingImageCaptured, + type InstallationDrawingCaptureBatch, + type InstallationDrawingImageTransform, + type InstallationDrawingNumberMarker, +} from '../lib/installationDrawingCaptureTypes' + +interface UseInstallationDrawingCaptureOptions { + drawingId: string | null + steps: InstallationStepDraft[] + parts: import('../types').InstallationPartItem[] + numberingParts: import('../types').InstallationPartItem[] + tools: import('../types').InstallationToolItem[] + captureRef: MutableRefObject + numberingMarkersRef: MutableRefObject + setActiveTab: (tab: InstallationInspectorTab) => void + setDetailView: (view: InstallationDetailView) => void + setMode: (mode: InstallationWorkbenchMode) => void + setActiveStepId: (stepId: string) => void +} + +export const INSTALLATION_DRAWING_CAPTURE_OPTIONS = { + clearColor: 0xffffff, + clearAlpha: 1, + sceneGridVisible: false, + sceneBackground: 0xffffff, + captureProfile: 'studio-product' as const, + hideHelpers: true, + cropTransparentBounds: false, +} + +export const INSTALLATION_DRAWING_LINE_CAPTURE_OPTIONS = { + ...INSTALLATION_DRAWING_CAPTURE_OPTIONS, + renderMode: 'technical-line' as const, +} + +function buildActionCapture( + step: InstallationStepDraft, + action: NonNullable[number], + steps: InstallationStepDraft[], + parts: import('../types').InstallationPartItem[], + numberingParts: import('../types').InstallationPartItem[], + tools: import('../types').InstallationToolItem[], + sequence: number, +): InstallationDrawingActionCapture { + const materialItems = buildInstallationStepMaterialItems({ + step, + steps, + stepParts: parts, + numberingParts, + }) + const actionNodeNames = new Set(action.nodeNames) + const materials = materialItems + .filter((item) => item.nodeNames.some((nodeName) => actionNodeNames.has(nodeName))) + .map((item) => ({ + code: item.code, + name: item.name, + kind: item.kind === 'hardware' ? 'hardware' as const : 'part' as const, + quantity: item.quantity, + snapshotPartId: item.snapshotPartId, + })) + const toolIdentities = collectInstallationToolAttachmentIdentities( + numberingParts, + action.nodeNames, + Object.keys(action.toolTransforms ?? {}), + { + availableToolIds: tools.length > 0 ? tools.map((tool) => tool.id) : undefined, + fallbackToolId: tools.length === 1 ? tools[0]?.id : undefined, + }, + ) + const actionTools = Array.from(new Set(toolIdentities.map((identity) => identity.toolId))).map((toolId) => { + const tool = tools.find((item) => item.id === toolId) + return { + code: tool?.toolCode ?? toolId, + name: tool?.name ?? '工具(主数据未同步)', + } + }) + + return { + id: `${step.id}:${action.id}`, + stepId: step.id, + frameKind: 'action', + actionId: action.id, + sequence, + image: createEmptyInstallationDrawingCapturedImage(), + materials: Array.from(new Map(materials.map((item) => [`${item.kind}:${item.code}:${item.name}`, item])).values()), + tools: Array.from(new Map(actionTools.map((tool) => [tool.code, tool])).values()), + } +} + +function buildFixedCapture( + step: InstallationStepDraft, + steps: InstallationStepDraft[], + parts: import('../types').InstallationPartItem[], + numberingParts: import('../types').InstallationPartItem[], + sequence: number, +): InstallationDrawingActionCapture { + const fixedNodeNames = new Set(step.fixedNodeNames) + const materials = buildInstallationStepMaterialItems({ + step, + steps, + stepParts: parts, + numberingParts, + }) + .filter((item) => item.nodeNames.some((nodeName) => fixedNodeNames.has(nodeName))) + .map((item) => ({ + code: item.code, + name: item.name, + kind: item.kind === 'hardware' ? 'hardware' as const : 'part' as const, + quantity: item.quantity, + snapshotPartId: item.snapshotPartId, + })) + + return { + id: `${step.id}:fixed`, + stepId: step.id, + frameKind: 'fixed', + actionId: null, + sequence, + image: createEmptyInstallationDrawingCapturedImage(), + materials: Array.from( + new Map(materials.map((item) => [`${item.kind}:${item.code}:${item.name}`, item])).values(), + ), + tools: [], + } +} + +function buildTemplateCaptures( + steps: InstallationStepDraft[], + parts: import('../types').InstallationPartItem[], + numberingParts: import('../types').InstallationPartItem[], + tools: import('../types').InstallationToolItem[], +): InstallationDrawingCaptures { + let sequence = 0 + return { + finished: createEmptyInstallationDrawingCapturedImage(), + exploded: createEmptyInstallationDrawingCapturedImage(), + actions: steps.flatMap((step) => [ + buildFixedCapture(step, steps, parts, numberingParts, ++sequence), + ...(step.actions ?? []).map((action) => + buildActionCapture(step, action, steps, parts, numberingParts, tools, ++sequence), + ), + ]), + inventoryTransforms: {}, + } +} + +function findActionTarget(captures: InstallationDrawingCaptures, target: InstallationDrawingCaptureTarget) { + return target.kind === 'action' + ? captures.actions.findIndex((action) => action.id === target.id) + : -1 +} + +function resolveCaptureImage( + captures: InstallationDrawingCaptures, + target: InstallationDrawingCaptureTarget, +) { + if (target.kind === 'finished') return captures.finished + if (target.kind === 'exploded') return captures.exploded + if (target.kind === 'inventory') return null + return captures.actions.find((action) => action.id === target.id)?.image ?? null +} + +function mergeCaptureTemplate( + template: InstallationDrawingCaptures, + current: InstallationDrawingCaptures, +): InstallationDrawingCaptures { + const currentActions = new Map(current.actions.map((action) => [action.id, action.image])) + return { + finished: current.finished, + exploded: current.exploded, + actions: template.actions.map((action) => ({ + ...action, + image: currentActions.get(action.id) ?? action.image, + })), + inventoryTransforms: current.inventoryTransforms, + } +} + +export function useInstallationDrawingCapture({ + drawingId, + steps, + parts, + numberingParts, + tools, + captureRef, + numberingMarkersRef, + setActiveTab, + setDetailView, + setMode, + setActiveStepId, +}: UseInstallationDrawingCaptureOptions) { + const template = useMemo( + () => buildTemplateCaptures(steps, parts, numberingParts, tools), + [numberingParts, parts, steps, tools], + ) + const [captures, setCaptures] = useState(template) + const [captureTarget, setCaptureTarget] = useState(null) + const [capturing, setCapturing] = useState(false) + const [captureReady, setCaptureReady] = useState(false) + const [error, setError] = useState(null) + const previousDrawingIdRef = useRef(null) + + useEffect(() => { + setCaptures((current) => + previousDrawingIdRef.current === drawingId + ? mergeCaptureTemplate(template, current) + : template, + ) + previousDrawingIdRef.current = drawingId + setCaptureTarget(null) + setCaptureReady(false) + setError(null) + }, [drawingId, template]) + + const openTemplate = useCallback(() => { + setCaptures((current) => current.actions.length === template.actions.length ? current : template) + setCaptureTarget(null) + setError(null) + return captures + }, [captures, template]) + + const beginCapture = useCallback((target: InstallationDrawingCaptureTarget) => { + if (target.kind === 'inventory') return + setError(null) + setCaptureTarget(target) + setCaptureReady(false) + setActiveTab('steps') + if (isInstallationDrawingOverviewCaptureTarget(target)) { + setMode('edit') + } else { + setMode('edit') + } + if (target.kind === 'finished') { + setDetailView('finished') + } else if (target.kind === 'exploded') { + setDetailView('exploded') + } else { + setDetailView('steps') + setActiveStepId(target.stepId) + } + }, [setActiveStepId, setActiveTab, setDetailView, setMode]) + + const cancelCapture = useCallback(() => { + setCaptureTarget(null) + setCapturing(false) + setCaptureReady(false) + setError(null) + }, []) + + const captureCurrentScene = useCallback((commitToTemplate = true) => { + if (!captureTarget || capturing) return null + if (!captureReady) { + setError('完整模型画面仍在准备中,请稍后再截取。') + return null + } + const capture = captureRef.current + if (!capture) { + setError('3D 模型画面尚未准备好,请稍后再截取。') + return null + } + setCapturing(true) + setError(null) + try { + const originalUrl = capture(INSTALLATION_DRAWING_CAPTURE_OPTIONS) + if (!originalUrl) throw new Error('当前 3D 画面截取失败,请确认模型已完整加载。') + const lineArtUrl = capture({ + ...INSTALLATION_DRAWING_LINE_CAPTURE_OPTIONS, + technicalLineStyle: captureTarget.kind === 'finished' ? 'clean' : 'detail', + }) + if (!lineArtUrl) throw new Error('当前模型无法生成打印线稿,请确认模型几何已完整加载。') + const capturedImage = { + originalUrl, + lineArtUrl, + transform: { ...DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM }, + numberingMarkers: numberingMarkersRef.current.markers.map((marker) => ({ ...marker })), + numberingMarkerCountOverrides: {}, + captureAspectRatio: numberingMarkersRef.current.aspectRatio, + } + const image = preserveInstallationDrawingCaptureAnnotations( + capturedImage, + resolveCaptureImage(captures, captureTarget), + ) + if (commitToTemplate) { + setCaptures((current) => { + if (captureTarget.kind === 'finished') return { ...current, finished: image } + if (captureTarget.kind === 'exploded') return { ...current, exploded: image } + const actionIndex = findActionTarget(current, captureTarget) + if (actionIndex < 0) return current + const actions = [...current.actions] + actions[actionIndex] = { ...actions[actionIndex], image } + return { ...current, actions } + }) + } + setCaptureTarget(null) + return image + } catch (captureError) { + setError(captureError instanceof Error ? captureError.message : '安装图纸截图失败。') + return null + } finally { + setCapturing(false) + } + }, [captureReady, captureRef, captureTarget, captures, capturing, numberingMarkersRef]) + + const applyPersistedBatch = useCallback((batch: InstallationDrawingCaptureBatch | null) => { + if (!batch) { + setCaptures(template) + return + } + setCaptures({ + finished: batch.images.finished ?? createEmptyInstallationDrawingCapturedImage(), + exploded: batch.images.exploded ?? createEmptyInstallationDrawingCapturedImage(), + actions: template.actions.map((action) => { + const target = { + kind: 'action' as const, + id: action.id, + stepId: action.stepId, + frameKind: action.frameKind, + actionId: action.actionId, + sequence: action.sequence, + } + return { + ...action, + image: batch.images[getInstallationDrawingCaptureTargetKey(target)] + ?? createEmptyInstallationDrawingCapturedImage(), + } + }), + inventoryTransforms: batch.inventoryTransforms, + }) + }, [template]) + + const setCaptureFrameReady = useCallback((ready: boolean) => { + setCaptureReady(ready) + if (ready) setError(null) + }, []) + + const updateImageLayout = useCallback(( + target: InstallationDrawingCaptureTarget, + transform: InstallationDrawingImageTransform, + numberingMarkers: InstallationDrawingNumberMarker[], + numberingMarkerCountOverrides: Record, + ) => { + setCaptures((current) => { + if (target.kind === 'finished') { + return { + ...current, + finished: { + ...current.finished, + transform, + numberingMarkers, + numberingMarkerCountOverrides, + }, + } + } + if (target.kind === 'exploded') { + return { + ...current, + exploded: { + ...current.exploded, + transform, + numberingMarkers, + numberingMarkerCountOverrides, + }, + } + } + if (target.kind === 'inventory') { + return { + ...current, + inventoryTransforms: { + ...current.inventoryTransforms, + [target.id]: transform, + }, + } + } + const actionIndex = findActionTarget(current, target) + if (actionIndex < 0) return current + const actions = [...current.actions] + actions[actionIndex] = { + ...actions[actionIndex], + image: { + ...actions[actionIndex].image, + transform, + numberingMarkers, + numberingMarkerCountOverrides, + }, + } + return { ...current, actions } + }) + }, []) + + return { + captures, + captureTarget, + capturing, + captureReady, + error, + hasPendingCaptures: !isInstallationDrawingImageCaptured(captures.finished) + || !isInstallationDrawingImageCaptured(captures.exploded) + || captures.actions.some((action) => !isInstallationDrawingImageCaptured(action.image)), + openTemplate, + beginCapture, + captureCurrentScene, + cancelCapture, + setCaptureFrameReady, + updateImageLayout, + applyPersistedBatch, + } +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingConfirmation.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingConfirmation.ts new file mode 100644 index 00000000..eacc4610 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingConfirmation.ts @@ -0,0 +1,79 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { + CreateInstallationDemoShareInput, + InstallationDemoQrShareDTO, +} from "@/lib/installation-demo-share-types"; +import { syncInstallationDemoQrShare } from "@/lib/open-product/platform-api"; +import type { InstallationNumberingSnapshotBatch } from "../lib/installationNumberingSnapshotStorage"; +import type { InstallationDrawingImageMode } from "../lib/installationDrawingCaptureTypes"; + +interface UseInstallationDrawingConfirmationOptions { + prepareNumberingSnapshotBatch: () => Promise; + confirmCaptureBatch: ( + imageMode: InstallationDrawingImageMode, + ) => Promise; + buildShareInput: ( + batch: InstallationNumberingSnapshotBatch, + imageMode: InstallationDrawingImageMode, + ) => CreateInstallationDemoShareInput | null; + onQrShareSynced: (share: InstallationDemoQrShareDTO) => void; +} + +export function useInstallationDrawingConfirmation({ + prepareNumberingSnapshotBatch, + confirmCaptureBatch, + buildShareInput, + onQrShareSynced, +}: UseInstallationDrawingConfirmationOptions) { + const [confirming, setConfirming] = useState(false); + const [message, setMessage] = useState(null); + + const confirm = useCallback( + async (imageMode: InstallationDrawingImageMode) => { + if (confirming) return false; + setConfirming(true); + setMessage(null); + let captureConfirmed = false; + try { + const numberingSnapshotBatch = await prepareNumberingSnapshotBatch(); + if (!numberingSnapshotBatch) { + throw new Error("部件或工具图未准备完整,请稍后再确认"); + } + const confirmedAt = await confirmCaptureBatch(imageMode); + if (!confirmedAt) throw new Error("图纸确认没有完成,请检查当前提示"); + captureConfirmed = true; + + const shareInput = buildShareInput(numberingSnapshotBatch, imageMode); + if (!shareInput?.drawingDocument) { + throw new Error("已确认图纸未能组装为分享内容"); + } + const share = await syncInstallationDemoQrShare(shareInput); + onQrShareSynced(share); + setMessage("图纸样式已确认,永久二维码已同步;临时链接请重新生成"); + return true; + } catch (error) { + const detail = + error instanceof Error ? error.message : "图纸样式确认失败"; + setMessage( + captureConfirmed + ? `图纸已确认,但永久二维码同步失败:${detail}` + : detail, + ); + return false; + } finally { + setConfirming(false); + } + }, + [ + buildShareInput, + confirmCaptureBatch, + confirming, + onQrShareSynced, + prepareNumberingSnapshotBatch, + ], + ); + + return { confirming, message, confirm }; +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingMarkerInventory.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingMarkerInventory.ts new file mode 100644 index 00000000..47040e4f --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingMarkerInventory.ts @@ -0,0 +1,101 @@ +'use client' + +import { useMemo, useState } from 'react' +import type { InstallationPartItem } from '../types' +import type { + InstallationDrawingCapturedImage, + InstallationDrawingCaptures, + InstallationDrawingCaptureTarget, + InstallationDrawingMarkerSource, +} from '../lib/installationDrawingCaptureTypes' +import { isInstallationDrawingImageCaptured } from '../lib/installationDrawingCaptureTypes' +import { + buildInstallationDrawingActionMarkerSources, + buildInstallationDrawingExplodedMarkerSources, + countInstallationDrawingUnplacedMarkers, + reconcileInstallationDrawingNumberMarkers, +} from '../lib/installationDrawingMarkerInventory' + +function resolveCapturedImage( + captures: InstallationDrawingCaptures, + target: InstallationDrawingCaptureTarget | null, +): InstallationDrawingCapturedImage | null { + if (!target) return null + if (target.kind === 'finished') return captures.finished + if (target.kind === 'exploded') return captures.exploded + if (target.kind === 'inventory') return null + return captures.actions.find((capture) => capture.id === target.id)?.image ?? null +} + +function reconcileCapturedImage( + image: InstallationDrawingCapturedImage, + sources: InstallationDrawingMarkerSource[], +) { + if (!isInstallationDrawingImageCaptured(image)) return image + return { + ...image, + numberingMarkers: reconcileInstallationDrawingNumberMarkers({ + markers: image.numberingMarkers, + sources, + countOverrides: image.numberingMarkerCountOverrides, + }), + } +} + +function resolveMarkerSources( + captures: InstallationDrawingCaptures, + explodedSources: InstallationDrawingMarkerSource[], + target: InstallationDrawingCaptureTarget | null, +) { + if (!target || target.kind === 'finished' || target.kind === 'inventory') return [] + if (target.kind === 'exploded') return explodedSources + const capture = captures.actions.find((item) => item.id === target.id) + return capture ? buildInstallationDrawingActionMarkerSources(capture) : [] +} + +export function useInstallationDrawingMarkerInventory( + captures: InstallationDrawingCaptures, + numberingParts: InstallationPartItem[], +) { + const [markerManagerTarget, setMarkerManagerTarget] = + useState(null) + const explodedMarkerSources = useMemo( + () => buildInstallationDrawingExplodedMarkerSources(numberingParts), + [numberingParts], + ) + const reconciledCaptures = useMemo(() => ({ + ...captures, + exploded: reconcileCapturedImage(captures.exploded, explodedMarkerSources), + actions: captures.actions.map((capture) => ({ + ...capture, + image: reconcileCapturedImage( + capture.image, + buildInstallationDrawingActionMarkerSources(capture), + ), + })), + }), [captures, explodedMarkerSources]) + const markerManagerImage = resolveCapturedImage( + reconciledCaptures, + markerManagerTarget, + ) + const markerManagerSources = resolveMarkerSources( + reconciledCaptures, + explodedMarkerSources, + markerManagerTarget, + ) + const totalUnplacedMarkers = + countInstallationDrawingUnplacedMarkers(reconciledCaptures.exploded.numberingMarkers) + + reconciledCaptures.actions.reduce( + (count, capture) => count + countInstallationDrawingUnplacedMarkers(capture.image.numberingMarkers), + 0, + ) + + return { + markerManagerTarget, + setMarkerManagerTarget, + reconciledCaptures, + markerManagerImage, + markerManagerSources, + totalUnplacedMarkers, + } +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingOverviewCaptureFrame.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingOverviewCaptureFrame.ts new file mode 100644 index 00000000..1f956fd7 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingOverviewCaptureFrame.ts @@ -0,0 +1,79 @@ +'use client' + +import { useEffect, useMemo } from 'react' +import type { ThreeDEngine } from '@/lib/3d/engine' +import { + isInstallationDrawingOverviewCaptureTarget, + type InstallationDrawingCaptureTarget, +} from '../lib/installationDrawingCaptureTypes' +import type { InstallationWorkbenchView } from '../types' + +interface UseInstallationDrawingOverviewCaptureFrameOptions { + target: InstallationDrawingCaptureTarget | null + engine: ThreeDEngine | null + modelNodeNames: string[] + stageView: InstallationWorkbenchView + onReadyChange: (ready: boolean) => void +} + +const OVERVIEW_CAMERA_PADDING = { + finished: 1.16, + exploded: 1.12, +} as const + +export function useInstallationDrawingOverviewCaptureFrame({ + target, + engine, + modelNodeNames, + stageView, + onReadyChange, +}: UseInstallationDrawingOverviewCaptureFrameOptions) { + const overviewKind = isInstallationDrawingOverviewCaptureTarget(target) + ? target.kind + : null + const modelNodeSignature = useMemo(() => modelNodeNames.join('\u001f'), [modelNodeNames]) + + useEffect(() => { + if (!overviewKind) return + + onReadyChange(false) + if (!engine || modelNodeNames.length === 0 || stageView !== overviewKind) return + + let firstFrameId = 0 + let secondFrameId = 0 + let fitTimerId = 0 + let readyTimerId = 0 + + firstFrameId = window.requestAnimationFrame(() => { + secondFrameId = window.requestAnimationFrame(() => { + fitTimerId = window.setTimeout(() => { + engine.cancelCameraAnimation() + const framed = engine.fitToNodeNames( + modelNodeNames, + OVERVIEW_CAMERA_PADDING[overviewKind], + ) + if (!framed) { + engine.fitToView(OVERVIEW_CAMERA_PADDING[overviewKind]) + } + readyTimerId = window.setTimeout(() => onReadyChange(true), 560) + }, 120) + }) + }) + + return () => { + window.cancelAnimationFrame(firstFrameId) + window.cancelAnimationFrame(secondFrameId) + window.clearTimeout(fitTimerId) + window.clearTimeout(readyTimerId) + engine.cancelCameraAnimation() + onReadyChange(false) + } + }, [ + engine, + modelNodeNames, + modelNodeSignature, + onReadyChange, + overviewKind, + stageView, + ]) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingTools.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingTools.ts index e6e8496e..ddfeecfb 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingTools.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationDrawingTools.ts @@ -6,6 +6,7 @@ import { fetchInstallationDemoConfig, saveInstallationDemoConfig, } from '../lib/installationDemoConfigStorage' +import { reconcileInstallationDrawingToolIds } from '../lib/installationDrawingToolSelection' import { useInstallationToolbox } from './useInstallationToolbox' function readToolIds(value: unknown): string[] { @@ -42,10 +43,14 @@ export function useInstallationDrawingTools(drawingId: string | null, canEdit: b } }, [drawingId]) - const selectedToolIds = useMemo( + const storedSelectedToolIds = useMemo( () => (drawingId ? toolIdsByDrawingId[drawingId] ?? [] : []), [drawingId, toolIdsByDrawingId], ) + const selectedToolIds = useMemo( + () => reconcileInstallationDrawingToolIds(storedSelectedToolIds, toolbox.tools), + [storedSelectedToolIds, toolbox.tools], + ) const selectedTools = useMemo(() => { const selectedIdSet = new Set(selectedToolIds) return toolbox.tools.filter((tool) => selectedIdSet.has(tool.id)) @@ -53,25 +58,27 @@ export function useInstallationDrawingTools(drawingId: string | null, canEdit: b useEffect(() => { if (!canEdit || !drawingId || !loadedDrawingIdsRef.current.has(drawingId)) return - const toolIds = toolIdsByDrawingId[drawingId] ?? [] - const serialized = JSON.stringify(toolIds) + const serialized = JSON.stringify(selectedToolIds) if (lastSavedToolIdsRef.current[drawingId] === serialized) return lastSavedToolIdsRef.current[drawingId] = serialized - saveInstallationDemoConfig(drawingId, { toolIds }) - }, [canEdit, drawingId, toolIdsByDrawingId]) + saveInstallationDemoConfig(drawingId, { toolIds: selectedToolIds }) + }, [canEdit, drawingId, selectedToolIds]) const toggleToolId = useCallback( (toolId: string) => { if (!canEdit || !drawingId) return setToolIdsByDrawingId((current) => { - const currentToolIds = current[drawingId] ?? [] + const currentToolIds = reconcileInstallationDrawingToolIds( + current[drawingId] ?? [], + toolbox.tools, + ) const nextToolIds = currentToolIds.includes(toolId) ? currentToolIds.filter((item) => item !== toolId) : [...currentToolIds, toolId] return { ...current, [drawingId]: nextToolIds } }) }, - [canEdit, drawingId], + [canEdit, drawingId, toolbox.tools], ) const getToolById = useCallback( diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationStepActionWorkspace.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationStepActionWorkspace.ts index 8351c006..b2966832 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationStepActionWorkspace.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationStepActionWorkspace.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ThreeDEngine } from '@/lib/3d/engine' +import type { InstallationDrawingActionCaptureTarget } from '../lib/installationDrawingCaptureTypes' import type { InstallationPartItem, InstallationStepDraft, @@ -46,6 +47,7 @@ interface UseInstallationStepActionWorkspaceOptions { modelNodeNames: string[] engine: ThreeDEngine | null canEdit: boolean + captureTarget: InstallationDrawingActionCaptureTarget | null updateStepWith: ( stepId: string, updater: (step: InstallationStepDraft) => InstallationStepDraft, @@ -64,6 +66,7 @@ export function useInstallationStepActionWorkspace({ modelNodeNames, engine, canEdit, + captureTarget, updateStepWith, }: UseInstallationStepActionWorkspaceOptions) { const [mode, setModeState] = useState('scope') @@ -147,12 +150,31 @@ export function useInstallationStepActionWorkspace({ scopeNodeNamesRef.current.every((nodeName) => Boolean(step.expandedTransforms?.[nodeName]?.position), ) + const captureStepMatches = captureTarget?.stepId === step?.id + const captureAction = + captureStepMatches && captureTarget?.frameKind === 'action' && captureTarget.actionId + ? findInstallationStepAction(step, captureTarget.actionId) + : null + + if (captureStepMatches) { + setModeState('edit') + setActiveActionId(captureAction?.id ?? null) + setEditTarget('action') + setSelectedNodeNames(captureAction?.nodeNames ?? []) + return + } + setActiveActionId(null) setEditTarget(hasExpandedState ? 'action' : 'posture') setSelectedNodeNames(hasExpandedState ? [] : scopeNodeNamesRef.current) }, 0) return () => window.clearTimeout(timer) - }, [activeStep?.id]) + }, [ + activeStep?.id, + captureTarget?.actionId, + captureTarget?.frameKind, + captureTarget?.stepId, + ]) const commands = useInstallationStepActionCommands({ activeStep, diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.test.ts new file mode 100644 index 00000000..1364705e --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { Object3D, Scene } from 'three' +import type { ThreeDEngine } from '@/lib/3d/engine' +import { + applyInstallationDrawingActionCaptureCamera, + applyInstallationDrawingActionCaptureFrame, + isInstallationDrawingActionCaptureCameraApplied, + isInstallationDrawingActionCaptureFrameApplied, + isInstallationDrawingActionWorkspaceReady, + resolveInstallationDrawingActionCaptureFrame, +} from './installationDrawingActionCaptureFrame' +import type { InstallationPartItem, InstallationStepDraft } from '../types' + +const step: InstallationStepDraft = { + id: 'step-1', + order: 1, + title: '步骤 1', + fixedNodeNames: ['fixed'], + installNodeNames: [], + hardwareNodeNames: [], + direction: 'front', + cameraPreset: 'medium', + durationMs: 1200, + note: '', + workflowVersion: 2, + transformSpace: 'local', + scopeNodeNames: ['fixed', 'action-1', 'action-2'], + assembledTransforms: { + fixed: { position: [0, 0, 0], rotation: [0, 0, 0, 1] }, + 'action-1': { position: [1, 0, 0], rotation: [0, 0, 0, 1] }, + 'action-2': { position: [2, 0, 0], rotation: [0, 0, 0, 1] }, + }, + expandedTransforms: { + fixed: { position: [0, 1, 0], rotation: [0, 0, 0, 1] }, + 'action-1': { position: [1, 1, 0], rotation: [0, 0, 0, 1] }, + 'action-2': { position: [2, 1, 0], rotation: [0, 0, 0, 1] }, + }, + expandedStateSource: 'manual', + expandedCameraView: { position: [3, 3, 3], target: [0, 0, 0] }, + actions: [ + { + id: 'action-1', + order: 1, + title: '动作 1', + nodeNames: ['action-1'], + startTransforms: { + 'action-1': { position: [4, 1, 0], rotation: [0, 0, 0, 1] }, + }, + cameraView: { position: [5, 5, 5], target: [1, 0, 0] }, + }, + { + id: 'action-2', + order: 2, + title: '动作 2', + nodeNames: ['action-2'], + startTransforms: { + 'action-2': { position: [6, 1, 0], rotation: [0, 0, 0, 1] }, + }, + }, + ], +} + +describe('resolveInstallationDrawingActionCaptureFrame', () => { + it('shows only the step fixed nodes in the fixed opening frame', () => { + const frame = resolveInstallationDrawingActionCaptureFrame({ + target: { + kind: 'action', + id: 'step-1:fixed', + stepId: 'step-1', + frameKind: 'fixed', + actionId: null, + sequence: 1, + }, + steps: [step], + numberingParts: [], + modelNodeNames: ['fixed', 'action-1', 'action-2', 'outside'], + }) + + expect(frame?.hiddenNodeNames).toEqual(['action-1', 'action-2', 'outside']) + expect(frame?.cameraView).toEqual(step.expandedCameraView) + expect(frame?.editingActionId).toBeNull() + }) + + it('shows fixed, completed actions and the selected action from editor state', () => { + const frame = resolveInstallationDrawingActionCaptureFrame({ + target: { + kind: 'action', + id: 'step-1:action-2', + stepId: 'step-1', + frameKind: 'action', + actionId: 'action-2', + sequence: 3, + }, + steps: [step], + numberingParts: [], + modelNodeNames: ['fixed', 'action-1', 'action-2', 'outside'], + }) + + expect(frame?.hiddenNodeNames).toEqual(['outside']) + expect(frame?.editingActionId).toBe('action-2') + expect(frame?.expectedSelectedNodeNames).toEqual(['action-2']) + expect(frame?.cameraView).toEqual(step.expandedCameraView) + expect(frame?.localTransforms.find((item) => item.nodeName === 'action-2')?.position).toEqual([6, 1, 0]) + }) + + it('keeps finish-close action parts expanded exactly like the editor frame', () => { + const numberingParts = [ + { + kind: 'part', + finishClose: true, + nodeName: 'action-1', + nodeNames: ['action-1'], + }, + ] as InstallationPartItem[] + const frame = resolveInstallationDrawingActionCaptureFrame({ + target: { + kind: 'action', + id: 'step-1:action-2', + stepId: 'step-1', + frameKind: 'action', + actionId: 'action-2', + sequence: 3, + }, + steps: [step], + numberingParts, + modelNodeNames: ['fixed', 'action-1', 'action-2'], + }) + + expect(frame?.step.holdExpandedNodeNames).toEqual(['action-1', 'action-2']) + expect(frame?.localTransforms.find((item) => item.nodeName === 'action-1')?.position).toEqual([ + 1, + 1, + 0, + ]) + expect(frame?.localTransforms.find((item) => item.nodeName === 'action-1')?.position).not.toEqual( + step.assembledTransforms?.['action-1']?.position, + ) + expect(frame?.localTransforms.find((item) => item.nodeName === 'action-2')?.position).toEqual([ + 6, + 1, + 0, + ]) + }) + + it('applies the selected editor action to the engine before capture is ready', () => { + const frame = resolveInstallationDrawingActionCaptureFrame({ + target: { + kind: 'action', + id: 'step-1:action-1', + stepId: 'step-1', + frameKind: 'action', + actionId: 'action-1', + sequence: 2, + }, + steps: [step], + numberingParts: [], + modelNodeNames: ['fixed', 'action-1', 'action-2', 'outside'], + }) + expect(frame).not.toBeNull() + + const scene = new Scene() + const objects = new Map( + ['fixed', 'action-1', 'action-2', 'outside'].map((nodeName) => { + const object = new Object3D() + object.name = nodeName + scene.add(object) + return [nodeName, object] as const + }), + ) + objects.get('action-2')!.position.set(99, 99, 99) + const hiddenCalls: string[][] = [] + const engine = { + resolveObjectByNodeName: (nodeName: string) => objects.get(nodeName) ?? null, + getNodeOriginalLocalTransform: () => ({ + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0, 1] as [number, number, number, number], + }), + getScene: () => scene, + setHiddenNodes: (nodeNames: string[]) => hiddenCalls.push(nodeNames), + } as unknown as ThreeDEngine + + applyInstallationDrawingActionCaptureFrame( + engine, + frame!, + ['fixed', 'action-1', 'action-2', 'outside'], + ) + + expect(objects.get('fixed')!.position.toArray()).toEqual([0, 1, 0]) + expect(objects.get('action-1')!.position.toArray()).toEqual([4, 1, 0]) + expect(objects.get('action-2')!.position.toArray()).toEqual([0, 0, 0]) + expect(hiddenCalls).toEqual([['action-2', 'outside']]) + expect(isInstallationDrawingActionCaptureFrameApplied(engine, frame!)).toBe(true) + + objects.get('action-1')!.position.set(0, 0, 0) + expect(isInstallationDrawingActionCaptureFrameApplied(engine, frame!)).toBe(false) + }) + + it('uses an action camera before the step expanded camera and verifies the applied view', () => { + const frame = resolveInstallationDrawingActionCaptureFrame({ + target: { + kind: 'action', + id: 'step-1:action-1', + stepId: 'step-1', + frameKind: 'action', + actionId: 'action-1', + sequence: 2, + }, + steps: [step], + numberingParts: [], + modelNodeNames: ['fixed', 'action-1', 'action-2'], + }) + expect(frame?.cameraView).toEqual(step.actions?.[0].cameraView) + + const camera = new Object3D() + const target = new Object3D().position + const updateProjectionMatrix = jest.fn() + const updateControls = jest.fn() + const engine = { + cancelCameraAnimation: jest.fn(), + getCamera: () => ({ + position: camera.position, + updateProjectionMatrix, + }), + getControls: () => ({ target, update: updateControls }), + } as unknown as ThreeDEngine + + expect(isInstallationDrawingActionCaptureCameraApplied(engine, frame!)).toBe(false) + applyInstallationDrawingActionCaptureCamera(engine, frame!) + expect(camera.position.toArray()).toEqual([5, 5, 5]) + expect(target.toArray()).toEqual([1, 0, 0]) + expect(updateProjectionMatrix).toHaveBeenCalledTimes(1) + expect(updateControls).toHaveBeenCalledTimes(1) + expect(isInstallationDrawingActionCaptureCameraApplied(engine, frame!)).toBe(true) + }) + + it('waits for the editor to activate the exact action and selected models', () => { + const frame = resolveInstallationDrawingActionCaptureFrame({ + target: { + kind: 'action', + id: 'step-1:action-2', + stepId: 'step-1', + frameKind: 'action', + actionId: 'action-2', + sequence: 3, + }, + steps: [step], + numberingParts: [], + modelNodeNames: ['fixed', 'action-1', 'action-2'], + }) + expect(frame).not.toBeNull() + + expect(isInstallationDrawingActionWorkspaceReady(frame!, { + activeStepId: 'step-1', + activeActionId: null, + selectedNodeNames: [], + })).toBe(false) + expect(isInstallationDrawingActionWorkspaceReady(frame!, { + activeStepId: 'step-1', + activeActionId: 'action-2', + selectedNodeNames: [], + })).toBe(false) + expect(isInstallationDrawingActionWorkspaceReady(frame!, { + activeStepId: 'step-1', + activeActionId: 'action-2', + selectedNodeNames: ['action-2'], + })).toBe(true) + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.ts new file mode 100644 index 00000000..dc0320df --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingActionCaptureFrame.ts @@ -0,0 +1,184 @@ +import type { NodeLocalTransformState, ThreeDEngine } from '@/lib/3d/engine' +import { buildInstallationStepEditFrame } from './installationStepActionPlayback' +import { deriveInstallationFinishClosePlaybackStep } from './installationFinishCloseBinding' +import type { InstallationDrawingActionCaptureTarget } from './installationDrawingCaptureTypes' +import type { + InstallationCameraViewSnapshot, + InstallationPartItem, + InstallationStepDraft, +} from '../types' + +export interface InstallationDrawingActionCaptureFrame { + step: InstallationStepDraft + localTransforms: NodeLocalTransformState[] + hiddenNodeNames: string[] + cameraView: InstallationCameraViewSnapshot | null + editingActionId: string | null + expectedSelectedNodeNames: string[] +} + +const POSITION_EPSILON = 1e-5 +const ROTATION_DOT_EPSILON = 1e-6 + +function isPositionApplied( + actual: { x: number; y: number; z: number }, + expected: NodeLocalTransformState['position'], +) { + return ( + Math.abs(actual.x - expected[0]) <= POSITION_EPSILON && + Math.abs(actual.y - expected[1]) <= POSITION_EPSILON && + Math.abs(actual.z - expected[2]) <= POSITION_EPSILON + ) +} + +function isRotationApplied( + actual: { x: number; y: number; z: number; w: number }, + expected: NodeLocalTransformState['rotation'], +) { + const expectedLength = Math.hypot(...expected) + if (expectedLength <= Number.EPSILON) return false + const dot = Math.abs( + actual.x * (expected[0] / expectedLength) + + actual.y * (expected[1] / expectedLength) + + actual.z * (expected[2] / expectedLength) + + actual.w * (expected[3] / expectedLength), + ) + return Math.abs(1 - dot) <= ROTATION_DOT_EPSILON +} + +export function applyInstallationDrawingActionCaptureFrame( + engine: ThreeDEngine, + frame: InstallationDrawingActionCaptureFrame, + modelNodeNames: string[], +) { + const transformByNodeName = new Map( + frame.localTransforms.map((transform) => [transform.nodeName, transform] as const), + ) + + modelNodeNames.forEach((nodeName) => { + const object = engine.resolveObjectByNodeName(nodeName) + if (!object) return + const transform = + transformByNodeName.get(nodeName) ?? engine.getNodeOriginalLocalTransform(nodeName) + if (!transform) return + object.position.set(...transform.position) + object.quaternion.set(...transform.rotation).normalize() + object.updateMatrix() + }) + engine.getScene().updateMatrixWorld(true) + engine.setHiddenNodes(frame.hiddenNodeNames) +} + +export function isInstallationDrawingActionCaptureFrameApplied( + engine: ThreeDEngine, + frame: InstallationDrawingActionCaptureFrame, +) { + if (frame.localTransforms.length === 0) return false + return frame.localTransforms.every((transform) => { + const object = engine.resolveObjectByNodeName(transform.nodeName) + return Boolean( + object && + isPositionApplied(object.position, transform.position) && + isRotationApplied(object.quaternion, transform.rotation), + ) + }) +} + +export function applyInstallationDrawingActionCaptureCamera( + engine: ThreeDEngine, + frame: InstallationDrawingActionCaptureFrame, +) { + if (!frame.cameraView) return + engine.cancelCameraAnimation() + const camera = engine.getCamera() + const controls = engine.getControls() + camera.position.set(...frame.cameraView.position) + controls.target.set(...frame.cameraView.target) + camera.updateProjectionMatrix() + controls.update() +} + +export function isInstallationDrawingActionCaptureCameraApplied( + engine: ThreeDEngine, + frame: InstallationDrawingActionCaptureFrame, +) { + if (!frame.cameraView) return true + return ( + isPositionApplied(engine.getCamera().position, frame.cameraView.position) && + isPositionApplied(engine.getControls().target, frame.cameraView.target) + ) +} + +interface InstallationDrawingActionWorkspaceState { + activeStepId: string | null + activeActionId: string | null + selectedNodeNames: string[] +} + +export function isInstallationDrawingActionWorkspaceReady( + frame: InstallationDrawingActionCaptureFrame, + state: InstallationDrawingActionWorkspaceState, +) { + if (state.activeStepId !== frame.step.id) return false + if (state.activeActionId !== frame.editingActionId) return false + if (frame.editingActionId === null) return true + + const selectedNodeNameSet = new Set(state.selectedNodeNames) + return ( + selectedNodeNameSet.size === frame.expectedSelectedNodeNames.length && + frame.expectedSelectedNodeNames.every((nodeName) => selectedNodeNameSet.has(nodeName)) + ) +} + +interface ResolveInstallationDrawingActionCaptureFrameInput { + target: InstallationDrawingActionCaptureTarget | null + steps: InstallationStepDraft[] + numberingParts: InstallationPartItem[] + modelNodeNames: string[] +} + +export function resolveInstallationDrawingActionCaptureFrame({ + target, + steps, + numberingParts, + modelNodeNames, +}: ResolveInstallationDrawingActionCaptureFrameInput): InstallationDrawingActionCaptureFrame | null { + if (!target) return null + const storedStep = steps.find((item) => item.id === target.stepId) ?? null + if (!storedStep) return null + const step = deriveInstallationFinishClosePlaybackStep({ + step: storedStep, + numberingParts, + }) + if (!step) return null + + const action = target.actionId + ? (step.actions ?? []).find((item) => item.id === target.actionId) ?? null + : null + if (target.frameKind === 'action' && !action) return null + + const editingActionId = target.frameKind === 'action' ? action?.id ?? null : null + const frame = buildInstallationStepEditFrame( + step, + 'expanded', + target.frameKind === 'fixed' ? 'fixed' : 'action', + editingActionId, + ) + const scopeNodeNameSet = new Set(step.scopeNodeNames ?? []) + const frameHiddenNodeNameSet = new Set(frame.hiddenNodeNames) + const hiddenNodeNames = modelNodeNames.filter( + (nodeName) => !scopeNodeNameSet.has(nodeName) || frameHiddenNodeNameSet.has(nodeName), + ) + + return { + step, + localTransforms: frame.transforms, + hiddenNodeNames, + cameraView: + target.frameKind === 'fixed' + ? step.expandedCameraView ?? null + : action?.cameraView ?? step.expandedCameraView ?? null, + editingActionId, + expectedSelectedNodeNames: action?.nodeNames ?? [], + } +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.test.ts new file mode 100644 index 00000000..d2052c2f --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.test.ts @@ -0,0 +1,27 @@ +import { resolveInstallationDrawingPendingTargets } from './installationDrawingAutoCapturePlan' +import type { InstallationDrawingCaptureTarget } from './installationDrawingCaptureTypes' + +const targets: Array> = [ + { kind: 'finished' }, + { kind: 'exploded' }, + { + kind: 'action', + id: 'step-1:fixed', + stepId: 'step-1', + frameKind: 'fixed', + actionId: null, + sequence: 1, + }, +] + +describe('resolveInstallationDrawingPendingTargets', () => { + it('skips images already saved in the first automatic batch', () => { + expect(resolveInstallationDrawingPendingTargets(targets, ['finished', 'exploded'])) + .toEqual([targets[2]]) + }) + + it('keeps every target for a full replacement batch', () => { + expect(resolveInstallationDrawingPendingTargets(targets, [])).toEqual(targets) + }) +}) + diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.ts new file mode 100644 index 00000000..2c023934 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingAutoCapturePlan.ts @@ -0,0 +1,17 @@ +import { + getInstallationDrawingCaptureTargetKey, + type InstallationDrawingCaptureTarget, +} from './installationDrawingCaptureTypes' + +type CaptureTarget = Exclude + +export function resolveInstallationDrawingPendingTargets( + targets: CaptureTarget[], + completedTargetKeys: Iterable, +) { + const completed = new Set(completedTargetKeys) + return targets.filter( + (target) => !completed.has(getInstallationDrawingCaptureTargetKey(target)), + ) +} + diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureApi.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureApi.ts new file mode 100644 index 00000000..7fbc2a6f --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureApi.ts @@ -0,0 +1,232 @@ +"use client"; + +import type { + InstallationDrawingCapturedImage, + InstallationDrawingCaptureSnapshot, + InstallationDrawingImageMode, + InstallationDrawingImageTransform, + InstallationDrawingNumberMarker, +} from "./installationDrawingCaptureTypes"; + +interface CaptureApiResponse { + success?: boolean; + data?: { snapshot?: InstallationDrawingCaptureSnapshot | null }; + error?: { message?: string }; +} + +function getErrorMessage(payload: CaptureApiResponse | null, fallback: string) { + return payload?.error?.message || fallback; +} + +async function readResponse(response: Response, fallback: string) { + const payload = (await response + .json() + .catch(() => null)) as CaptureApiResponse | null; + if (!response.ok || !payload?.success) { + throw new Error(getErrorMessage(payload, fallback)); + } + return payload.data?.snapshot ?? null; +} + +function dataUrlToBlob(value: string) { + const match = /^data:(image\/[^;,]+);base64,([\s\S]+)$/.exec(value); + if (!match) throw new Error("截图不是可上传的图片数据"); + const binary = atob(match[2]); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new Blob([bytes], { type: match[1] }); +} + +export async function fetchInstallationDrawingCaptureSnapshot( + drawingId: string, + modelUrl: string, +) { + const searchParams = new URLSearchParams({ drawingId, modelUrl }); + const response = await fetch( + `/api/installation-demo-workbench/drawing-captures?${searchParams.toString()}`, + { credentials: "same-origin" }, + ); + return readResponse(response, "安装图纸截图读取失败"); +} + +export async function beginInstallationDrawingCaptureBatch(input: { + drawingId: string; + modelUrl: string; + configFingerprint: string; + expectedTargetKeys: string[]; + inventoryTransforms: Record; + mode: "fill" | "replace"; +}) { + const response = await fetch( + "/api/installation-demo-workbench/drawing-captures", + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ operation: "begin", ...input }), + }, + ); + return readResponse(response, "自动抓取批次创建失败"); +} + +export async function uploadInstallationDrawingCapture(input: { + drawingId: string; + modelUrl: string; + batchId: string; + targetKey: string; + image: InstallationDrawingCapturedImage; +}) { + if (!input.image.originalUrl || !input.image.lineArtUrl) { + throw new Error("当前画面缺少原图或打印线稿"); + } + const formData = new FormData(); + formData.append("drawingId", input.drawingId); + formData.append("modelUrl", input.modelUrl); + formData.append("batchId", input.batchId); + formData.append("targetKey", input.targetKey); + formData.append("transform", JSON.stringify(input.image.transform)); + formData.append( + "numberingMarkers", + JSON.stringify(input.image.numberingMarkers), + ); + formData.append( + "numberingMarkerCountOverrides", + JSON.stringify(input.image.numberingMarkerCountOverrides), + ); + formData.append( + "captureAspectRatio", + JSON.stringify(input.image.captureAspectRatio), + ); + formData.append( + "original", + dataUrlToBlob(input.image.originalUrl), + "original.png", + ); + formData.append( + "lineArt", + dataUrlToBlob(input.image.lineArtUrl), + "line-art.png", + ); + const response = await fetch( + "/api/installation-demo-workbench/drawing-captures", + { + method: "POST", + credentials: "same-origin", + body: formData, + }, + ); + return readResponse(response, "当前画面保存失败"); +} + +export async function replaceInstallationDrawingCapture(input: { + drawingId: string; + modelUrl: string; + configFingerprint: string; + expectedTargetKeys: string[]; + targetKey: string; + image: InstallationDrawingCapturedImage; +}) { + if (!input.image.originalUrl || !input.image.lineArtUrl) { + throw new Error("当前画面缺少原图或打印线稿"); + } + const formData = new FormData(); + formData.append("operation", "replace"); + formData.append("drawingId", input.drawingId); + formData.append("modelUrl", input.modelUrl); + formData.append("configFingerprint", input.configFingerprint); + formData.append( + "expectedTargetKeys", + JSON.stringify(input.expectedTargetKeys), + ); + formData.append("targetKey", input.targetKey); + formData.append("transform", JSON.stringify(input.image.transform)); + formData.append( + "numberingMarkers", + JSON.stringify(input.image.numberingMarkers), + ); + formData.append( + "numberingMarkerCountOverrides", + JSON.stringify(input.image.numberingMarkerCountOverrides), + ); + formData.append( + "captureAspectRatio", + JSON.stringify(input.image.captureAspectRatio), + ); + formData.append( + "original", + dataUrlToBlob(input.image.originalUrl), + "original.png", + ); + formData.append( + "lineArt", + dataUrlToBlob(input.image.lineArtUrl), + "line-art.png", + ); + const response = await fetch( + "/api/installation-demo-workbench/drawing-captures", + { + method: "POST", + credentials: "same-origin", + body: formData, + }, + ); + return readResponse(response, "当前画面重新截取保存失败"); +} + +export async function finalizeInstallationDrawingCaptureBatch(input: { + drawingId: string; + modelUrl: string; + batchId: string; +}) { + const response = await fetch( + "/api/installation-demo-workbench/drawing-captures", + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ operation: "finalize", ...input }), + }, + ); + return readResponse(response, "自动抓取批次固定失败"); +} + +export async function confirmInstallationDrawingCapture(input: { + drawingId: string; + modelUrl: string; + configFingerprint: string; + expectedTargetKeys: string[]; + imageMode: InstallationDrawingImageMode; +}) { + const response = await fetch( + "/api/installation-demo-workbench/drawing-captures", + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ operation: "confirm", ...input }), + }, + ); + return readResponse(response, "图纸样式确认失败"); +} + +export async function updateInstallationDrawingCaptureLayout(input: { + drawingId: string; + modelUrl: string; + targetKey: string; + transform: InstallationDrawingImageTransform; + numberingMarkers: InstallationDrawingNumberMarker[]; + numberingMarkerCountOverrides: Record; +}) { + const response = await fetch( + "/api/installation-demo-workbench/drawing-captures", + { + method: "PATCH", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }, + ); + return readResponse(response, "画面与编号位置保存失败"); +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureFingerprint.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureFingerprint.test.ts new file mode 100644 index 00000000..5c8b9ae5 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureFingerprint.test.ts @@ -0,0 +1,35 @@ +import { buildInstallationDrawingCaptureFingerprint } from './installationDrawingCaptureFingerprint' + +describe('buildInstallationDrawingCaptureFingerprint', () => { + const input = { + drawingId: 'drawing-1', + modelUrl: '/api/storage/object?key=model.glb', + steps: [{ id: 'step-1', actions: [{ id: 'action-1', nodeNames: ['A'] }] }], + tools: [{ id: 'tool-1', toolCode: 'T01', name: 'Tool' }], + } + + it('uses the template-numbering capture version', () => { + expect(buildInstallationDrawingCaptureFingerprint(input as never)).toMatch(/^v3:/) + }) + + it('is stable when object keys have a different insertion order', () => { + const reordered = { + tools: input.tools, + steps: input.steps, + modelUrl: input.modelUrl, + drawingId: input.drawingId, + } + expect(buildInstallationDrawingCaptureFingerprint(input as never)).toBe( + buildInstallationDrawingCaptureFingerprint(reordered as never), + ) + }) + + it('changes when an action configuration changes', () => { + expect(buildInstallationDrawingCaptureFingerprint(input as never)).not.toBe( + buildInstallationDrawingCaptureFingerprint({ + ...input, + steps: [{ id: 'step-1', actions: [{ id: 'action-1', nodeNames: ['B'] }] }], + } as never), + ) + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureFingerprint.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureFingerprint.ts new file mode 100644 index 00000000..50c3aa07 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureFingerprint.ts @@ -0,0 +1,29 @@ +import type { InstallationStepDraft, InstallationToolItem } from '../types' + +function stableSerialize(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]` + const record = value as Record + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`) + .join(',')}}` +} + +function hashString(value: string) { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + +export function buildInstallationDrawingCaptureFingerprint(input: { + drawingId: string + modelUrl: string + steps: InstallationStepDraft[] + tools: InstallationToolItem[] +}) { + return `v3:${hashString(stableSerialize(input))}` +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureMarkers.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureMarkers.test.ts new file mode 100644 index 00000000..1492050a --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureMarkers.test.ts @@ -0,0 +1,49 @@ +import { buildInstallationDrawingCaptureMarkers } from './installationDrawingCaptureMarkers' + +describe('buildInstallationDrawingCaptureMarkers', () => { + it('stores every projected board anchor as a normalized marker', () => { + const markers = buildInstallationDrawingCaptureMarkers([ + { + markerId: 'part-1', + partId: 'part-1', + code: '1', + anchors: [{ x: 25, y: 50 }, { x: 75, y: 20 }], + }, + ] as never, 100, 100) + + expect(markers).toEqual([ + { id: 'part-1:0', partId: 'part-1', code: '1', x: 0.25, y: 0.5, placed: true }, + { id: 'part-1:1', partId: 'part-1', code: '1', x: 0.75, y: 0.2, placed: true }, + ]) + }) + + it('clamps positions and removes duplicate projected markers', () => { + const markers = buildInstallationDrawingCaptureMarkers([ + { + markerId: 'part-a', + partId: 'part-a', + code: ' 2 ', + anchors: [{ x: -2, y: 120 }, { x: -2, y: 120 }], + }, + ] as never, 100, 100) + + expect(markers).toEqual([{ id: 'part-a:0', partId: 'part-a', code: '2', x: 0, y: 1, placed: true }]) + }) + + it('uses the label center for board-centered numbering', () => { + const markers = buildInstallationDrawingCaptureMarkers([ + { + markerId: 'part-center', + partId: 'part-center', + code: '3', + anchors: [], + boxX: 40, + boxY: 30, + boxWidth: 20, + boxHeight: 20, + }, + ] as never, 100, 100) + + expect(markers).toEqual([{ id: 'part-center:0', partId: 'part-center', code: '3', x: 0.5, y: 0.4, placed: true }]) + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureMarkers.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureMarkers.ts new file mode 100644 index 00000000..c65b89c4 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureMarkers.ts @@ -0,0 +1,39 @@ +import type { NumberingMarkerLayout } from './installationNumberingOverlayLayout' +import type { InstallationDrawingNumberMarker } from './installationDrawingCaptureTypes' + +const clampUnit = (value: number) => Math.min(1, Math.max(0, value)) + +export function buildInstallationDrawingCaptureMarkers( + markers: NumberingMarkerLayout[], + width: number, + height: number, +): InstallationDrawingNumberMarker[] { + if (width <= 0 || height <= 0) return [] + + const unique = new Map() + markers.forEach((marker) => { + const code = marker.code.trim() + if (!code) return + const projectedPositions = marker.anchors.length > 0 + ? marker.anchors + : [{ + x: marker.boxX + marker.boxWidth / 2, + y: marker.boxY + marker.boxHeight / 2, + }] + projectedPositions.forEach((anchor, anchorIndex) => { + const x = Number(clampUnit(anchor.x / width).toFixed(6)) + const y = Number(clampUnit(anchor.y / height).toFixed(6)) + const dedupeKey = `${code}:${x.toFixed(4)}:${y.toFixed(4)}` + if (unique.has(dedupeKey)) return + unique.set(dedupeKey, { + id: `${marker.markerId}:${anchorIndex}`, + partId: marker.partId, + code, + x, + y, + placed: true, + }) + }) + }) + return Array.from(unique.values()) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureNumbering.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureNumbering.test.ts new file mode 100644 index 00000000..963bfa63 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureNumbering.test.ts @@ -0,0 +1,53 @@ +import { buildInstallationDrawingCaptureNumberingParts } from './installationDrawingCaptureNumbering' + +const parts = [ + { id: 'part-a', kind: 'part', nodeName: 'A', nodeNames: ['A', 'A-copy'] }, + { id: 'part-b', kind: 'part', nodeName: 'B', nodeNames: ['B'] }, + { id: 'hardware-c', kind: 'hardware', nodeName: 'C', nodeNames: ['C'] }, +] + +const step = { + id: 'step-1', + fixedNodeNames: ['A'], + actions: [{ id: 'action-1', nodeNames: ['B', 'C'] }], +} + +describe('buildInstallationDrawingCaptureNumberingParts', () => { + it('keeps all board parts and excludes hardware in the exploded capture', () => { + expect(buildInstallationDrawingCaptureNumberingParts({ + target: { kind: 'exploded' }, + step: null, + parts: parts as never, + }).map((part) => part.id)).toEqual(['part-a', 'part-b']) + }) + + it('keeps only the fixed board nodes for a fixed frame', () => { + expect(buildInstallationDrawingCaptureNumberingParts({ + target: { + kind: 'action', + id: 'fixed-step-1', + stepId: 'step-1', + frameKind: 'fixed', + actionId: null, + sequence: 1, + }, + step: step as never, + parts: parts as never, + })).toMatchObject([{ id: 'part-a', nodeNames: ['A'] }]) + }) + + it('keeps the current action board nodes and excludes its hardware', () => { + expect(buildInstallationDrawingCaptureNumberingParts({ + target: { + kind: 'action', + id: 'action-1', + stepId: 'step-1', + frameKind: 'action', + actionId: 'action-1', + sequence: 2, + }, + step: step as never, + parts: parts as never, + }).map((part) => part.id)).toEqual(['part-b']) + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureNumbering.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureNumbering.ts new file mode 100644 index 00000000..d15e42dd --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureNumbering.ts @@ -0,0 +1,32 @@ +import type { InstallationPartItem, InstallationStepDraft } from '../types' +import { getPartNodeNames } from './installationPartCatalogHelpers' +import type { InstallationDrawingCaptureTarget } from './installationDrawingCaptureTypes' +import { buildInstallationNumberingDisplayParts } from './installationNumberingDisplayParts' + +export function buildInstallationDrawingCaptureNumberingParts(input: { + target: InstallationDrawingCaptureTarget | null + step: InstallationStepDraft | null + parts: InstallationPartItem[] +}) { + const target = input.target + const partItems = buildInstallationNumberingDisplayParts(input.parts, 'part') + if (target?.kind === 'exploded') return partItems + if (target?.kind !== 'action' || input.step?.id !== target.stepId) return [] + + const targetNodeNames = target.frameKind === 'fixed' + ? input.step.fixedNodeNames + : input.step.actions?.find((action) => action.id === target.actionId)?.nodeNames ?? [] + const targetNodeNameSet = new Set(targetNodeNames) + + return partItems.flatMap((part) => { + const matchedNodeNames = getPartNodeNames(part).filter((nodeName) => + targetNodeNameSet.has(nodeName), + ) + if (matchedNodeNames.length === 0) return [] + return [{ + ...part, + nodeName: matchedNodeNames[0] ?? part.nodeName, + nodeNames: matchedNodeNames, + }] + }) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCapturePreservation.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCapturePreservation.test.ts new file mode 100644 index 00000000..749d8a70 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCapturePreservation.test.ts @@ -0,0 +1,49 @@ +import { preserveInstallationDrawingCaptureAnnotations } from './installationDrawingCapturePreservation' +import { createEmptyInstallationDrawingCapturedImage } from './installationDrawingCaptureTypes' + +const capturedImage = { + originalUrl: 'original.png', + lineArtUrl: 'line-art.png', + transform: { scale: 1, offsetX: 0, offsetY: 0 }, + numberingMarkers: [], + numberingMarkerCountOverrides: {}, + captureAspectRatio: 1.5, +} + +describe('preserveInstallationDrawingCaptureAnnotations', () => { + it('keeps saved marker positions and desired counts when recapturing', () => { + const previous = { + ...capturedImage, + transform: { scale: 1.2, offsetX: 4, offsetY: -3 }, + numberingMarkers: [ + { id: 'part-27:0', partId: 'part-27', code: '27', x: 0.4, y: 0.6, placed: true }, + ], + numberingMarkerCountOverrides: { 'part-27': 2 }, + } + const next = { + ...capturedImage, + originalUrl: 'new-original.png', + lineArtUrl: 'new-line-art.png', + numberingMarkers: [ + { id: 'fresh', partId: 'part-27', code: '27', x: 0.1, y: 0.1, placed: true }, + ], + } + + expect(preserveInstallationDrawingCaptureAnnotations(next, previous)).toEqual({ + ...next, + transform: previous.transform, + numberingMarkers: previous.numberingMarkers, + numberingMarkerCountOverrides: previous.numberingMarkerCountOverrides, + }) + }) + + it('uses newly captured markers when there is no previous image', () => { + const next = { ...capturedImage, numberingMarkerCountOverrides: { fresh: 1 } } + expect( + preserveInstallationDrawingCaptureAnnotations( + next, + createEmptyInstallationDrawingCapturedImage(), + ), + ).toBe(next) + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCapturePreservation.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCapturePreservation.ts new file mode 100644 index 00000000..d16fc708 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCapturePreservation.ts @@ -0,0 +1,19 @@ +import { + isInstallationDrawingImageCaptured, + type InstallationDrawingCapturedImage, +} from './installationDrawingCaptureTypes' + +export function preserveInstallationDrawingCaptureAnnotations( + next: InstallationDrawingCapturedImage, + previous: InstallationDrawingCapturedImage | null, +) { + if (!previous || !isInstallationDrawingImageCaptured(previous)) return next + return { + ...next, + transform: { ...previous.transform }, + numberingMarkers: previous.numberingMarkers.map((marker) => ({ ...marker })), + numberingMarkerCountOverrides: { + ...previous.numberingMarkerCountOverrides, + }, + } +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureTypes.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureTypes.test.ts new file mode 100644 index 00000000..b9140f40 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureTypes.test.ts @@ -0,0 +1,34 @@ +import { + resolveInstallationDrawingConfirmedAtForMode, + resolveInstallationDrawingImageMode, +} from "./installationDrawingCaptureTypes"; + +describe("installation drawing image mode", () => { + it("shows the confirmed mode until the operator selects another mode", () => { + expect(resolveInstallationDrawingImageMode(null, "original")).toBe( + "original", + ); + expect(resolveInstallationDrawingImageMode("line-art", "original")).toBe( + "line-art", + ); + expect(resolveInstallationDrawingImageMode(null, null)).toBe("line-art"); + }); + + it("requires reconfirmation after the operator switches modes", () => { + const confirmedAt = "2026-08-15T08:00:00.000Z"; + expect( + resolveInstallationDrawingConfirmedAtForMode( + confirmedAt, + "original", + "original", + ), + ).toBe(confirmedAt); + expect( + resolveInstallationDrawingConfirmedAtForMode( + confirmedAt, + "original", + "line-art", + ), + ).toBeNull(); + }); +}); diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureTypes.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureTypes.ts new file mode 100644 index 00000000..77438f93 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingCaptureTypes.ts @@ -0,0 +1,216 @@ +export type InstallationDrawingImageMode = "original" | "line-art"; + +export interface InstallationDrawingImageTransform { + scale: number; + offsetX: number; + offsetY: number; +} + +export interface InstallationDrawingNumberMarker { + id: string; + partId: string | null; + code: string; + x: number; + y: number; + placed: boolean; +} + +export interface InstallationDrawingMarkerSource { + id: string; + code: string; + name: string; + quantity: number; +} + +export interface InstallationDrawingMarkerSnapshot { + markers: InstallationDrawingNumberMarker[]; + aspectRatio: number | null; +} + +export interface InstallationDrawingCapturedImage { + originalUrl: string | null; + lineArtUrl: string | null; + transform: InstallationDrawingImageTransform; + numberingMarkers: InstallationDrawingNumberMarker[]; + numberingMarkerCountOverrides: Record; + captureAspectRatio: number | null; +} + +export interface InstallationDrawingActionCapture { + id: string; + stepId: string; + frameKind: "fixed" | "action"; + actionId: string | null; + sequence: number; + image: InstallationDrawingCapturedImage; + materials: Array<{ + code: string; + name: string; + kind: "part" | "hardware"; + quantity: number; + snapshotPartId: string | null; + }>; + tools: Array<{ + code: string; + name: string; + }>; +} + +export interface InstallationDrawingCaptures { + finished: InstallationDrawingCapturedImage; + exploded: InstallationDrawingCapturedImage; + actions: InstallationDrawingActionCapture[]; + inventoryTransforms: Record; +} + +export interface InstallationDrawingPersistedImage { + originalUrl: string; + lineArtUrl: string; + transform: InstallationDrawingImageTransform; + numberingMarkers: InstallationDrawingNumberMarker[]; + numberingMarkerCountOverrides: Record; + captureAspectRatio: number | null; +} + +export interface InstallationDrawingCaptureBatch { + batchId: string; + configFingerprint: string; + images: Record; + inventoryTransforms: Record; + expectedTargetKeys: string[]; + completedCount: number; + totalCount: number; + savedAt: string; + confirmedAt: string | null; + confirmedImageMode: InstallationDrawingImageMode | null; +} + +export interface InstallationDrawingCaptureSnapshot { + version: number; + drawingId: string; + modelUrl: string; + modelIdentity: string; + active: InstallationDrawingCaptureBatch | null; + draft: InstallationDrawingCaptureBatch | null; +} + +export type InstallationDrawingCaptureTarget = + | { kind: "finished" } + | { kind: "exploded" } + | { + kind: "action"; + id: string; + stepId: string; + frameKind: "fixed" | "action"; + actionId: string | null; + sequence: number; + } + | { + kind: "inventory"; + id: string; + }; + +export type InstallationDrawingOverviewCaptureTarget = Extract< + InstallationDrawingCaptureTarget, + { kind: "finished" | "exploded" } +>; + +export type InstallationDrawingActionCaptureTarget = Extract< + InstallationDrawingCaptureTarget, + { kind: "action" } +>; + +export function isInstallationDrawingOverviewCaptureTarget( + target: InstallationDrawingCaptureTarget | null, +): target is InstallationDrawingOverviewCaptureTarget { + return target?.kind === "finished" || target?.kind === "exploded"; +} + +export function isInstallationDrawingActionCaptureTarget( + target: InstallationDrawingCaptureTarget | null, +): target is InstallationDrawingActionCaptureTarget { + return target?.kind === "action"; +} + +export function getInstallationDrawingCaptureTargetKey( + target: Exclude, +) { + return target.kind === "action" ? `action:${target.id}` : target.kind; +} + +export function getInstallationDrawingTransformTargetKey( + target: InstallationDrawingCaptureTarget, +) { + return target.kind === "inventory" + ? `inventory:${target.id}` + : getInstallationDrawingCaptureTargetKey(target); +} + +export function buildInstallationDrawingCaptureTargets( + captures: InstallationDrawingCaptures, +): Array> { + return [ + { kind: "finished" }, + { kind: "exploded" }, + ...captures.actions.map((action) => ({ + kind: "action" as const, + id: action.id, + stepId: action.stepId, + frameKind: action.frameKind, + actionId: action.actionId, + sequence: action.sequence, + })), + ]; +} + +export const DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM: InstallationDrawingImageTransform = + { + scale: 1, + offsetX: 0, + offsetY: 0, + }; + +export function createEmptyInstallationDrawingCapturedImage(): InstallationDrawingCapturedImage { + return { + originalUrl: null, + lineArtUrl: null, + transform: { ...DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM }, + numberingMarkers: [], + numberingMarkerCountOverrides: {}, + captureAspectRatio: null, + }; +} + +export function isInstallationDrawingImageAvailable( + image: InstallationDrawingCapturedImage, +) { + return Boolean(image.originalUrl || image.lineArtUrl); +} + +export function isInstallationDrawingImageCaptured( + image: InstallationDrawingCapturedImage, +) { + return Boolean(image.originalUrl && image.lineArtUrl); +} + +export function resolveInstallationDrawingImageUrl( + image: InstallationDrawingCapturedImage, + mode: InstallationDrawingImageMode, +) { + return mode === "line-art" ? image.lineArtUrl : image.originalUrl; +} + +export function resolveInstallationDrawingImageMode( + override: InstallationDrawingImageMode | null, + confirmed: InstallationDrawingImageMode | null, +): InstallationDrawingImageMode { + return override ?? confirmed ?? "line-art"; +} + +export function resolveInstallationDrawingConfirmedAtForMode( + confirmedAt: string | null, + confirmedMode: InstallationDrawingImageMode | null, + currentMode: InstallationDrawingImageMode, +) { + return confirmedMode === currentMode ? confirmedAt : null; +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingLayout.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingLayout.test.ts new file mode 100644 index 00000000..0b81b48c --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingLayout.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from '@jest/globals' +import { + INSTALLATION_DRAWING_ACTIONS_PANEL_CAPACITY, + INSTALLATION_DRAWING_INVENTORY_PANEL_CAPACITY, + INSTALLATION_DRAWING_PANELS_PER_PAGE, + buildInstallationDrawingLayout, +} from './installationDrawingLayout' +import type { InstallationStepDraft } from '../types' + +function step(order: number, actionCount: number): InstallationStepDraft { + return { + id: `step-${order}`, + order, + title: `步骤 ${order}`, + fixedNodeNames: [], + installNodeNames: [], + hardwareNodeNames: [], + direction: 'front', + cameraPreset: 'medium', + durationMs: 1200, + note: '', + actions: Array.from({ length: actionCount }, (_, index) => ({ + id: `action-${order}-${index + 1}`, + order: index + 1, + title: `动作 ${index + 1}`, + nodeNames: [], + startTransforms: {}, + })), + } +} + +describe('buildInstallationDrawingLayout', () => { + const xj0096Steps = Array.from({ length: 16 }, (_, index) => + step(index + 1, index < 9 ? 4 : 3), + ) + + it('uses two fixed half-page panels on each A4 landscape page', () => { + const layout = buildInstallationDrawingLayout({ + templateId: 'set-1', + steps: xj0096Steps, + inventoryItemCount: 40, + }) + + expect(layout.paper).toBe('A4') + expect(layout.orientation).toBe('landscape') + expect(INSTALLATION_DRAWING_PANELS_PER_PAGE).toBe(2) + expect(INSTALLATION_DRAWING_INVENTORY_PANEL_CAPACITY).toBe(16) + expect(INSTALLATION_DRAWING_ACTIONS_PANEL_CAPACITY).toBe(4) + expect(layout.pages.slice(0, -1).every((page) => page.panels.length === 2)).toBe(true) + }) + + it('flows overview, inventory and actions through consecutive half pages', () => { + const layout = buildInstallationDrawingLayout({ + templateId: 'set-1', + steps: xj0096Steps, + inventoryItemCount: 40, + }) + const panels = layout.pages.flatMap((page) => page.panels) + + expect(panels.slice(0, 4).map((panel) => panel.kind)).toEqual([ + 'overview', + 'inventory', + 'inventory', + 'inventory', + ]) + expect(panels.filter((panel) => panel.kind === 'inventory').map((panel) => [panel.inventoryStart, panel.inventoryEnd])).toEqual([ + [0, 16], + [16, 32], + [32, 40], + ]) + expect(panels[4].kind).toBe('actions') + }) + + it('starts every step with its fixed frame and keeps all frames in sequence', () => { + const layout = buildInstallationDrawingLayout({ + templateId: 'set-1', + steps: xj0096Steps, + inventoryItemCount: 40, + }) + const actionPanels = layout.pages + .flatMap((page) => page.panels) + .filter((panel) => panel.kind === 'actions') + const actions = actionPanels + .flatMap((panel) => panel.actionGroups ?? []) + .flatMap((group) => group.actions) + + expect(actionPanels).toHaveLength(19) + expect(actionPanels.every((panel) => (panel.actionGroups ?? []).flatMap((group) => group.actions).length <= 4)).toBe(true) + expect(actions).toHaveLength(73) + expect(new Set(actions.map((action) => action.id)).size).toBe(73) + expect(actions.map((action) => action.sequence)).toEqual(Array.from({ length: 73 }, (_, index) => index + 1)) + expect(actions.filter((action) => action.frameKind === 'fixed')).toHaveLength(16) + xj0096Steps.forEach((currentStep) => { + const stepFrames = actions.filter((action) => action.stepId === currentStep.id) + expect(stepFrames[0]).toMatchObject({ frameKind: 'fixed', actionId: null }) + }) + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingLayout.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingLayout.ts new file mode 100644 index 00000000..66741e4f --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingLayout.ts @@ -0,0 +1,179 @@ +import type { InstallationStepDraft } from '../types' +import { + getInstallationDrawingTemplate, + type InstallationDrawingTemplateId, +} from './installationDrawingTemplates' + +export type InstallationDrawingPaper = 'A4' | 'A3' +export type InstallationDrawingOrientation = 'portrait' | 'landscape' +export type InstallationDrawingPanelKind = 'overview' | 'inventory' | 'actions' + +export const INSTALLATION_DRAWING_INVENTORY_PANEL_CAPACITY = 16 +export const INSTALLATION_DRAWING_ACTIONS_PANEL_CAPACITY = 4 +export const INSTALLATION_DRAWING_PANELS_PER_PAGE = 2 + +export interface InstallationDrawingActionRef { + id: string + sequence: number + stepId: string + stepOrder: number + frameKind: 'fixed' | 'action' + actionId: string | null + actionOrder: number + title: string +} + +export interface InstallationDrawingActionGroup { + stepId: string + stepOrder: number + stepTitle: string + actions: InstallationDrawingActionRef[] +} + +export interface InstallationDrawingPanel { + id: string + kind: InstallationDrawingPanelKind + inventoryStart?: number + inventoryEnd?: number + actionGroups?: InstallationDrawingActionGroup[] +} + +export interface InstallationDrawingPage { + id: string + panels: InstallationDrawingPanel[] +} + +export interface InstallationDrawingLayout { + templateId: InstallationDrawingTemplateId + paper: InstallationDrawingPaper + orientation: InstallationDrawingOrientation + pages: InstallationDrawingPage[] +} + +interface BuildInstallationDrawingLayoutInput { + templateId?: InstallationDrawingTemplateId + paper?: InstallationDrawingPaper + orientation?: InstallationDrawingOrientation | 'auto' + steps: InstallationStepDraft[] + inventoryItemCount?: number +} + +function buildActionGroups(steps: InstallationStepDraft[]): InstallationDrawingActionGroup[] { + let sequence = 0 + return steps.flatMap((step) => { + const fixedFrame: InstallationDrawingActionRef = { + id: `${step.id}:fixed`, + sequence: ++sequence, + stepId: step.id, + stepOrder: step.order, + frameKind: 'fixed', + actionId: null, + actionOrder: 0, + title: '', + } + const actions = (step.actions ?? []).map((action) => { + sequence += 1 + return { + id: `${step.id}:${action.id}`, + sequence, + stepId: step.id, + stepOrder: step.order, + frameKind: 'action' as const, + actionId: action.id, + actionOrder: action.order, + title: action.title || `动作 ${sequence}`, + } + }) + return [{ + stepId: step.id, + stepOrder: step.order, + stepTitle: step.title || '', + actions: [fixedFrame, ...actions], + }] + }) +} + +function takeActionGroups( + groups: InstallationDrawingActionGroup[], + actionCount: number, +): [InstallationDrawingActionGroup[], InstallationDrawingActionGroup[]] { + const selected: InstallationDrawingActionGroup[] = [] + const remaining: InstallationDrawingActionGroup[] = [] + let capacity = actionCount + + groups.forEach((group) => { + if (capacity <= 0) { + remaining.push(group) + return + } + const selectedActions = group.actions.slice(0, capacity) + const remainingActions = group.actions.slice(capacity) + if (selectedActions.length > 0) selected.push({ ...group, actions: selectedActions }) + if (remainingActions.length > 0) remaining.push({ ...group, actions: remainingActions }) + capacity -= selectedActions.length + }) + + return [selected, remaining] +} + +function buildInventoryPanels(inventoryItemCount: number): InstallationDrawingPanel[] { + const panelCount = Math.ceil(inventoryItemCount / INSTALLATION_DRAWING_INVENTORY_PANEL_CAPACITY) + return Array.from({ length: panelCount }, (_, index) => { + const start = index * INSTALLATION_DRAWING_INVENTORY_PANEL_CAPACITY + return { + id: `inventory-${index + 1}`, + kind: 'inventory' as const, + inventoryStart: start, + inventoryEnd: Math.min(start + INSTALLATION_DRAWING_INVENTORY_PANEL_CAPACITY, inventoryItemCount), + } + }) +} + +function buildActionPanels(actionGroups: InstallationDrawingActionGroup[]): InstallationDrawingPanel[] { + const panelGroups: InstallationDrawingActionGroup[][] = [] + let remaining = actionGroups + while (remaining.length > 0) { + const [current, next] = takeActionGroups(remaining, INSTALLATION_DRAWING_ACTIONS_PANEL_CAPACITY) + panelGroups.push(current) + remaining = next + } + + return panelGroups.map((groups, index) => ({ + id: `actions-${index + 1}`, + kind: 'actions' as const, + actionGroups: groups, + })) +} + +function pairPanels(panels: InstallationDrawingPanel[]): InstallationDrawingPage[] { + const pages: InstallationDrawingPage[] = [] + for (let index = 0; index < panels.length; index += INSTALLATION_DRAWING_PANELS_PER_PAGE) { + pages.push({ + id: `page-${pages.length + 1}`, + panels: panels.slice(index, index + INSTALLATION_DRAWING_PANELS_PER_PAGE), + }) + } + return pages +} + +export function buildInstallationDrawingLayout({ + templateId, + paper, + orientation, + steps, + inventoryItemCount = 0, +}: BuildInstallationDrawingLayoutInput): InstallationDrawingLayout { + const template = getInstallationDrawingTemplate(templateId ?? 'set-1') + const panels: InstallationDrawingPanel[] = [ + { id: 'overview', kind: 'overview' }, + ...buildInventoryPanels(inventoryItemCount), + ...buildActionPanels(buildActionGroups(steps)), + ] + + return { + templateId: template.id, + paper: template.paper ?? paper ?? 'A4', + orientation: template.orientation ?? (orientation === 'auto' ? 'portrait' : orientation ?? 'portrait'), + pages: pairPanels(panels), + } +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingMarkerInventory.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingMarkerInventory.test.ts new file mode 100644 index 00000000..82c972eb --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingMarkerInventory.test.ts @@ -0,0 +1,45 @@ +import { + countInstallationDrawingUnplacedMarkers, + reconcileInstallationDrawingNumberMarkers, +} from './installationDrawingMarkerInventory' + +const sources = [ + { id: 'part-27', code: '27', name: '层板', quantity: 5 }, + { id: 'part-8', code: '8', name: '背板', quantity: 1 }, +] + +describe('installation drawing marker inventory', () => { + it('adds hidden or unprojected instances as unplaced markers', () => { + const markers = reconcileInstallationDrawingNumberMarkers({ + markers: [ + { id: '27-a', partId: 'part-27', code: '27', x: 0.2, y: 0.3, placed: true }, + { id: '27-b', partId: 'part-27', code: '27', x: 0.4, y: 0.5, placed: true }, + ], + sources, + countOverrides: {}, + }) + + expect(markers.filter((marker) => marker.code === '27')).toHaveLength(5) + expect(markers.filter((marker) => marker.code === '8')).toHaveLength(1) + expect(countInstallationDrawingUnplacedMarkers(markers)).toBe(4) + }) + + it('respects an explicit annotation count without changing source quantity', () => { + const markers = reconcileInstallationDrawingNumberMarkers({ + markers: Array.from({ length: 5 }, (_, index) => ({ + id: `27-${index}`, + partId: 'part-27', + code: '27', + x: 0.1 + index * 0.1, + y: 0.4, + placed: true, + })), + sources, + countOverrides: { 'part-27': 2, 'part-8': 0 }, + }) + + expect(markers).toHaveLength(2) + expect(markers.every((marker) => marker.code === '27')).toBe(true) + expect(sources[0].quantity).toBe(5) + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingMarkerInventory.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingMarkerInventory.ts new file mode 100644 index 00000000..ce6ce096 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingMarkerInventory.ts @@ -0,0 +1,104 @@ +import type { InstallationPartItem } from '../types' +import { buildInstallationNumberingDisplayParts } from './installationNumberingDisplayParts' +import type { + InstallationDrawingActionCapture, + InstallationDrawingMarkerSource, + InstallationDrawingNumberMarker, +} from './installationDrawingCaptureTypes' + +const MAX_MARKER_COUNT = 240 + +function normalizeCount(value: number) { + return Math.min(MAX_MARKER_COUNT, Math.max(0, Math.floor(value))) +} + +export function buildInstallationDrawingExplodedMarkerSources( + parts: InstallationPartItem[], +): InstallationDrawingMarkerSource[] { + return buildInstallationNumberingDisplayParts(parts, 'part') + .filter((part) => Boolean(part.code.trim())) + .map((part) => ({ + id: part.id, + code: part.code.trim(), + name: part.name, + quantity: Math.max(1, normalizeCount(part.quantity)), + })) +} + +export function buildInstallationDrawingActionMarkerSources( + capture: InstallationDrawingActionCapture, +): InstallationDrawingMarkerSource[] { + return capture.materials + .filter((material) => material.kind === 'part' && Boolean(material.code.trim())) + .map((material) => ({ + id: material.snapshotPartId ?? `part-code:${material.code.trim()}`, + code: material.code.trim(), + name: material.name, + quantity: Math.max(1, normalizeCount(material.quantity)), + })) +} + +function buildStagingPosition(index: number) { + return { + x: Number((0.92 - (index % 3) * 0.065).toFixed(6)), + y: Number(Math.min(0.9, 0.1 + Math.floor(index / 3) * 0.075).toFixed(6)), + } +} + +export function reconcileInstallationDrawingNumberMarkers(input: { + markers: InstallationDrawingNumberMarker[] + sources: InstallationDrawingMarkerSource[] + countOverrides: Record +}) { + const claimedIds = new Set() + const usedIds = new Set(input.markers.map((marker) => marker.id)) + const nextMarkers: InstallationDrawingNumberMarker[] = [] + let unplacedIndex = 0 + + input.sources.forEach((source) => { + const desiredCount = normalizeCount( + input.countOverrides[source.id] ?? source.quantity, + ) + const matching = input.markers.filter((marker) => ( + !claimedIds.has(marker.id) && + (marker.partId === source.id || (!marker.partId && marker.code === source.code)) + )) + matching.slice(0, desiredCount).forEach((marker) => { + claimedIds.add(marker.id) + nextMarkers.push({ + ...marker, + partId: source.id, + code: source.code, + placed: marker.placed !== false, + }) + }) + + for (let index = matching.length; index < desiredCount; index += 1) { + let id = `${source.id}:manual:${index}` + while (usedIds.has(id)) id = `${id}:next` + usedIds.add(id) + const position = buildStagingPosition(unplacedIndex) + unplacedIndex += 1 + nextMarkers.push({ + id, + partId: source.id, + code: source.code, + ...position, + placed: false, + }) + } + }) + + input.markers.forEach((marker) => { + if (claimedIds.has(marker.id)) return + if (input.sources.some((source) => source.id === marker.partId)) return + nextMarkers.push({ ...marker, placed: marker.placed !== false }) + }) + return nextMarkers +} + +export function countInstallationDrawingUnplacedMarkers( + markers: InstallationDrawingNumberMarker[], +) { + return markers.filter((marker) => !marker.placed).length +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingTemplates.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingTemplates.ts new file mode 100644 index 00000000..5e1af601 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingTemplates.ts @@ -0,0 +1,27 @@ +export type InstallationDrawingTemplateId = 'set-1' + +export interface InstallationDrawingTemplateDefinition { + id: InstallationDrawingTemplateId + name: string + description: string + paper: 'A4' + orientation: 'landscape' + overviewLayout: 'split-overview-inventory' +} + +export const INSTALLATION_DRAWING_TEMPLATES: InstallationDrawingTemplateDefinition[] = [ + { + id: 'set-1', + name: '第一套模板', + description: '成品、爆炸图、部件清单与安装动作组合排版', + paper: 'A4', + orientation: 'landscape', + overviewLayout: 'split-overview-inventory', + }, +] + +export const DEFAULT_INSTALLATION_DRAWING_TEMPLATE_ID: InstallationDrawingTemplateId = 'set-1' + +export function getInstallationDrawingTemplate(templateId: InstallationDrawingTemplateId) { + return INSTALLATION_DRAWING_TEMPLATES.find((template) => template.id === templateId) ?? INSTALLATION_DRAWING_TEMPLATES[0] +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingToolSelection.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingToolSelection.test.ts new file mode 100644 index 00000000..7825f867 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingToolSelection.test.ts @@ -0,0 +1,34 @@ +import { reconcileInstallationDrawingToolIds } from './installationDrawingToolSelection' + +describe('reconcileInstallationDrawingToolIds', () => { + it('does not select the only tool when the drawing did not previously select one', () => { + expect(reconcileInstallationDrawingToolIds([], [{ id: 'tool-driver' }])).toEqual([]) + }) + + it('keeps current tool ids and removes stale ids', () => { + expect( + reconcileInstallationDrawingToolIds( + ['tool-retired', 'tool-driver', 'tool-driver'], + [{ id: 'tool-driver' }, { id: 'tool-wrench' }], + ), + ).toEqual(['tool-driver']) + }) + + it('migrates a stale selection when the toolbox has exactly one tool', () => { + expect( + reconcileInstallationDrawingToolIds( + ['tool-retired'], + [{ id: 'tool-driver' }], + ), + ).toEqual(['tool-driver']) + }) + + it('preserves an ambiguous stale selection when multiple tools are available', () => { + expect( + reconcileInstallationDrawingToolIds( + ['tool-retired'], + [{ id: 'tool-driver' }, { id: 'tool-wrench' }], + ), + ).toEqual(['tool-retired']) + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingToolSelection.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingToolSelection.ts new file mode 100644 index 00000000..80f144f4 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDrawingToolSelection.ts @@ -0,0 +1,21 @@ +interface InstallationDrawingToolIdentity { + id: string +} + +export function reconcileInstallationDrawingToolIds( + selectedToolIds: string[], + availableTools: ReadonlyArray, +) { + const availableToolIdSet = new Set(availableTools.map((tool) => tool.id)) + const currentToolIds = selectedToolIds.filter((toolId) => availableToolIdSet.has(toolId)) + + if (currentToolIds.length > 0) { + return Array.from(new Set(currentToolIds)) + } + + if (selectedToolIds.length > 0 && availableTools.length === 1) { + return [availableTools[0].id] + } + + return selectedToolIds +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotApi.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotApi.ts index 66094fbe..6b48af20 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotApi.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotApi.ts @@ -33,19 +33,19 @@ function dataUrlToBlob(value: string) { async function appendSnapshots( formData: FormData, - fieldPrefix: 'part' | 'tool', + manifestField: string, snapshots: Record, ) { const items = Object.entries(snapshots) const manifest = await Promise.all( items.map(async ([id, dataUrl], index) => { - const field = `${fieldPrefix}-${index}` + const field = `${manifestField}-${index}` const blob = await dataUrlToBlob(dataUrl) formData.append(field, blob, `${field}.png`) return { id, field } }), ) - formData.append(`${fieldPrefix}Snapshots`, JSON.stringify(manifest)) + formData.append(manifestField, JSON.stringify(manifest)) } export async function fetchInstallationNumberingSnapshotBatch(drawingId: string, modelUrl: string) { @@ -73,8 +73,10 @@ export async function saveInstallationNumberingSnapshotBatch( snapshot.modelIdentity ?? resolveInstallationNumberingSnapshotModelIdentity(snapshot.modelUrl), ) - await appendSnapshots(formData, 'part', snapshot.snapshotUrls) - await appendSnapshots(formData, 'tool', snapshot.toolSnapshotUrls ?? {}) + await appendSnapshots(formData, 'partSnapshots', snapshot.snapshotUrls) + await appendSnapshots(formData, 'partLineArtSnapshots', snapshot.snapshotLineArtUrls ?? {}) + await appendSnapshots(formData, 'toolSnapshots', snapshot.toolSnapshotUrls ?? {}) + await appendSnapshots(formData, 'toolLineArtSnapshots', snapshot.toolSnapshotLineArtUrls ?? {}) const response = await fetch('/api/installation-demo-workbench/numbering-snapshots', { method: 'POST', diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotBatchBuilder.test.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotBatchBuilder.test.ts new file mode 100644 index 00000000..9294d25b --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotBatchBuilder.test.ts @@ -0,0 +1,108 @@ +import { + buildInstallationNumberingSnapshotBatch, + installationNumberingSnapshotBatchUsesPublicAssets, + restoreInstallationNumberingSnapshotBatchForShare, +} from './installationNumberingSnapshotBatchBuilder' + +describe('buildInstallationNumberingSnapshotBatch', () => { + it('builds a complete batch with only the current part and tool IDs', () => { + const result = buildInstallationNumberingSnapshotBatch({ + modelUrl: '/api/storage/object?key=models%2Fcabinet.glb', + partIds: ['part-1'], + toolIds: ['tool-1'], + snapshotUrls: { 'part-1': '/part.png', stale: '/stale.png' }, + snapshotLineArtUrls: { 'part-1': '/part-line.png' }, + toolSnapshotUrls: { 'tool-1': '/tool.png' }, + toolSnapshotLineArtUrls: { 'tool-1': '/tool-line.png' }, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.batch.snapshotUrls).toEqual({ 'part-1': '/part.png' }) + expect(installationNumberingSnapshotBatchUsesPublicAssets(result.batch)).toBe(true) + }) + + it('rejects a batch with a missing part line drawing', () => { + const result = buildInstallationNumberingSnapshotBatch({ + modelUrl: '/model.obj', + partIds: ['part-1'], + toolIds: [], + snapshotUrls: { 'part-1': 'data:image/png;base64,original' }, + snapshotLineArtUrls: {}, + toolSnapshotUrls: {}, + toolSnapshotLineArtUrls: {}, + }) + + expect(result).toEqual({ + ok: false, + message: '部件线稿尚未生成完整,请重新截取缺失部件', + }) + }) + + it('detects local data URLs that cannot be used by a public share directly', () => { + const result = buildInstallationNumberingSnapshotBatch({ + modelUrl: '/model.obj', + partIds: ['part-1'], + toolIds: [], + snapshotUrls: { 'part-1': 'data:image/png;base64,original' }, + snapshotLineArtUrls: { 'part-1': 'data:image/png;base64,line' }, + toolSnapshotUrls: {}, + toolSnapshotLineArtUrls: {}, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(installationNumberingSnapshotBatchUsesPublicAssets(result.batch)).toBe(false) + }) + + it('restores a complete public batch for the current model and current item IDs', () => { + const restored = restoreInstallationNumberingSnapshotBatchForShare({ + modelUrl: '/api/storage/object?key=models%2Fcabinet.glb&token=current', + partIds: ['part-1'], + toolIds: ['tool-1'], + snapshot: { + modelUrl: '/api/storage/object?key=models%2Fcabinet.glb&token=saved', + modelIdentity: 'storage:models/cabinet.glb', + snapshotUrls: { 'part-1': '/part.png', stale: '/stale.png' }, + snapshotLineArtUrls: { 'part-1': '/part-line.png', stale: '/stale-line.png' }, + toolSnapshotUrls: { 'tool-1': '/tool.png' }, + toolSnapshotLineArtUrls: { 'tool-1': '/tool-line.png' }, + savedAt: '2026-08-15T12:00:00.000Z', + }, + }) + + expect(restored).toMatchObject({ + modelUrl: '/api/storage/object?key=models%2Fcabinet.glb&token=current', + snapshotUrls: { 'part-1': '/part.png' }, + snapshotLineArtUrls: { 'part-1': '/part-line.png' }, + savedAt: '2026-08-15T12:00:00.000Z', + }) + }) + + it('rejects a server batch that is incomplete or still contains local data URLs', () => { + const baseSnapshot = { + modelUrl: '/model.obj', + snapshotUrls: { 'part-1': '/part.png' }, + snapshotLineArtUrls: { 'part-1': '/part-line.png' }, + toolSnapshotUrls: {}, + toolSnapshotLineArtUrls: {}, + savedAt: '2026-08-15T12:00:00.000Z', + } + + expect(restoreInstallationNumberingSnapshotBatchForShare({ + modelUrl: '/model.obj', + partIds: ['part-1', 'part-2'], + toolIds: [], + snapshot: baseSnapshot, + })).toBeNull() + expect(restoreInstallationNumberingSnapshotBatchForShare({ + modelUrl: '/model.obj', + partIds: ['part-1'], + toolIds: [], + snapshot: { + ...baseSnapshot, + snapshotUrls: { 'part-1': 'data:image/png;base64,original' }, + }, + })).toBeNull() + }) +}) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotBatchBuilder.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotBatchBuilder.ts new file mode 100644 index 00000000..e19869b2 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotBatchBuilder.ts @@ -0,0 +1,110 @@ +import type { InstallationNumberingSnapshotBatch } from './installationNumberingSnapshotStorage' +import { + installationNumberingSnapshotMatchesModel, + resolveInstallationNumberingSnapshotModelIdentity, +} from './installationNumberingSnapshotModel' + +interface BuildInstallationNumberingSnapshotBatchInput { + modelUrl: string + partIds: string[] + toolIds: string[] + snapshotUrls: Record + snapshotLineArtUrls: Record + toolSnapshotUrls: Record + toolSnapshotLineArtUrls: Record +} + +interface RestoreInstallationNumberingSnapshotBatchInput { + modelUrl: string + partIds: string[] + toolIds: string[] + snapshot: InstallationNumberingSnapshotBatch +} + +export type BuildInstallationNumberingSnapshotBatchResult = + | { ok: true; batch: InstallationNumberingSnapshotBatch } + | { ok: false; message: string } + +function selectUrls(ids: string[], urls: Record) { + return Object.fromEntries(ids.flatMap((id) => urls[id] ? [[id, urls[id]]] : [])) +} + +export function buildInstallationNumberingSnapshotBatch({ + modelUrl, + partIds, + toolIds, + snapshotUrls, + snapshotLineArtUrls, + toolSnapshotUrls, + toolSnapshotLineArtUrls, +}: BuildInstallationNumberingSnapshotBatchInput): BuildInstallationNumberingSnapshotBatchResult { + if (partIds.length + toolIds.length === 0) { + return { ok: false, message: '当前没有可保存的快照' } + } + + const selectedSnapshotUrls = selectUrls(partIds, snapshotUrls) + if (Object.keys(selectedSnapshotUrls).length !== partIds.length) { + return { ok: false, message: '部件快照尚未生成完整,请补齐后保存' } + } + const selectedSnapshotLineArtUrls = selectUrls(partIds, snapshotLineArtUrls) + if (Object.keys(selectedSnapshotLineArtUrls).length !== partIds.length) { + return { ok: false, message: '部件线稿尚未生成完整,请重新截取缺失部件' } + } + const selectedToolSnapshotUrls = selectUrls(toolIds, toolSnapshotUrls) + if (Object.keys(selectedToolSnapshotUrls).length !== toolIds.length) { + return { ok: false, message: '工具快照尚未生成完整,请补齐后保存' } + } + const selectedToolSnapshotLineArtUrls = selectUrls(toolIds, toolSnapshotLineArtUrls) + if (Object.keys(selectedToolSnapshotLineArtUrls).length !== toolIds.length) { + return { ok: false, message: '工具线稿尚未生成完整,请重新截取缺失工具' } + } + + return { + ok: true, + batch: { + modelUrl, + modelIdentity: resolveInstallationNumberingSnapshotModelIdentity(modelUrl), + snapshotUrls: selectedSnapshotUrls, + snapshotLineArtUrls: selectedSnapshotLineArtUrls, + toolSnapshotUrls: selectedToolSnapshotUrls, + toolSnapshotLineArtUrls: selectedToolSnapshotLineArtUrls, + savedAt: new Date().toISOString(), + }, + } +} + +export function installationNumberingSnapshotBatchUsesPublicAssets( + batch: InstallationNumberingSnapshotBatch, +) { + return [ + ...Object.values(batch.snapshotUrls), + ...Object.values(batch.snapshotLineArtUrls ?? {}), + ...Object.values(batch.toolSnapshotUrls ?? {}), + ...Object.values(batch.toolSnapshotLineArtUrls ?? {}), + ].every((url) => !url.startsWith('data:')) +} + +export function restoreInstallationNumberingSnapshotBatchForShare({ + modelUrl, + partIds, + toolIds, + snapshot, +}: RestoreInstallationNumberingSnapshotBatchInput): InstallationNumberingSnapshotBatch | null { + if (!installationNumberingSnapshotMatchesModel(snapshot, modelUrl)) return null + + const result = buildInstallationNumberingSnapshotBatch({ + modelUrl, + partIds, + toolIds, + snapshotUrls: snapshot.snapshotUrls, + snapshotLineArtUrls: snapshot.snapshotLineArtUrls ?? {}, + toolSnapshotUrls: snapshot.toolSnapshotUrls ?? {}, + toolSnapshotLineArtUrls: snapshot.toolSnapshotLineArtUrls ?? {}, + }) + if (!result.ok || !installationNumberingSnapshotBatchUsesPublicAssets(result.batch)) return null + + return { + ...result.batch, + savedAt: snapshot.savedAt, + } +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotStorage.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotStorage.ts index b922df64..cecb4a92 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotStorage.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationNumberingSnapshotStorage.ts @@ -11,7 +11,9 @@ export interface InstallationNumberingSnapshotDraft { modelUrl: string modelIdentity?: string snapshotUrls: SnapshotMapByPartId + snapshotLineArtUrls?: SnapshotMapByPartId toolSnapshotUrls?: SnapshotMapByPartId + toolSnapshotLineArtUrls?: SnapshotMapByPartId } export interface InstallationNumberingSnapshotBatch extends InstallationNumberingSnapshotDraft { @@ -33,7 +35,9 @@ function isSnapshotDraft(value: unknown): value is InstallationNumberingSnapshot return ( typeof record.modelUrl === 'string' && isSnapshotMapByPartId(record.snapshotUrls) && - (record.toolSnapshotUrls === undefined || isSnapshotMapByPartId(record.toolSnapshotUrls)) + (record.snapshotLineArtUrls === undefined || isSnapshotMapByPartId(record.snapshotLineArtUrls)) && + (record.toolSnapshotUrls === undefined || isSnapshotMapByPartId(record.toolSnapshotUrls)) && + (record.toolSnapshotLineArtUrls === undefined || isSnapshotMapByPartId(record.toolSnapshotLineArtUrls)) ) } diff --git a/furniture-3d-system/src/lib/3d/engine.ts b/furniture-3d-system/src/lib/3d/engine.ts index d3023bc9..f830fd59 100644 --- a/furniture-3d-system/src/lib/3d/engine.ts +++ b/furniture-3d-system/src/lib/3d/engine.ts @@ -24,6 +24,7 @@ import { import { shouldApplyZUpToYUpRotation } from "./model-up-axis"; import { startRenderLoop, stopRenderLoop } from "./engine-render-loop"; import { getMeshWorldGeometryCenter } from "./mesh-geometry-center"; +import { withTechnicalLineCaptureScene } from "./technical-line-capture"; import { PLATFORM_VIEWPORT_CAMERA_FOCAL_LENGTH, PLATFORM_VIEWPORT_CAMERA_POSITION, @@ -110,6 +111,9 @@ export interface EngineCaptureOptions { cropPaddingRatio?: number; cropMinPaddingPx?: number; cropAlphaThreshold?: number; + renderMode?: "default" | "technical-line"; + technicalLineStyle?: "clean" | "detail"; + renderCamera?: THREE.Camera; } export interface EngineNodeThumbnailCaptureOptions extends EngineCaptureOptions { @@ -1667,6 +1671,36 @@ export class ThreeDEngine { } capturePngDataUrl(options: EngineCaptureOptions = {}): string { + if (options.renderMode === "technical-line") { + const roots = [ + ...(this.modelRoot ? [this.modelRoot] : []), + ...Array.from(this.externalRaycastRoots), + ]; + const drawingBufferSize = this.renderer.getDrawingBufferSize(new THREE.Vector2()); + return withTechnicalLineCaptureScene( + roots, + (renderCamera) => + this.capturePngDataUrl({ + ...options, + renderMode: "default", + clearColor: 0xffffff, + clearAlpha: 1, + sceneGridVisible: false, + sceneBackground: 0xffffff, + captureProfile: "default", + toneMappingExposure: 1, + hideHelpers: true, + cropTransparentBounds: false, + renderCamera, + }), + { + sourceCamera: this.camera, + viewportWidth: drawingBufferSize.x, + viewportHeight: drawingBufferSize.y, + style: options.technicalLineStyle ?? "detail", + }, + ); + } const shouldOverrideClear = options.clearColor !== undefined || options.clearAlpha !== undefined; const shouldOverrideGrid = options.sceneGridVisible !== undefined; @@ -1756,7 +1790,7 @@ export class ThreeDEngine { this.directionalLight?.updateMatrixWorld(); } - this.renderer.render(this.scene, this.camera); + this.renderer.render(this.scene, options.renderCamera ?? this.camera); if (options.cropTransparentBounds) { return this.captureTrimmedRendererDataUrl({ paddingRatio: options.cropPaddingRatio, diff --git a/furniture-3d-system/src/lib/3d/technical-line-camera.ts b/furniture-3d-system/src/lib/3d/technical-line-camera.ts new file mode 100644 index 00000000..63247213 --- /dev/null +++ b/furniture-3d-system/src/lib/3d/technical-line-camera.ts @@ -0,0 +1,83 @@ +import * as THREE from 'three' + +function expandWorldBounds(box: THREE.Box3, mesh: THREE.Mesh) { + if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox() + const localBox = mesh.geometry.boundingBox + if (!localBox || localBox.isEmpty()) return + const worldBox = localBox.clone().applyMatrix4(mesh.matrixWorld) + box.union(worldBox) +} + +function getBoxCorners(box: THREE.Box3) { + return [ + new THREE.Vector3(box.min.x, box.min.y, box.min.z), + new THREE.Vector3(box.min.x, box.min.y, box.max.z), + new THREE.Vector3(box.min.x, box.max.y, box.min.z), + new THREE.Vector3(box.min.x, box.max.y, box.max.z), + new THREE.Vector3(box.max.x, box.min.y, box.min.z), + new THREE.Vector3(box.max.x, box.min.y, box.max.z), + new THREE.Vector3(box.max.x, box.max.y, box.min.z), + new THREE.Vector3(box.max.x, box.max.y, box.max.z), + ] +} + +export function createTechnicalLineOrthographicCamera({ + meshes, + sourceCamera, + viewportWidth, + viewportHeight, + padding = 1.08, +}: { + meshes: THREE.Mesh[] + sourceCamera: THREE.Camera + viewportWidth: number + viewportHeight: number + padding?: number +}) { + const bounds = new THREE.Box3() + meshes.forEach((mesh) => expandWorldBounds(bounds, mesh)) + if (bounds.isEmpty()) { + throw new Error('当前场景没有可用于生成线稿的模型几何。') + } + + sourceCamera.updateMatrixWorld(true) + const center = bounds.getCenter(new THREE.Vector3()) + const direction = sourceCamera.getWorldDirection(new THREE.Vector3()).normalize() + const right = new THREE.Vector3(1, 0, 0).applyQuaternion(sourceCamera.quaternion).normalize() + const up = new THREE.Vector3(0, 1, 0).applyQuaternion(sourceCamera.quaternion).normalize() + const projected = getBoxCorners(bounds).map((point) => { + const offset = point.clone().sub(center) + return new THREE.Vector3(offset.dot(right), offset.dot(up), offset.dot(direction)) + }) + const minX = Math.min(...projected.map((point) => point.x)) + const maxX = Math.max(...projected.map((point) => point.x)) + const minY = Math.min(...projected.map((point) => point.y)) + const maxY = Math.max(...projected.map((point) => point.y)) + const minZ = Math.min(...projected.map((point) => point.z)) + const maxZ = Math.max(...projected.map((point) => point.z)) + const aspect = Math.max(viewportWidth, 1) / Math.max(viewportHeight, 1) + let halfWidth = Math.max((maxX - minX) * 0.5 * padding, 0.01) + let halfHeight = Math.max((maxY - minY) * 0.5 * padding, 0.01) + if (halfWidth / halfHeight < aspect) { + halfWidth = halfHeight * aspect + } else { + halfHeight = halfWidth / aspect + } + + const depthSpan = Math.max(maxZ - minZ, bounds.getSize(new THREE.Vector3()).length(), 1) + const distance = depthSpan * 2 + const depthMargin = depthSpan * 0.5 + const camera = new THREE.OrthographicCamera( + -halfWidth, + halfWidth, + halfHeight, + -halfHeight, + Math.max(0.01, distance + minZ - depthMargin), + distance + maxZ + depthMargin, + ) + camera.position.copy(center).addScaledVector(direction, -distance) + camera.quaternion.copy(sourceCamera.quaternion) + camera.updateProjectionMatrix() + camera.updateMatrixWorld(true) + return camera +} diff --git a/furniture-3d-system/src/lib/3d/technical-line-capture.test.ts b/furniture-3d-system/src/lib/3d/technical-line-capture.test.ts new file mode 100644 index 00000000..6ae0e0f7 --- /dev/null +++ b/furniture-3d-system/src/lib/3d/technical-line-capture.test.ts @@ -0,0 +1,66 @@ +import * as THREE from 'three' +import { withTechnicalLineCaptureScene } from './technical-line-capture' + +function createSceneMesh() { + const scene = new THREE.Scene() + const root = new THREE.Group() + const originalMaterial = new THREE.MeshStandardMaterial({ color: 0x668899 }) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(2, 1, 1), originalMaterial) + root.add(mesh) + scene.add(root) + return { scene, root, mesh, originalMaterial } +} + +function createSourceCamera() { + const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100) + camera.position.set(4, 3, 6) + camera.lookAt(0, 0, 0) + camera.updateProjectionMatrix() + camera.updateMatrixWorld(true) + return camera +} + +const viewport = { viewportWidth: 800, viewportHeight: 800 } + +describe('withTechnicalLineCaptureScene', () => { + test('uses one world-space CAD line set and an orthographic camera during capture', () => { + const { scene, root, mesh, originalMaterial } = createSceneMesh() + const result = withTechnicalLineCaptureScene([root], (camera) => { + expect(camera).toBeInstanceOf(THREE.OrthographicCamera) + expect(mesh.material).toBeInstanceOf(THREE.MeshBasicMaterial) + expect((mesh.material as THREE.MeshBasicMaterial).color.getHex()).toBe(0xffffff) + const edge = scene.children.find((child) => child.name === '__technical_line_capture_edge__') + expect(edge).toBeInstanceOf(THREE.LineSegments) + expect((edge as THREE.LineSegments).material).toMatchObject({ depthTest: true }) + return 'captured' + }, { + sourceCamera: createSourceCamera(), + ...viewport, + style: 'clean', + }) + + expect(result).toBe('captured') + expect(mesh.material).toBe(originalMaterial) + expect(scene.children.some((child) => child.name === '__technical_line_capture_edge__')).toBe(false) + }) + + test('restores the scene when capture throws', () => { + const { scene, root, mesh, originalMaterial } = createSceneMesh() + expect(() => withTechnicalLineCaptureScene([root], () => { + throw new Error('capture failed') + }, { + sourceCamera: createSourceCamera(), + ...viewport, + })).toThrow('capture failed') + + expect(mesh.material).toBe(originalMaterial) + expect(scene.children.some((child) => child.name === '__technical_line_capture_edge__')).toBe(false) + }) + + test('rejects scenes without usable visible geometry', () => { + expect(() => withTechnicalLineCaptureScene([new THREE.Group()], () => 'unused', { + sourceCamera: createSourceCamera(), + ...viewport, + })).toThrow('当前场景没有可用于生成线稿的模型几何。') + }) +}) diff --git a/furniture-3d-system/src/lib/3d/technical-line-capture.ts b/furniture-3d-system/src/lib/3d/technical-line-capture.ts new file mode 100644 index 00000000..198208e8 --- /dev/null +++ b/furniture-3d-system/src/lib/3d/technical-line-capture.ts @@ -0,0 +1,137 @@ +import * as THREE from 'three' +import { isHighlightHelperObjectName } from './engine-highlight-resources' +import { createTechnicalLineOrthographicCamera } from './technical-line-camera' +import { buildMergedTechnicalLineGeometry } from './technical-line-geometry' + +const TECHNICAL_LINE_EDGE_NAME = '__technical_line_capture_edge__' + +export type TechnicalLineCaptureStyle = 'clean' | 'detail' + +export interface TechnicalLineCaptureSceneOptions { + sourceCamera: THREE.Camera + viewportWidth: number + viewportHeight: number + style?: TechnicalLineCaptureStyle +} + +interface MaterialSnapshot { + mesh: THREE.Mesh + material: THREE.Material | THREE.Material[] +} + +function isVisibleInScene(object: THREE.Object3D) { + let current: THREE.Object3D | null = object + while (current) { + if (!current.visible) return false + current = current.parent + } + return true +} + +function collectCaptureMeshes(roots: THREE.Object3D[]) { + const meshes = new Map() + roots.forEach((root) => { + root.traverse((child) => { + if (!(child as THREE.Mesh).isMesh) return + if (isHighlightHelperObjectName(child.name)) return + if (child.userData?.__modelViewerCaptureHelper === true) return + if (!isVisibleInScene(child)) return + const mesh = child as THREE.Mesh + if (!(mesh.geometry instanceof THREE.BufferGeometry)) return + meshes.set(mesh.uuid, mesh) + }) + }) + return Array.from(meshes.values()) +} + +function findLineParent(roots: THREE.Object3D[]) { + let current: THREE.Object3D | null = roots[0] ?? null + while (current?.parent) current = current.parent + return current +} + +function createOcclusionMaterial() { + return new THREE.MeshBasicMaterial({ + color: 0xffffff, + side: THREE.DoubleSide, + depthTest: true, + depthWrite: true, + polygonOffset: true, + polygonOffsetFactor: 1, + polygonOffsetUnits: 1, + toneMapped: false, + }) +} + +export function withTechnicalLineCaptureScene( + roots: THREE.Object3D[], + capture: (camera: THREE.Camera) => T, + options: TechnicalLineCaptureSceneOptions, +): T { + if (options.viewportWidth <= 0 || options.viewportHeight <= 0) { + throw new Error('线稿生成缺少有效的画布尺寸。') + } + roots.forEach((root) => root.updateMatrixWorld(true)) + const meshes = collectCaptureMeshes(roots) + if (meshes.length === 0) { + throw new Error('当前场景没有可用于生成线稿的模型几何。') + } + const lineParent = findLineParent(roots) + if (!lineParent) { + throw new Error('当前场景没有可用于承载线稿的场景根节点。') + } + const camera = createTechnicalLineOrthographicCamera({ + meshes, + sourceCamera: options.sourceCamera, + viewportWidth: options.viewportWidth, + viewportHeight: options.viewportHeight, + }) + const geometry = buildMergedTechnicalLineGeometry({ + meshes, + camera, + viewportWidth: options.viewportWidth, + viewportHeight: options.viewportHeight, + style: options.style ?? 'detail', + }) + const positions = geometry.getAttribute('position') + if (!positions || positions.count === 0) { + geometry.dispose() + throw new Error('当前模型没有可见的结构轮廓可用于生成线稿。') + } + const lineMaterial = new THREE.LineBasicMaterial({ + color: 0x15191a, + depthTest: true, + depthWrite: false, + toneMapped: false, + }) + const lines = new THREE.LineSegments(geometry, lineMaterial) + lines.name = TECHNICAL_LINE_EDGE_NAME + lines.renderOrder = 2 + lines.frustumCulled = false + lines.matrixAutoUpdate = false + lines.matrix.copy(lineParent.matrixWorld).invert() + const materialSnapshots: MaterialSnapshot[] = [] + + try { + meshes.forEach((mesh) => { + materialSnapshots.push({ mesh, material: mesh.material }) + mesh.material = createOcclusionMaterial() + }) + lineParent.add(lines) + lineParent.updateMatrixWorld(true) + return capture(camera) + } finally { + lineParent.remove(lines) + geometry.dispose() + lineMaterial.dispose() + materialSnapshots.forEach(({ mesh, material }) => { + if (Array.isArray(mesh.material)) { + mesh.material.forEach((item) => item.dispose()) + } else { + mesh.material.dispose() + } + mesh.material = material + }) + roots.forEach((root) => root.updateMatrixWorld(true)) + } +} diff --git a/furniture-3d-system/src/lib/3d/technical-line-geometry.test.ts b/furniture-3d-system/src/lib/3d/technical-line-geometry.test.ts new file mode 100644 index 00000000..268aacdc --- /dev/null +++ b/furniture-3d-system/src/lib/3d/technical-line-geometry.test.ts @@ -0,0 +1,60 @@ +import * as THREE from 'three' +import { buildMergedTechnicalLineGeometry } from './technical-line-geometry' + +function createFrontCamera() { + const camera = new THREE.OrthographicCamera(-2, 2, 2, -2, 0.1, 20) + camera.position.set(0, 0, 10) + camera.lookAt(0, 0, 0) + camera.updateProjectionMatrix() + camera.updateMatrixWorld(true) + return camera +} + +describe('buildMergedTechnicalLineGeometry', () => { + test('removes the coplanar diagonal between two triangles', () => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([ + -1, -1, 0, + 1, -1, 0, + 1, 1, 0, + -1, -1, 0, + 1, 1, 0, + -1, 1, 0, + ], 3)) + const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial()) + mesh.updateMatrixWorld(true) + + const lines = buildMergedTechnicalLineGeometry({ + meshes: [mesh], + camera: createFrontCamera(), + viewportWidth: 800, + viewportHeight: 800, + style: 'clean', + }) + + expect(lines.getAttribute('position').count).toBe(8) + }) + + test('deduplicates identical structural edges across overlapping meshes', () => { + const first = new THREE.Mesh(new THREE.BoxGeometry(2, 1, 1), new THREE.MeshBasicMaterial()) + const second = new THREE.Mesh(new THREE.BoxGeometry(2, 1, 1), new THREE.MeshBasicMaterial()) + first.updateMatrixWorld(true) + second.updateMatrixWorld(true) + const single = buildMergedTechnicalLineGeometry({ + meshes: [first], + camera: createFrontCamera(), + viewportWidth: 800, + viewportHeight: 800, + style: 'detail', + }) + const duplicate = buildMergedTechnicalLineGeometry({ + meshes: [first, second], + camera: createFrontCamera(), + viewportWidth: 800, + viewportHeight: 800, + style: 'detail', + }) + + expect(duplicate.getAttribute('position').count).toBe(single.getAttribute('position').count) + }) +}) diff --git a/furniture-3d-system/src/lib/3d/technical-line-geometry.ts b/furniture-3d-system/src/lib/3d/technical-line-geometry.ts new file mode 100644 index 00000000..e2bb8cbb --- /dev/null +++ b/furniture-3d-system/src/lib/3d/technical-line-geometry.ts @@ -0,0 +1,233 @@ +import * as THREE from 'three' +import type { TechnicalLineCaptureStyle } from './technical-line-capture' + +interface WorldSegment { + start: THREE.Vector3 + end: THREE.Vector3 +} + +interface EdgeFaceRecord { + start: THREE.Vector3 + end: THREE.Vector3 + normals: THREE.Vector3[] +} + +const TECHNICAL_LINE_PROFILES: Record = { + clean: { + creaseAngle: 42, + minBoundaryLengthPx: 8, + minFeatureLengthPx: 2.4, + }, + detail: { + creaseAngle: 18, + minBoundaryLengthPx: 0.8, + minFeatureLengthPx: 0.8, + }, +} + +function quantize(value: number, tolerance: number) { + return Math.round(value / tolerance) +} + +function pointKey(point: THREE.Vector3, tolerance: number) { + return [ + quantize(point.x, tolerance), + quantize(point.y, tolerance), + quantize(point.z, tolerance), + ].join(':') +} + +function canonicalEdgeKey(start: THREE.Vector3, end: THREE.Vector3, tolerance: number) { + const startKey = pointKey(start, tolerance) + const endKey = pointKey(end, tolerance) + return startKey < endKey ? `${startKey}|${endKey}` : `${endKey}|${startKey}` +} + +function projectWorldPointToPixels( + point: THREE.Vector3, + camera: THREE.Camera, + viewportWidth: number, + viewportHeight: number, +) { + const projected = point.clone().project(camera) + return new THREE.Vector2( + (projected.x + 1) * viewportWidth * 0.5, + (1 - projected.y) * viewportHeight * 0.5, + ) +} + +function getPositionAt( + position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, + index: number, +) { + return new THREE.Vector3().fromBufferAttribute(position, index) +} + +function collectMeshFeatureSegments( + mesh: THREE.Mesh, + camera: THREE.Camera, + viewportWidth: number, + viewportHeight: number, + style: TechnicalLineCaptureStyle, +) { + const position = mesh.geometry.getAttribute('position') + if (!position || position.count < 3) return [] + if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox() + const localSize = mesh.geometry.boundingBox?.getSize(new THREE.Vector3()).length() ?? 1 + const weldTolerance = Math.max(localSize * 1e-6, 1e-7) + const edges = new Map() + const index = mesh.geometry.getIndex() + const triangleCount = index ? Math.floor(index.count / 3) : Math.floor(position.count / 3) + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { + const indices = index + ? [index.getX(triangleIndex * 3), index.getX(triangleIndex * 3 + 1), index.getX(triangleIndex * 3 + 2)] + : [triangleIndex * 3, triangleIndex * 3 + 1, triangleIndex * 3 + 2] + const points = indices.map((vertexIndex) => + getPositionAt(position, vertexIndex).applyMatrix4(mesh.matrixWorld), + ) + const normal = points[1].clone().sub(points[0]) + .cross(points[2].clone().sub(points[0])) + if (normal.lengthSq() <= 1e-12) continue + normal.normalize() + + ;[[0, 1], [1, 2], [2, 0]].forEach(([startIndex, endIndex]) => { + const start = points[startIndex] + const end = points[endIndex] + const key = canonicalEdgeKey(start, end, weldTolerance) + const existing = edges.get(key) + if (existing) { + existing.normals.push(normal) + } else { + edges.set(key, { start: start.clone(), end: end.clone(), normals: [normal] }) + } + }) + } + + const profile = TECHNICAL_LINE_PROFILES[style] + const viewDirection = camera.getWorldDirection(new THREE.Vector3()).normalize() + const creaseCos = Math.cos(THREE.MathUtils.degToRad(profile.creaseAngle)) + return Array.from(edges.values()).flatMap((edge) => { + const boundary = edge.normals.length === 1 + let silhouette = false + let feature = false + for (let leftIndex = 0; leftIndex < edge.normals.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < edge.normals.length; rightIndex += 1) { + const left = edge.normals[leftIndex] + const right = edge.normals[rightIndex] + if (left.dot(viewDirection) * right.dot(viewDirection) < -1e-6) silhouette = true + if (left.dot(right) < creaseCos) feature = true + } + } + if (!boundary && !silhouette && !feature) return [] + const projectedLength = projectWorldPointToPixels( + edge.start, + camera, + viewportWidth, + viewportHeight, + ).distanceTo(projectWorldPointToPixels(edge.end, camera, viewportWidth, viewportHeight)) + const minimumLength = boundary && !silhouette + ? profile.minBoundaryLengthPx + : profile.minFeatureLengthPx + if (projectedLength < minimumLength) return [] + return [{ start: edge.start, end: edge.end }] + }) +} + +function canonicalDirection(direction: THREE.Vector3) { + const normalized = direction.clone().normalize() + const shouldFlip = normalized.x < -1e-8 + || (Math.abs(normalized.x) <= 1e-8 && normalized.y < -1e-8) + || (Math.abs(normalized.x) <= 1e-8 && Math.abs(normalized.y) <= 1e-8 && normalized.z < 0) + return shouldFlip ? normalized.multiplyScalar(-1) : normalized +} + +function mergeCollinearSegments(segments: WorldSegment[], tolerance: number) { + const buckets = new Map }>() + segments.forEach((segment) => { + const offset = segment.end.clone().sub(segment.start) + if (offset.lengthSq() <= tolerance * tolerance) return + const direction = canonicalDirection(offset) + const anchor = segment.start.clone().addScaledVector(direction, -segment.start.dot(direction)) + const directionTolerance = 1e-4 + const key = [ + quantize(direction.x, directionTolerance), + quantize(direction.y, directionTolerance), + quantize(direction.z, directionTolerance), + quantize(anchor.x, tolerance), + quantize(anchor.y, tolerance), + quantize(anchor.z, tolerance), + ].join(':') + const start = segment.start.dot(direction) + const end = segment.end.dot(direction) + const interval: [number, number] = start <= end ? [start, end] : [end, start] + const bucket = buckets.get(key) + if (bucket) { + bucket.intervals.push(interval) + } else { + buckets.set(key, { direction, anchor, intervals: [interval] }) + } + }) + + return Array.from(buckets.values()).flatMap(({ direction, anchor, intervals }) => { + intervals.sort((left, right) => left[0] - right[0]) + const merged: Array<[number, number]> = [] + intervals.forEach((interval) => { + const current = merged[merged.length - 1] + if (current && interval[0] <= current[1] + tolerance * 4) { + current[1] = Math.max(current[1], interval[1]) + } else { + merged.push([...interval]) + } + }) + return merged.map(([start, end]) => ({ + start: anchor.clone().addScaledVector(direction, start), + end: anchor.clone().addScaledVector(direction, end), + })) + }) +} + +export function buildMergedTechnicalLineGeometry({ + meshes, + camera, + viewportWidth, + viewportHeight, + style, +}: { + meshes: THREE.Mesh[] + camera: THREE.Camera + viewportWidth: number + viewportHeight: number + style: TechnicalLineCaptureStyle +}) { + const worldBounds = new THREE.Box3() + meshes.forEach((mesh) => { + if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox() + if (mesh.geometry.boundingBox) { + worldBounds.union(mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld)) + } + }) + const worldTolerance = Math.max(worldBounds.getSize(new THREE.Vector3()).length() * 1e-5, 1e-6) + const segments = meshes.flatMap((mesh) => collectMeshFeatureSegments( + mesh, + camera, + viewportWidth, + viewportHeight, + style, + )) + const uniqueSegments = Array.from(new Map( + segments.map((segment) => [canonicalEdgeKey(segment.start, segment.end, worldTolerance), segment]), + ).values()) + const merged = mergeCollinearSegments(uniqueSegments, worldTolerance) + const positions = merged.flatMap((segment) => [ + segment.start.x, segment.start.y, segment.start.z, + segment.end.x, segment.end.y, segment.end.z, + ]) + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + return geometry +} diff --git a/furniture-3d-system/src/lib/installation-demo-share-types.ts b/furniture-3d-system/src/lib/installation-demo-share-types.ts index 6472ccd7..95e4be78 100644 --- a/furniture-3d-system/src/lib/installation-demo-share-types.ts +++ b/furniture-3d-system/src/lib/installation-demo-share-types.ts @@ -1,5 +1,10 @@ -import type { CameraViewSnapshot } from '@/lib/3d/camera-controller' -import type { InstallationStepMaterialShareSnapshot } from '@/app/installation-demo-workbench/lib/installationStepMaterialShareSnapshots' +import type { CameraViewSnapshot } from "@/lib/3d/camera-controller"; +import type { InstallationStepMaterialShareSnapshot } from "@/app/installation-demo-workbench/lib/installationStepMaterialShareSnapshots"; +import type { + InstallationDrawingCaptures, + InstallationDrawingImageMode, +} from "@/app/installation-demo-workbench/lib/installationDrawingCaptureTypes"; +import type { InstallationDrawingTemplateId } from "@/app/installation-demo-workbench/lib/installationDrawingTemplates"; import type { InstallationDetailView, InstallationPartItem, @@ -7,72 +12,85 @@ import type { InstallationToolItem, InstallationWorkbenchMode, InstallationUploadedModel, -} from '@/app/installation-demo-workbench/types' +} from "@/app/installation-demo-workbench/types"; export interface InstallationDemoShareDrawingSnapshot { - id: string - code: string - modelCode: string - spuCode: string + id: string; + code: string; + modelCode: string; + spuCode: string; } export interface InstallationDemoShareInitialViewState { - detailView: InstallationDetailView - mode: InstallationWorkbenchMode - activeStepId: string | null - numberingRevealCount: number - selectedNumberingPartId: string | null - cameraView: CameraViewSnapshot | null - playing: boolean + detailView: InstallationDetailView; + mode: InstallationWorkbenchMode; + activeStepId: string | null; + numberingRevealCount: number; + selectedNumberingPartId: string | null; + cameraView: CameraViewSnapshot | null; + playing: boolean; +} + +export interface InstallationDemoShareDrawingDocument { + templateId: InstallationDrawingTemplateId; + imageMode: InstallationDrawingImageMode; + captures: InstallationDrawingCaptures; + snapshotLineArtUrls: Record; + toolSnapshotLineArtUrls: Record; } export interface InstallationDemoShareSnapshot { - version: 1 - token: string - drawing: InstallationDemoShareDrawingSnapshot - modelUrl: string - uploadedModel: InstallationUploadedModel | null - parts: InstallationPartItem[] - numberingParts: InstallationPartItem[] - drawingTools: InstallationToolItem[] - numberingView: CameraViewSnapshot | null - numberingSnapshotUrls: Record - numberingToolSnapshotUrls: Record - stepMaterialsByStepId: Record - steps: InstallationStepDraft[] - initialView: InstallationDemoShareInitialViewState | null - createdAt: string - expiresAt: string | null + version: 1; + token: string; + drawing: InstallationDemoShareDrawingSnapshot; + modelUrl: string; + uploadedModel: InstallationUploadedModel | null; + parts: InstallationPartItem[]; + numberingParts: InstallationPartItem[]; + drawingTools: InstallationToolItem[]; + numberingView: CameraViewSnapshot | null; + numberingSnapshotUrls: Record; + numberingToolSnapshotUrls: Record; + stepMaterialsByStepId: Record; + steps: InstallationStepDraft[]; + drawingDocument: InstallationDemoShareDrawingDocument | null; + initialView: InstallationDemoShareInitialViewState | null; + createdAt: string; + expiresAt: string | null; createdBy: { - id: string - name: string - } + id: string; + name: string; + }; } export interface CreateInstallationDemoShareInput { - drawing: InstallationDemoShareDrawingSnapshot - modelUrl: string - uploadedModel?: InstallationUploadedModel | null - parts: InstallationPartItem[] - numberingParts: InstallationPartItem[] - drawingTools?: InstallationToolItem[] - numberingView?: CameraViewSnapshot | null - numberingSnapshotUrls?: Record | null - numberingToolSnapshotUrls?: Record | null - stepMaterialsByStepId?: Record | null - steps: InstallationStepDraft[] - initialView?: InstallationDemoShareInitialViewState | null + drawing: InstallationDemoShareDrawingSnapshot; + modelUrl: string; + uploadedModel?: InstallationUploadedModel | null; + parts: InstallationPartItem[]; + numberingParts: InstallationPartItem[]; + drawingTools?: InstallationToolItem[]; + numberingView?: CameraViewSnapshot | null; + numberingSnapshotUrls?: Record | null; + numberingToolSnapshotUrls?: Record | null; + stepMaterialsByStepId?: Record< + string, + InstallationStepMaterialShareSnapshot + > | null; + steps: InstallationStepDraft[]; + drawingDocument?: InstallationDemoShareDrawingDocument | null; + initialView?: InstallationDemoShareInitialViewState | null; } export interface InstallationDemoShareLinkDTO { - token: string - url: string - expiresAt: string - snapshot: InstallationDemoShareSnapshot + token: string; + url: string; + expiresAt: string; + snapshot: InstallationDemoShareSnapshot; } export interface InstallationDemoQrShareDTO { - token: string - url: string - snapshot: InstallationDemoShareSnapshot + token: string; + url: string; + snapshot: InstallationDemoShareSnapshot; } diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-marker-counts.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-marker-counts.ts new file mode 100644 index 00000000..b9232098 --- /dev/null +++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-marker-counts.ts @@ -0,0 +1,33 @@ +import { AppError, ErrorCode } from "@/server/core/errors"; + +const MAX_NUMBER_MARKER_COUNT = 240; + +export function normalizeCaptureNumberMarkerCountOverrides(value: unknown) { + if (value === undefined || value === null) return {}; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, "图纸编号数量设置无效"); + } + const entries = Object.entries(value); + if (entries.length > MAX_NUMBER_MARKER_COUNT) { + throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "图纸编号数量设置过多"); + } + return Object.fromEntries(entries.map(([partId, count]) => { + const normalizedPartId = partId.trim(); + const normalizedCount = Number(count); + if (!normalizedPartId || normalizedPartId.length > 160) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, "图纸编号部件 ID 无效"); + } + if (!Number.isInteger(normalizedCount) || normalizedCount < 0 || normalizedCount > MAX_NUMBER_MARKER_COUNT) { + throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "图纸编号数量必须是 0 到 240 的整数"); + } + return [normalizedPartId, normalizedCount]; + })); +} + +export function normalizeStoredCaptureNumberMarkerCountOverrides(value: unknown) { + try { + return normalizeCaptureNumberMarkerCountOverrides(value); + } catch { + return {}; + } +} diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-replacement.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-replacement.ts new file mode 100644 index 00000000..5aac1bb4 --- /dev/null +++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-replacement.ts @@ -0,0 +1,47 @@ +import type { + StoredCaptureBatch, + StoredCaptureImage, +} from "./installation-demo-drawing-capture.service"; + +interface ReconcileCaptureReplacementInput { + active: StoredCaptureBatch; + configFingerprint: string; + expectedTargetKeys: string[]; + targetKey: string; + image: StoredCaptureImage; + savedAt: string; +} + +export function reconcileCaptureReplacement({ + active, + configFingerprint, + expectedTargetKeys, + targetKey, + image, + savedAt, +}: ReconcileCaptureReplacementInput) { + const expected = new Set(expectedTargetKeys); + const retainedImages = Object.fromEntries( + Object.entries(active.images).filter( + ([key]) => expected.has(key) && key !== targetKey, + ), + ); + const images = { ...retainedImages, [targetKey]: image }; + const discardedImages = Object.entries(active.images) + .filter(([key]) => !expected.has(key) || key === targetKey) + .map(([, discarded]) => discarded); + const complete = expectedTargetKeys.every((key) => Boolean(images[key])); + + return { + batch: { + ...active, + configFingerprint: complete + ? configFingerprint + : active.configFingerprint, + images, + expectedTargetKeys, + savedAt, + }, + discardedImages, + }; +} diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-storage.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-storage.ts new file mode 100644 index 00000000..c20dc926 --- /dev/null +++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture-storage.ts @@ -0,0 +1,91 @@ +import { + deleteObjectWithResult, + uploadBufferToObjectStorageWithMetadata, +} from "@/lib/storage"; +import { AppError, ErrorCode } from "@/server/core/errors"; +import type { + ImageTransform, + CaptureNumberMarker, + StoredCaptureImage, +} from "./installation-demo-drawing-capture.service"; + +const MAX_IMAGE_SIZE_BYTES = 8 * 1024 * 1024; +const ALLOWED_CONTENT_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/webp", +]); + +export interface DrawingCaptureUpload { + bytes: Buffer; + contentType: string; +} + +export function validateDrawingCaptureUpload( + upload: DrawingCaptureUpload, + label: string, +) { + if ( + !ALLOWED_CONTENT_TYPES.has(upload.contentType) || + upload.bytes.length === 0 + ) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + `${label}必须是图片`, + ); + } + if (upload.bytes.length > MAX_IMAGE_SIZE_BYTES) { + throw new AppError(ErrorCode.FILE_TOO_LARGE, `${label}不能超过 8MB`); + } +} + +export async function cleanupDrawingCaptureStorageKeys(keys: string[]) { + await Promise.allSettled(keys.map((key) => deleteObjectWithResult(key))); +} + +export function drawingCaptureImageStorageKeys( + image: StoredCaptureImage | null | undefined, +) { + return image ? [image.originalStorageKey, image.lineArtStorageKey] : []; +} + +export async function uploadDrawingCaptureImagePair(input: { + baseKey: string; + original: DrawingCaptureUpload; + lineArt: DrawingCaptureUpload; + transform: ImageTransform; + numberingMarkers: CaptureNumberMarker[]; + numberingMarkerCountOverrides: Record; + captureAspectRatio: number | null; +}): Promise { + validateDrawingCaptureUpload(input.original, "原图"); + validateDrawingCaptureUpload(input.lineArt, "线稿"); + const uploadedKeys: string[] = []; + try { + const original = await uploadBufferToObjectStorageWithMetadata( + input.original.bytes, + `${input.baseKey}/original.png`, + input.original.contentType, + ); + uploadedKeys.push(original.storageKey); + const lineArt = await uploadBufferToObjectStorageWithMetadata( + input.lineArt.bytes, + `${input.baseKey}/line-art.png`, + input.lineArt.contentType, + ); + uploadedKeys.push(lineArt.storageKey); + return { + originalUrl: original.url, + originalStorageKey: original.storageKey, + lineArtUrl: lineArt.url, + lineArtStorageKey: lineArt.storageKey, + transform: input.transform, + numberingMarkers: input.numberingMarkers, + numberingMarkerCountOverrides: input.numberingMarkerCountOverrides, + captureAspectRatio: input.captureAspectRatio, + }; + } catch (error) { + await cleanupDrawingCaptureStorageKeys(uploadedKeys); + throw error; + } +} diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture.service.test.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture.service.test.ts new file mode 100644 index 00000000..160506d2 --- /dev/null +++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture.service.test.ts @@ -0,0 +1,393 @@ +jest.mock("server-only", () => ({})); + +jest.mock("@/lib/storage", () => ({ + deleteObjectWithResult: jest.fn(), + normalizeObjectStorageUrlForPersistence: jest.fn((url: string) => url), + uploadBufferToObjectStorageWithMetadata: jest.fn(), +})); + +jest.mock("../system-config.service", () => ({ + systemConfigService: { + get: jest.fn(), + set: jest.fn(), + }, +})); + +jest.mock("./installation-demo-access.service", () => ({ + installationDemoAccessService: { + getAccess: jest.fn(), + }, +})); + +import { uploadBufferToObjectStorageWithMetadata } from "@/lib/storage"; +import { systemConfigService } from "../system-config.service"; +import { installationDemoAccessService } from "./installation-demo-access.service"; +import { InstallationDemoDrawingCaptureService } from "./installation-demo-drawing-capture.service"; + +const user = { id: "user-1", email: "user@example.com" }; +const image = { bytes: Buffer.from("image"), contentType: "image/png" }; +const transform = { scale: 1, offsetX: 0, offsetY: 0 }; +const numberingMarkers = [ + { + id: "part-1:0", + partId: "part-1", + code: "1", + x: 0.25, + y: 0.5, + placed: true, + }, +]; +const numberingMarkerCountOverrides = { "part-1": 2 }; + +describe("InstallationDemoDrawingCaptureService", () => { + let stored: unknown; + let service: InstallationDemoDrawingCaptureService; + + beforeEach(() => { + jest.clearAllMocks(); + stored = null; + service = new InstallationDemoDrawingCaptureService(); + jest.mocked(installationDemoAccessService.getAccess).mockResolvedValue({ + canView: true, + canEdit: true, + canShare: true, + }); + jest + .mocked(systemConfigService.get) + .mockImplementation(async () => stored as never); + jest + .mocked(systemConfigService.set) + .mockImplementation(async (_key, value) => { + stored = value; + return value as never; + }); + jest + .mocked(uploadBufferToObjectStorageWithMetadata) + .mockImplementation(async (_bytes, key) => ({ + url: `/api/storage/object?key=${encodeURIComponent(key)}`, + storageKey: key, + })); + }); + + async function begin( + fingerprint = "config-1", + mode: "fill" | "replace" = "fill", + ) { + return service.begin(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + configFingerprint: fingerprint, + expectedTargetKeys: ["finished", "exploded"], + inventoryTransforms: {}, + mode, + }); + } + + async function upload(batchId: string, targetKey: string) { + return service.upload(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + batchId, + targetKey, + transform, + numberingMarkers, + numberingMarkerCountOverrides, + captureAspectRatio: 1.75, + original: image, + lineArt: image, + }); + } + + it("keeps captures in a draft until every expected image is present", async () => { + const started = await begin(); + const batchId = started?.draft?.batchId as string; + const partial = await upload(batchId, "finished"); + + expect(partial?.active).toBeNull(); + expect(partial?.draft?.completedCount).toBe(1); + expect(partial?.draft?.images.finished.numberingMarkers).toEqual( + numberingMarkers, + ); + expect( + partial?.draft?.images.finished.numberingMarkerCountOverrides, + ).toEqual(numberingMarkerCountOverrides); + expect(partial?.draft?.images.finished.captureAspectRatio).toBe(1.75); + await expect( + service.finalize(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + batchId, + }), + ).rejects.toThrow("还有 1 张画面未完成"); + + await upload(batchId, "exploded"); + const finalized = await service.finalize(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + batchId, + }); + expect(finalized?.draft).toBeNull(); + expect(finalized?.active?.completedCount).toBe(2); + expect(finalized?.active?.confirmedAt).toBeNull(); + expect(finalized?.active?.confirmedImageMode).toBeNull(); + }); + + it("does not replace the active batch while a new version is incomplete", async () => { + const first = await begin(); + const firstBatchId = first?.draft?.batchId as string; + await upload(firstBatchId, "finished"); + await upload(firstBatchId, "exploded"); + const active = await service.finalize(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + batchId: firstBatchId, + }); + + const replacement = await begin("config-2", "replace"); + expect(replacement?.active?.batchId).toBe(active?.active?.batchId); + expect(replacement?.draft?.completedCount).toBe(0); + await upload(replacement?.draft?.batchId as string, "finished"); + + const current = await service.get( + user as never, + "drawing-1", + "/api/storage/object?key=model.glb", + ); + expect(current?.active?.batchId).toBe(active?.active?.batchId); + expect(current?.draft?.completedCount).toBe(1); + }); + + it("replaces one active image and reconciles progress with the current template", async () => { + const first = await begin(); + const batchId = first?.draft?.batchId as string; + await upload(batchId, "finished"); + await upload(batchId, "exploded"); + const finalized = await service.finalize(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + batchId, + }); + const previousFinishedUrl = finalized?.active?.images.finished.originalUrl; + + const partial = await service.replace(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + configFingerprint: "config-2", + expectedTargetKeys: ["finished", "action:new"], + targetKey: "finished", + transform, + original: image, + lineArt: image, + }); + + expect(partial?.active?.batchId).toBe(batchId); + expect(partial?.active?.completedCount).toBe(1); + expect(partial?.active?.totalCount).toBe(2); + expect(partial?.active?.configFingerprint).toBe("config-1"); + expect(partial?.active?.images.exploded).toBeUndefined(); + expect(partial?.active?.images.finished.originalUrl).not.toBe( + previousFinishedUrl, + ); + + const complete = await service.replace(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + configFingerprint: "config-2", + expectedTargetKeys: ["finished", "action:new"], + targetKey: "action:new", + transform, + original: image, + lineArt: image, + }); + + expect(complete?.active?.completedCount).toBe(2); + expect(complete?.active?.configFingerprint).toBe("config-2"); + }); + + it("stores template numbering positions without replacing captured files", async () => { + const started = await begin(); + const batchId = started?.draft?.batchId as string; + await upload(batchId, "finished"); + await upload(batchId, "exploded"); + const finalized = await service.finalize(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + batchId, + }); + const originalUrl = finalized?.active?.images.exploded.originalUrl; + const adjustedMarkers = [ + { + id: "part-1:0", + partId: "part-1", + code: "1", + x: 0.62, + y: 0.38, + placed: true, + }, + ]; + + const adjusted = await service.updateLayout(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + targetKey: "exploded", + transform: { scale: 1.15, offsetX: 4, offsetY: -3 }, + numberingMarkers: adjustedMarkers, + numberingMarkerCountOverrides: { "part-1": 1 }, + }); + + expect(adjusted?.active?.images.exploded.originalUrl).toBe(originalUrl); + expect(adjusted?.active?.images.exploded.transform).toEqual({ + scale: 1.15, + offsetX: 4, + offsetY: -3, + }); + expect(adjusted?.active?.images.exploded.numberingMarkers).toEqual( + adjustedMarkers, + ); + expect( + adjusted?.active?.images.exploded.numberingMarkerCountOverrides, + ).toEqual({ + "part-1": 1, + }); + }); + + it("confirms a complete drawing and clears confirmation after layout changes", async () => { + const started = await begin(); + const batchId = started?.draft?.batchId as string; + await upload(batchId, "finished"); + await upload(batchId, "exploded"); + await service.finalize(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + batchId, + }); + + const confirmed = await service.confirm(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + configFingerprint: "config-1", + expectedTargetKeys: ["finished", "exploded"], + imageMode: "original", + }); + expect(confirmed?.active?.confirmedAt).toEqual(expect.any(String)); + expect(confirmed?.active?.confirmedImageMode).toBe("original"); + + const reconfirmed = await service.confirm(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + configFingerprint: "config-1", + expectedTargetKeys: ["finished", "exploded"], + imageMode: "line-art", + }); + expect(reconfirmed?.active?.confirmedImageMode).toBe("line-art"); + + const adjusted = await service.updateLayout(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + targetKey: "exploded", + transform, + numberingMarkers, + numberingMarkerCountOverrides, + }); + expect(adjusted?.active?.confirmedAt).toBeNull(); + expect(adjusted?.active?.confirmedImageMode).toBeNull(); + }); + + it("rejects confirmation while a numbering marker is still unplaced", async () => { + const started = await begin(); + const batchId = started?.draft?.batchId as string; + await service.upload(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + batchId, + targetKey: "finished", + transform, + numberingMarkers: [{ ...numberingMarkers[0], placed: false }], + numberingMarkerCountOverrides, + captureAspectRatio: 1.75, + original: image, + lineArt: image, + }); + await upload(batchId, "exploded"); + await service.finalize(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + batchId, + }); + + await expect( + service.confirm(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + configFingerprint: "config-1", + expectedTargetKeys: ["finished", "exploded"], + imageMode: "line-art", + }), + ).rejects.toThrow("还有 1 个编号待放置"); + }); + + it("normalizes legacy markers and missing count overrides", async () => { + const started = await begin(); + const batchId = started?.draft?.batchId as string; + await upload(batchId, "finished"); + await upload(batchId, "exploded"); + await service.finalize(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + batchId, + }); + + const legacy = stored as { + active: { + images: Record< + string, + { + numberingMarkers: Array>; + numberingMarkerCountOverrides?: unknown; + } + >; + }; + }; + legacy.active.images.exploded.numberingMarkers = [ + { id: "legacy-8", code: "8", x: 0.3, y: 0.4 }, + ]; + delete legacy.active.images.exploded.numberingMarkerCountOverrides; + + const loaded = await service.get( + user as never, + "drawing-1", + "/api/storage/object?key=model.glb", + ); + expect(loaded?.active?.images.exploded.numberingMarkers).toEqual([ + { id: "legacy-8", partId: null, code: "8", x: 0.3, y: 0.4, placed: true }, + ]); + expect( + loaded?.active?.images.exploded.numberingMarkerCountOverrides, + ).toEqual({}); + }); + + it("defaults a legacy confirmed drawing without an image mode to line art", async () => { + const started = await begin(); + const batchId = started?.draft?.batchId as string; + await upload(batchId, "finished"); + await upload(batchId, "exploded"); + await service.finalize(user as never, { + drawingId: "drawing-1", + modelUrl: "/api/storage/object?key=model.glb", + batchId, + }); + + const legacy = stored as { + active: { confirmedAt: string; confirmedImageMode?: unknown }; + }; + legacy.active.confirmedAt = "2026-08-15T00:00:00.000Z"; + delete legacy.active.confirmedImageMode; + + const loaded = await service.get( + user as never, + "drawing-1", + "/api/storage/object?key=model.glb", + ); + expect(loaded?.active?.confirmedImageMode).toBe("line-art"); + }); +}); diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture.service.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture.service.ts new file mode 100644 index 00000000..d7ee54c6 --- /dev/null +++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-drawing-capture.service.ts @@ -0,0 +1,948 @@ +import "server-only"; + +import { createHash, randomUUID } from "node:crypto"; +import { normalizeObjectStorageUrlForPersistence } from "@/lib/storage"; +import { AppError, ErrorCode } from "@/server/core/errors"; +import type { AuthUser } from "@/server/core/types"; +import { systemConfigService } from "../system-config.service"; +import { installationDemoAccessService } from "./installation-demo-access.service"; +import { reconcileCaptureReplacement } from "./installation-demo-drawing-capture-replacement"; +import { + normalizeCaptureNumberMarkerCountOverrides, + normalizeStoredCaptureNumberMarkerCountOverrides, +} from "./installation-demo-drawing-capture-marker-counts"; +import { + cleanupDrawingCaptureStorageKeys, + drawingCaptureImageStorageKeys, + uploadDrawingCaptureImagePair, + type DrawingCaptureUpload, +} from "./installation-demo-drawing-capture-storage"; + +export type { DrawingCaptureUpload } from "./installation-demo-drawing-capture-storage"; + +const CONFIG_PREFIX = "installation-demo-workbench:drawing-captures:"; +const MANIFEST_VERSION = 1; +const MAX_TARGET_COUNT = 400; +const MAX_NUMBER_MARKERS = 240; + +export interface ImageTransform { + scale: number; + offsetX: number; + offsetY: number; +} + +export interface CaptureNumberMarker { + id: string; + partId: string | null; + code: string; + x: number; + y: number; + placed: boolean; +} + +export interface StoredCaptureImage { + originalUrl: string; + originalStorageKey: string; + lineArtUrl: string; + lineArtStorageKey: string; + transform: ImageTransform; + numberingMarkers: CaptureNumberMarker[]; + numberingMarkerCountOverrides: Record; + captureAspectRatio: number | null; +} + +export interface StoredCaptureBatch { + batchId: string; + configFingerprint: string; + images: Record; + inventoryTransforms: Record; + expectedTargetKeys: string[]; + savedAt: string; + confirmedAt: string | null; + confirmedImageMode: "original" | "line-art" | null; +} + +interface StoredCaptureManifest { + version: number; + drawingId: string; + modelUrl: string; + modelIdentity: string; + active: StoredCaptureBatch | null; + draft: StoredCaptureBatch | null; +} + +export interface BeginDrawingCaptureBatchInput { + drawingId: unknown; + modelUrl: unknown; + configFingerprint: unknown; + expectedTargetKeys: unknown; + inventoryTransforms?: unknown; + mode: unknown; +} + +export interface UploadDrawingCaptureInput { + drawingId: unknown; + modelUrl: unknown; + batchId: unknown; + targetKey: unknown; + transform: unknown; + numberingMarkers?: unknown; + numberingMarkerCountOverrides?: unknown; + captureAspectRatio?: unknown; + original: DrawingCaptureUpload; + lineArt: DrawingCaptureUpload; +} + +export interface ReplaceDrawingCaptureInput { + drawingId: unknown; + modelUrl: unknown; + configFingerprint: unknown; + expectedTargetKeys: unknown; + targetKey: unknown; + transform: unknown; + numberingMarkers?: unknown; + numberingMarkerCountOverrides?: unknown; + captureAspectRatio?: unknown; + original: DrawingCaptureUpload; + lineArt: DrawingCaptureUpload; +} + +export interface FinalizeDrawingCaptureBatchInput { + drawingId: unknown; + modelUrl: unknown; + batchId: unknown; +} + +export interface ConfirmDrawingCaptureInput { + drawingId: unknown; + modelUrl: unknown; + configFingerprint: unknown; + expectedTargetKeys: unknown; + imageMode: unknown; +} + +export interface UpdateDrawingCaptureLayoutInput { + drawingId: unknown; + modelUrl: unknown; + targetKey: unknown; + transform: unknown; + numberingMarkers?: unknown; + numberingMarkerCountOverrides?: unknown; +} + +function buildConfigKey(drawingId: string) { + return `${CONFIG_PREFIX}${drawingId}`; +} + +function requiredString(value: unknown, name: string, maxLength = 280) { + const normalized = typeof value === "string" ? value.trim() : ""; + if (!normalized) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `缺少${name}`); + } + if (normalized.length > maxLength) { + throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, `${name}过长`); + } + return normalized; +} + +function normalizeDrawingId(value: unknown) { + return requiredString(value, "图纸 ID", 240); +} + +function normalizeModelUrl(value: unknown) { + return normalizeObjectStorageUrlForPersistence( + requiredString(value, "模型地址", 2000), + ); +} + +function resolveModelIdentity(value: string) { + try { + const parsed = new URL(value, "http://installation-demo.local"); + if (parsed.pathname === "/api/storage/object") { + const key = parsed.searchParams.get("key")?.trim(); + if (key) return `storage:${key}`; + } + return `${parsed.origin}${parsed.pathname}`; + } catch { + return value; + } +} + +function normalizeTransform(value: unknown): ImageTransform { + const record = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + const scale = Number(record.scale); + const offsetX = Number(record.offsetX); + const offsetY = Number(record.offsetY); + if ( + !Number.isFinite(scale) || + scale < 0.5 || + scale > 2.5 || + !Number.isFinite(offsetX) || + offsetX < -100 || + offsetX > 100 || + !Number.isFinite(offsetY) || + offsetY < -100 || + offsetY > 100 + ) { + throw new AppError( + ErrorCode.VALIDATION_OUT_OF_RANGE, + "图片缩放或位置参数无效", + ); + } + return { scale, offsetX, offsetY }; +} + +function normalizeTransforms(value: unknown) { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value).map(([key, transform]) => [ + requiredString(key, "调整目标", 300), + normalizeTransform(transform), + ]), + ); +} + +function normalizeNumberingMarkers(value: unknown): CaptureNumberMarker[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value) || value.length > MAX_NUMBER_MARKERS) { + throw new AppError( + ErrorCode.VALIDATION_OUT_OF_RANGE, + "图纸编号标记数量无效", + ); + } + return value.map((item, index) => { + const record = + item && typeof item === "object" && !Array.isArray(item) + ? (item as Record) + : {}; + const x = Number(record.x); + const y = Number(record.y); + if ( + !Number.isFinite(x) || + x < 0 || + x > 1 || + !Number.isFinite(y) || + y < 0 || + y > 1 + ) { + throw new AppError( + ErrorCode.VALIDATION_OUT_OF_RANGE, + "图纸编号标记位置无效", + ); + } + return { + id: + typeof record.id === "string" && record.id.trim() + ? requiredString(record.id, "编号标记 ID", 160) + : `marker-${index + 1}`, + partId: + typeof record.partId === "string" && record.partId.trim() + ? requiredString(record.partId, "部件 ID", 160) + : null, + code: requiredString(record.code, "板件编号", 40), + x, + y, + placed: record.placed !== false, + }; + }); +} + +function normalizeStoredNumberingMarkers(value: unknown) { + try { + return normalizeNumberingMarkers(value); + } catch { + return []; + } +} + +function normalizeCaptureAspectRatio(value: unknown) { + if (value === undefined || value === null) return null; + const ratio = Number(value); + if (!Number.isFinite(ratio) || ratio < 0.25 || ratio > 4) { + throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "截图画布比例无效"); + } + return ratio; +} + +function normalizeStoredCaptureAspectRatio(value: unknown) { + try { + return normalizeCaptureAspectRatio(value); + } catch { + return null; + } +} + +function normalizeTargetKeys(value: unknown) { + if ( + !Array.isArray(value) || + value.length === 0 || + value.length > MAX_TARGET_COUNT + ) { + throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "截图目标数量无效"); + } + const keys = value.map((item) => requiredString(item, "截图目标", 300)); + if (new Set(keys).size !== keys.length) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, "截图目标不能重复"); + } + return keys; +} + +function normalizeStoredTransform(value: unknown): ImageTransform | null { + try { + return normalizeTransform(value); + } catch { + return null; + } +} + +function normalizeStoredImage(value: unknown): StoredCaptureImage | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const transform = normalizeStoredTransform(record.transform); + if ( + typeof record.originalUrl !== "string" || + !record.originalUrl || + typeof record.originalStorageKey !== "string" || + !record.originalStorageKey || + typeof record.lineArtUrl !== "string" || + !record.lineArtUrl || + typeof record.lineArtStorageKey !== "string" || + !record.lineArtStorageKey || + !transform + ) + return null; + return { + originalUrl: record.originalUrl, + originalStorageKey: record.originalStorageKey, + lineArtUrl: record.lineArtUrl, + lineArtStorageKey: record.lineArtStorageKey, + transform, + numberingMarkers: normalizeStoredNumberingMarkers(record.numberingMarkers), + numberingMarkerCountOverrides: + normalizeStoredCaptureNumberMarkerCountOverrides( + record.numberingMarkerCountOverrides, + ), + captureAspectRatio: normalizeStoredCaptureAspectRatio( + record.captureAspectRatio, + ), + }; +} + +function normalizeStoredBatch(value: unknown): StoredCaptureBatch | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if ( + typeof record.batchId !== "string" || + !record.batchId || + typeof record.configFingerprint !== "string" || + !record.configFingerprint || + !Array.isArray(record.expectedTargetKeys) + ) + return null; + const expectedTargetKeys = record.expectedTargetKeys.filter( + (key): key is string => typeof key === "string" && Boolean(key), + ); + if ( + expectedTargetKeys.length === 0 || + expectedTargetKeys.length > MAX_TARGET_COUNT + ) + return null; + const images = + record.images && + typeof record.images === "object" && + !Array.isArray(record.images) + ? Object.fromEntries( + Object.entries(record.images) + .map(([key, image]) => [key, normalizeStoredImage(image)] as const) + .filter((entry): entry is [string, StoredCaptureImage] => + Boolean(entry[1]), + ), + ) + : {}; + const inventoryTransforms = + record.inventoryTransforms && typeof record.inventoryTransforms === "object" + ? Object.fromEntries( + Object.entries(record.inventoryTransforms) + .map( + ([key, transform]) => + [key, normalizeStoredTransform(transform)] as const, + ) + .filter((entry): entry is [string, ImageTransform] => + Boolean(entry[1]), + ), + ) + : {}; + const confirmedAt = + typeof record.confirmedAt === "string" && + Number.isFinite(Date.parse(record.confirmedAt)) + ? record.confirmedAt + : null; + return { + batchId: record.batchId, + configFingerprint: record.configFingerprint, + images, + inventoryTransforms, + expectedTargetKeys, + savedAt: + typeof record.savedAt === "string" + ? record.savedAt + : new Date(0).toISOString(), + confirmedAt, + confirmedImageMode: confirmedAt + ? record.confirmedImageMode === "original" + ? "original" + : "line-art" + : null, + }; +} + +function normalizeStoredManifest( + value: unknown, + drawingId: string, +): StoredCaptureManifest | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if ( + record.version !== MANIFEST_VERSION || + typeof record.modelUrl !== "string" + ) + return null; + const modelUrl = record.modelUrl.trim(); + if (!modelUrl) return null; + return { + version: MANIFEST_VERSION, + drawingId, + modelUrl, + modelIdentity: + typeof record.modelIdentity === "string" && record.modelIdentity + ? record.modelIdentity + : resolveModelIdentity(modelUrl), + active: normalizeStoredBatch(record.active), + draft: normalizeStoredBatch(record.draft), + }; +} + +function publicBatch(batch: StoredCaptureBatch | null) { + if (!batch) return null; + return { + batchId: batch.batchId, + configFingerprint: batch.configFingerprint, + images: Object.fromEntries( + Object.entries(batch.images).map(([key, image]) => [ + key, + { + originalUrl: image.originalUrl, + lineArtUrl: image.lineArtUrl, + transform: image.transform, + numberingMarkers: image.numberingMarkers, + numberingMarkerCountOverrides: image.numberingMarkerCountOverrides, + captureAspectRatio: image.captureAspectRatio, + }, + ]), + ), + inventoryTransforms: batch.inventoryTransforms, + expectedTargetKeys: batch.expectedTargetKeys, + completedCount: batch.expectedTargetKeys.filter((key) => + Boolean(batch.images[key]), + ).length, + totalCount: batch.expectedTargetKeys.length, + savedAt: batch.savedAt, + confirmedAt: batch.confirmedAt, + confirmedImageMode: batch.confirmedImageMode, + }; +} + +function publicManifest(manifest: StoredCaptureManifest | null) { + if (!manifest) return null; + return { + version: manifest.version, + drawingId: manifest.drawingId, + modelUrl: manifest.modelUrl, + modelIdentity: manifest.modelIdentity, + active: publicBatch(manifest.active), + draft: publicBatch(manifest.draft), + }; +} + +function stableSegment(value: string) { + return createHash("sha256").update(value).digest("hex").slice(0, 24); +} + +function storageKeys(batch: StoredCaptureBatch | null) { + if (!batch) return []; + return Object.values(batch.images).flatMap((image) => [ + image.originalStorageKey, + image.lineArtStorageKey, + ]); +} + +const cleanupStorageKeys = cleanupDrawingCaptureStorageKeys; + +async function assertCanView(user: AuthUser) { + if (!(await installationDemoAccessService.getAccess(user)).canView) { + throw new AppError( + ErrorCode.AUTH_PERMISSION_DENIED, + "没有权限查看安装图纸截图", + ); + } +} + +async function assertCanEdit(user: AuthUser) { + if (!(await installationDemoAccessService.getAccess(user)).canEdit) { + throw new AppError( + ErrorCode.AUTH_PERMISSION_DENIED, + "没有权限保存安装图纸截图", + ); + } +} + +export class InstallationDemoDrawingCaptureService { + private async readManifest(drawingId: string) { + return normalizeStoredManifest( + await systemConfigService.get(buildConfigKey(drawingId)), + drawingId, + ); + } + + private async writeManifest(manifest: StoredCaptureManifest) { + await systemConfigService.set( + buildConfigKey(manifest.drawingId), + manifest, + `Installation drawing captures for ${manifest.drawingId}`, + ); + } + + async get(user: AuthUser, rawDrawingId: unknown, rawModelUrl?: unknown) { + await assertCanView(user); + const drawingId = normalizeDrawingId(rawDrawingId); + const manifest = await this.readManifest(drawingId); + if ( + !manifest || + rawModelUrl === undefined || + rawModelUrl === null || + rawModelUrl === "" + ) { + return publicManifest(manifest); + } + const modelUrl = normalizeModelUrl(rawModelUrl); + return manifest.modelIdentity === resolveModelIdentity(modelUrl) + ? publicManifest(manifest) + : null; + } + + async begin(user: AuthUser, input: BeginDrawingCaptureBatchInput) { + await assertCanEdit(user); + const drawingId = normalizeDrawingId(input.drawingId); + const modelUrl = normalizeModelUrl(input.modelUrl); + const modelIdentity = resolveModelIdentity(modelUrl); + const configFingerprint = requiredString( + input.configFingerprint, + "配置指纹", + 160, + ); + const expectedTargetKeys = normalizeTargetKeys(input.expectedTargetKeys); + const inventoryTransforms = normalizeTransforms(input.inventoryTransforms); + const mode = input.mode === "replace" ? "replace" : "fill"; + const previous = await this.readManifest(drawingId); + const sameModel = previous?.modelIdentity === modelIdentity; + const active = sameModel ? (previous?.active ?? null) : null; + const reusableDraft = + sameModel && + previous?.draft && + previous.draft.configFingerprint === configFingerprint && + previous.draft.expectedTargetKeys.join("\u001f") === + expectedTargetKeys.join("\u001f") + ? previous.draft + : null; + if (reusableDraft) return publicManifest(previous); + + const draft: StoredCaptureBatch = { + batchId: randomUUID(), + configFingerprint, + images: {}, + inventoryTransforms: + mode === "replace" + ? { ...(active?.inventoryTransforms ?? {}), ...inventoryTransforms } + : inventoryTransforms, + expectedTargetKeys, + savedAt: new Date().toISOString(), + confirmedAt: null, + confirmedImageMode: null, + }; + const next: StoredCaptureManifest = { + version: MANIFEST_VERSION, + drawingId, + modelUrl, + modelIdentity, + active, + draft, + }; + await this.writeManifest(next); + if (previous?.draft && previous.draft !== reusableDraft) { + await cleanupStorageKeys(storageKeys(previous.draft)); + } + if (!sameModel && previous?.active) { + await cleanupStorageKeys(storageKeys(previous.active)); + } + return publicManifest(next); + } + + async upload(user: AuthUser, input: UploadDrawingCaptureInput) { + await assertCanEdit(user); + const drawingId = normalizeDrawingId(input.drawingId); + const modelUrl = normalizeModelUrl(input.modelUrl); + const batchId = requiredString(input.batchId, "抓取批次", 160); + const targetKey = requiredString(input.targetKey, "截图目标", 300); + const transform = normalizeTransform(input.transform); + const numberingMarkers = normalizeNumberingMarkers(input.numberingMarkers); + const numberingMarkerCountOverrides = + normalizeCaptureNumberMarkerCountOverrides( + input.numberingMarkerCountOverrides, + ); + const captureAspectRatio = normalizeCaptureAspectRatio( + input.captureAspectRatio, + ); + const manifest = await this.readManifest(drawingId); + if ( + !manifest || + manifest.modelIdentity !== resolveModelIdentity(modelUrl) + ) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "模型已变化,请重新开始抓取", + ); + } + const draft = manifest.draft; + if (!draft || draft.batchId !== batchId) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "抓取批次已失效,请重新开始", + ); + } + if (!draft.expectedTargetKeys.includes(targetKey)) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "截图目标不属于当前批次", + ); + } + if (draft.images[targetKey]) return publicManifest(manifest); + + const baseKey = [ + "installation-drawing-captures", + stableSegment(drawingId), + stableSegment(manifest.modelIdentity), + stableSegment(batchId), + stableSegment(targetKey), + ].join("/"); + const capturedImage = await uploadDrawingCaptureImagePair({ + baseKey, + original: input.original, + lineArt: input.lineArt, + transform, + numberingMarkers, + numberingMarkerCountOverrides, + captureAspectRatio, + }); + try { + const nextDraft: StoredCaptureBatch = { + ...draft, + images: { + ...draft.images, + [targetKey]: capturedImage, + }, + savedAt: new Date().toISOString(), + }; + const next = { ...manifest, draft: nextDraft }; + await this.writeManifest(next); + return publicManifest(next); + } catch (error) { + await cleanupStorageKeys(drawingCaptureImageStorageKeys(capturedImage)); + throw error; + } + } + + async replace(user: AuthUser, input: ReplaceDrawingCaptureInput) { + await assertCanEdit(user); + const drawingId = normalizeDrawingId(input.drawingId); + const modelUrl = normalizeModelUrl(input.modelUrl); + const configFingerprint = requiredString( + input.configFingerprint, + "配置指纹", + 160, + ); + const expectedTargetKeys = normalizeTargetKeys(input.expectedTargetKeys); + const targetKey = requiredString(input.targetKey, "截图目标", 300); + const transform = normalizeTransform(input.transform); + const numberingMarkers = normalizeNumberingMarkers(input.numberingMarkers); + const numberingMarkerCountOverrides = + normalizeCaptureNumberMarkerCountOverrides( + input.numberingMarkerCountOverrides, + ); + const captureAspectRatio = normalizeCaptureAspectRatio( + input.captureAspectRatio, + ); + if (!expectedTargetKeys.includes(targetKey)) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "截图目标不属于当前模板", + ); + } + const manifest = await this.readManifest(drawingId); + if ( + !manifest || + manifest.modelIdentity !== resolveModelIdentity(modelUrl) || + !manifest.active + ) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "当前没有可更新的安装图纸截图", + ); + } + if (manifest.draft) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "当前批量抓取尚未完成,请先继续完成批量抓取", + ); + } + + const replacementId = randomUUID(); + const capturedImage = await uploadDrawingCaptureImagePair({ + baseKey: [ + "installation-drawing-captures", + stableSegment(drawingId), + stableSegment(manifest.modelIdentity), + stableSegment(manifest.active.batchId), + "replacements", + stableSegment(`${targetKey}:${replacementId}`), + ].join("/"), + original: input.original, + lineArt: input.lineArt, + transform, + numberingMarkers, + numberingMarkerCountOverrides, + captureAspectRatio, + }); + const replacement = reconcileCaptureReplacement({ + active: manifest.active, + configFingerprint, + expectedTargetKeys, + targetKey, + image: capturedImage, + savedAt: new Date().toISOString(), + }); + const next: StoredCaptureManifest = { + ...manifest, + active: { + ...replacement.batch, + confirmedAt: null, + confirmedImageMode: null, + }, + }; + try { + await this.writeManifest(next); + } catch (error) { + await cleanupStorageKeys(drawingCaptureImageStorageKeys(capturedImage)); + throw error; + } + await cleanupStorageKeys( + replacement.discardedImages.flatMap(drawingCaptureImageStorageKeys), + ); + return publicManifest(next); + } + + async finalize(user: AuthUser, input: FinalizeDrawingCaptureBatchInput) { + await assertCanEdit(user); + const drawingId = normalizeDrawingId(input.drawingId); + const modelUrl = normalizeModelUrl(input.modelUrl); + const batchId = requiredString(input.batchId, "抓取批次", 160); + const manifest = await this.readManifest(drawingId); + if ( + !manifest || + manifest.modelIdentity !== resolveModelIdentity(modelUrl) + ) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "模型已变化,请重新开始抓取", + ); + } + const draft = manifest.draft; + if (!draft || draft.batchId !== batchId) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "抓取批次已失效,请重新开始", + ); + } + const missing = draft.expectedTargetKeys.filter( + (key) => !draft.images[key], + ); + if (missing.length > 0) { + throw new AppError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + `还有 ${missing.length} 张画面未完成`, + ); + } + const previousActive = manifest.active; + const next: StoredCaptureManifest = { + ...manifest, + active: { + ...draft, + savedAt: new Date().toISOString(), + confirmedAt: null, + confirmedImageMode: null, + }, + draft: null, + }; + await this.writeManifest(next); + await cleanupStorageKeys(storageKeys(previousActive)); + return publicManifest(next); + } + + async updateLayout(user: AuthUser, input: UpdateDrawingCaptureLayoutInput) { + await assertCanEdit(user); + const drawingId = normalizeDrawingId(input.drawingId); + const modelUrl = normalizeModelUrl(input.modelUrl); + const targetKey = requiredString(input.targetKey, "调整目标", 320); + const transform = normalizeTransform(input.transform); + const numberingMarkers = normalizeNumberingMarkers(input.numberingMarkers); + const numberingMarkerCountOverrides = + normalizeCaptureNumberMarkerCountOverrides( + input.numberingMarkerCountOverrides, + ); + const manifest = await this.readManifest(drawingId); + if ( + !manifest || + manifest.modelIdentity !== resolveModelIdentity(modelUrl) || + !manifest.active + ) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "当前没有已固定的安装图纸截图", + ); + } + const updateBatch = (batch: StoredCaptureBatch | null) => { + if (!batch) return null; + if (targetKey.startsWith("inventory:")) { + return { + ...batch, + inventoryTransforms: { + ...batch.inventoryTransforms, + [targetKey.slice(10)]: transform, + }, + savedAt: new Date().toISOString(), + confirmedAt: null, + confirmedImageMode: null, + }; + } + const image = batch.images[targetKey]; + return image + ? { + ...batch, + images: { + ...batch.images, + [targetKey]: { + ...image, + transform, + numberingMarkers, + numberingMarkerCountOverrides, + }, + }, + savedAt: new Date().toISOString(), + confirmedAt: null, + confirmedImageMode: null, + } + : batch; + }; + const next = { + ...manifest, + active: updateBatch(manifest.active), + draft: updateBatch(manifest.draft), + } as StoredCaptureManifest; + await this.writeManifest(next); + return publicManifest(next); + } + + async confirm(user: AuthUser, input: ConfirmDrawingCaptureInput) { + await assertCanEdit(user); + const drawingId = normalizeDrawingId(input.drawingId); + const modelUrl = normalizeModelUrl(input.modelUrl); + const configFingerprint = requiredString( + input.configFingerprint, + "配置指纹", + 160, + ); + const expectedTargetKeys = normalizeTargetKeys(input.expectedTargetKeys); + if (input.imageMode !== "original" && input.imageMode !== "line-art") { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "图纸图片模式格式不正确", + ); + } + const manifest = await this.readManifest(drawingId); + if ( + !manifest || + manifest.modelIdentity !== resolveModelIdentity(modelUrl) || + !manifest.active + ) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "当前没有可确认的安装图纸", + ); + } + if (manifest.draft) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "当前批量抓取尚未完成,不能确认图纸", + ); + } + const active = manifest.active; + if ( + active.configFingerprint !== configFingerprint || + active.expectedTargetKeys.length !== expectedTargetKeys.length || + active.expectedTargetKeys.some( + (key, index) => key !== expectedTargetKeys[index], + ) + ) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "安装配置已变化,请重新获取图纸后再确认", + ); + } + const missingCount = expectedTargetKeys.filter( + (key) => !active.images[key], + ).length; + if (missingCount > 0) { + throw new AppError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + `还有 ${missingCount} 张画面未完成`, + ); + } + const unplacedMarkerCount = Object.values(active.images).reduce( + (count, image) => + count + + image.numberingMarkers.filter((marker) => !marker.placed).length, + 0, + ); + if (unplacedMarkerCount > 0) { + throw new AppError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + `还有 ${unplacedMarkerCount} 个编号待放置`, + ); + } + const next: StoredCaptureManifest = { + ...manifest, + active: { + ...active, + confirmedAt: new Date().toISOString(), + confirmedImageMode: input.imageMode, + }, + }; + await this.writeManifest(next); + return publicManifest(next); + } +} + +export const installationDemoDrawingCaptureService = + new InstallationDemoDrawingCaptureService(); diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-numbering-snapshot.service.test.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-numbering-snapshot.service.test.ts index 950d9f10..1d448fef 100644 --- a/furniture-3d-system/src/server/services/installation-demo/installation-demo-numbering-snapshot.service.test.ts +++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-numbering-snapshot.service.test.ts @@ -71,18 +71,24 @@ describe('InstallationDemoNumberingSnapshotService', () => { drawingId: 'drawing-1', modelUrl: '/api/storage/object?key=models%2Fcabinet.glb', partSnapshots: [createUpload('shared-id')], + partLineArtSnapshots: [createUpload('shared-id')], toolSnapshots: [createUpload('shared-id')], + toolLineArtSnapshots: [createUpload('shared-id')], }) - expect(mockedUpload).toHaveBeenCalledTimes(2) + expect(mockedUpload).toHaveBeenCalledTimes(4) expect(saved.modelIdentity).toBe('storage:models/cabinet.glb') expect(saved.snapshotUrls['shared-id']).toContain('%2Fpart%2F') + expect(saved.snapshotLineArtUrls['shared-id']).toContain('%2Fpart-line-art%2F') expect(saved.toolSnapshotUrls['shared-id']).toContain('%2Ftool%2F') + expect(saved.toolSnapshotLineArtUrls['shared-id']).toContain('%2Ftool-line-art%2F') expect(mockedConfigService.set).toHaveBeenCalledWith( 'installation-demo-workbench:numbering-snapshots:drawing-1', expect.objectContaining({ snapshotUrls: saved.snapshotUrls, + snapshotLineArtUrls: saved.snapshotLineArtUrls, toolSnapshotUrls: saved.toolSnapshotUrls, + toolSnapshotLineArtUrls: saved.toolSnapshotLineArtUrls, }), expect.any(String), ) @@ -93,7 +99,9 @@ describe('InstallationDemoNumberingSnapshotService', () => { version: 1, modelUrl: '/api/storage/object?key=models%2Fcabinet.glb&signature=old', snapshotUrls: { part: '/api/storage/object?key=snapshots%2Fpart.png' }, + snapshotLineArtUrls: {}, toolSnapshotUrls: { tool: '/api/storage/object?key=snapshots%2Ftool.png' }, + toolSnapshotLineArtUrls: {}, savedAt: '2026-07-27T00:00:00.000Z', }) const service = new InstallationDemoNumberingSnapshotService() diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-numbering-snapshot.service.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-numbering-snapshot.service.ts index f20576a0..dd24b1b6 100644 --- a/furniture-3d-system/src/server/services/installation-demo/installation-demo-numbering-snapshot.service.ts +++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-numbering-snapshot.service.ts @@ -24,7 +24,9 @@ export interface InstallationDemoNumberingSnapshotBatchDTO { modelUrl: string modelIdentity: string snapshotUrls: Record + snapshotLineArtUrls: Record toolSnapshotUrls: Record + toolSnapshotLineArtUrls: Record savedAt: string } @@ -39,7 +41,9 @@ export interface SaveInstallationDemoNumberingSnapshotInput { modelUrl: unknown modelIdentity?: unknown partSnapshots: InstallationDemoNumberingSnapshotUpload[] + partLineArtSnapshots?: InstallationDemoNumberingSnapshotUpload[] toolSnapshots: InstallationDemoNumberingSnapshotUpload[] + toolLineArtSnapshots?: InstallationDemoNumberingSnapshotUpload[] } function buildConfigKey(drawingId: string) { @@ -104,7 +108,9 @@ function normalizeStoredSnapshot(value: unknown, drawingId: string): Installatio ? record.modelIdentity : resolveModelIdentity(modelUrl), snapshotUrls: normalizeSnapshotMap(record.snapshotUrls), + snapshotLineArtUrls: normalizeSnapshotMap(record.snapshotLineArtUrls), toolSnapshotUrls: normalizeSnapshotMap(record.toolSnapshotUrls), + toolSnapshotLineArtUrls: normalizeSnapshotMap(record.toolSnapshotLineArtUrls), savedAt: typeof record.savedAt === 'string' ? record.savedAt : new Date(0).toISOString(), } } @@ -142,9 +148,12 @@ async function uploadSnapshots( drawingId: string, modelIdentity: string, batchId: string, - kind: 'part' | 'tool', + kind: 'part' | 'tool' | 'part-line-art' | 'tool-line-art', snapshots: InstallationDemoNumberingSnapshotUpload[], ) { + if (snapshots.length === 0) { + return { snapshotUrls: {}, storageKeys: [] } + } const saved: Array<{ id: string; url: string; storageKey: string }> = [] try { for (const snapshot of snapshots) { @@ -213,49 +222,67 @@ export class InstallationDemoNumberingSnapshotService { const modelUrl = normalizeModelUrl(input.modelUrl) const modelIdentity = resolveModelIdentity(modelUrl) const partSnapshots = input.partSnapshots ?? [] + const partLineArtSnapshots = input.partLineArtSnapshots ?? [] const toolSnapshots = input.toolSnapshots ?? [] + const toolLineArtSnapshots = input.toolLineArtSnapshots ?? [] // Part and tool IDs belong to different catalogs, so duplicate IDs are valid across kinds. - if (partSnapshots.length + toolSnapshots.length > MAX_SNAPSHOT_COUNT) { + if ( + partSnapshots.length + partLineArtSnapshots.length + + toolSnapshots.length + toolLineArtSnapshots.length > MAX_SNAPSHOT_COUNT + ) { throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '快照数量超出上限') } - const totalSize = validateUploads(partSnapshots) + validateUploads(toolSnapshots) + const totalSize = validateUploads(partSnapshots) + validateUploads(partLineArtSnapshots) + + validateUploads(toolSnapshots) + validateUploads(toolLineArtSnapshots) if (totalSize > MAX_TOTAL_SNAPSHOT_SIZE_BYTES) { throw new AppError(ErrorCode.FILE_TOO_LARGE, '本次快照总大小不能超过 48MB') } const batchId = randomUUID() - const partUpload = await uploadSnapshots(drawingId, modelIdentity, batchId, 'part', partSnapshots) - let toolUpload + const completedUploads: Awaited>[] = [] try { - toolUpload = await uploadSnapshots(drawingId, modelIdentity, batchId, 'tool', toolSnapshots) - } catch (error) { - await cleanupUploadedSnapshots(partUpload.storageKeys) - throw error - } - const uploadedStorageKeys = [...partUpload.storageKeys, ...toolUpload.storageKeys] + const partUpload = await uploadSnapshots(drawingId, modelIdentity, batchId, 'part', partSnapshots) + completedUploads.push(partUpload) + const partLineArtUpload = await uploadSnapshots( + drawingId, + modelIdentity, + batchId, + 'part-line-art', + partLineArtSnapshots, + ) + completedUploads.push(partLineArtUpload) + const toolUpload = await uploadSnapshots(drawingId, modelIdentity, batchId, 'tool', toolSnapshots) + completedUploads.push(toolUpload) + const toolLineArtUpload = await uploadSnapshots( + drawingId, + modelIdentity, + batchId, + 'tool-line-art', + toolLineArtSnapshots, + ) + completedUploads.push(toolLineArtUpload) - const snapshot: InstallationDemoNumberingSnapshotBatchDTO = { - version: SNAPSHOT_VERSION, - drawingId, - modelUrl, - modelIdentity, - snapshotUrls: partUpload.snapshotUrls, - toolSnapshotUrls: toolUpload.snapshotUrls, - savedAt: new Date().toISOString(), - } - - try { + const snapshot: InstallationDemoNumberingSnapshotBatchDTO = { + version: SNAPSHOT_VERSION, + drawingId, + modelUrl, + modelIdentity, + snapshotUrls: partUpload.snapshotUrls, + snapshotLineArtUrls: partLineArtUpload.snapshotUrls, + toolSnapshotUrls: toolUpload.snapshotUrls, + toolSnapshotLineArtUrls: toolLineArtUpload.snapshotUrls, + savedAt: new Date().toISOString(), + } await systemConfigService.set( buildConfigKey(drawingId), snapshot, `Installation demo numbering snapshots for ${drawingId}`, ) + return snapshot } catch (error) { - await cleanupUploadedSnapshots(uploadedStorageKeys) + await cleanupUploadedSnapshots(completedUploads.flatMap((upload) => upload.storageKeys)) throw error } - - return snapshot } } diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.test.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.test.ts index 4eacf038..65236380 100644 --- a/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.test.ts +++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.test.ts @@ -1,78 +1,146 @@ -jest.mock('server-only', () => ({})) +jest.mock("server-only", () => ({})); -jest.mock('../system-config.service', () => ({ +jest.mock("../system-config.service", () => ({ systemConfigService: { get: jest.fn(), set: jest.fn(), }, -})) +})); -jest.mock('./installation-demo-access.service', () => ({ +jest.mock("./installation-demo-access.service", () => ({ installationDemoAccessService: { getAccess: jest.fn(), }, -})) +})); -import { systemConfigService } from '../system-config.service' -import { installationDemoAccessService } from './installation-demo-access.service' -import { InstallationDemoShareService } from './installation-demo-share.service' +import { systemConfigService } from "../system-config.service"; +import { installationDemoAccessService } from "./installation-demo-access.service"; +import { InstallationDemoShareService } from "./installation-demo-share.service"; const user = { - id: 'user-1', - email: 'user@example.com', - name: 'Test user', - role: 'USER' as const, + id: "user-1", + email: "user@example.com", + name: "Test user", + role: "USER" as const, businessRoles: [], - status: 'ACTIVE' as const, -} + status: "ACTIVE" as const, +}; function createInput() { + const image = { + originalUrl: "/api/storage/object?key=drawings%2Ffinished.png", + lineArtUrl: "/api/storage/object?key=drawings%2Ffinished-line.png", + transform: { scale: 1, offsetX: 0, offsetY: 0 }, + numberingMarkers: [], + numberingMarkerCountOverrides: {}, + captureAspectRatio: 1.75, + }; return { - drawing: { id: 'drawing-1', code: 'CJ0267', modelCode: 'M-1', spuCode: 'SPU-1' }, - modelUrl: '/api/storage/object?key=models%2Fcabinet.glb', + drawing: { + id: "drawing-1", + code: "CJ0267", + modelCode: "M-1", + spuCode: "SPU-1", + }, + modelUrl: "/api/storage/object?key=models%2Fcabinet.glb", parts: [], numberingParts: [], drawingTools: [], - numberingSnapshotUrls: { part: '/api/storage/object?key=snapshots%2Fpart.png' }, - numberingToolSnapshotUrls: { tool: '/api/storage/object?key=snapshots%2Ftool.png' }, + numberingSnapshotUrls: { + part: "/api/storage/object?key=snapshots%2Fpart.png", + }, + numberingToolSnapshotUrls: { + tool: "/api/storage/object?key=snapshots%2Ftool.png", + }, steps: [], - } + drawingDocument: { + templateId: "set-1" as const, + imageMode: "original" as const, + captures: { + finished: image, + exploded: { + ...image, + originalUrl: "/api/storage/object?key=drawings%2Fexploded.png", + }, + actions: [], + inventoryTransforms: {}, + }, + snapshotLineArtUrls: { + part: "/api/storage/object?key=snapshots%2Fpart-line.png", + }, + toolSnapshotLineArtUrls: { + tool: "/api/storage/object?key=snapshots%2Ftool-line.png", + }, + }, + }; } -describe('InstallationDemoShareService', () => { - const mockedAccessService = jest.mocked(installationDemoAccessService) - const mockedConfigService = jest.mocked(systemConfigService) +describe("InstallationDemoShareService", () => { + const mockedAccessService = jest.mocked(installationDemoAccessService); + const mockedConfigService = jest.mocked(systemConfigService); beforeEach(() => { - jest.clearAllMocks() - mockedAccessService.getAccess.mockResolvedValue({ canView: true, canEdit: true, canShare: true }) - mockedConfigService.get.mockResolvedValue(null) - mockedConfigService.set.mockResolvedValue(undefined) - }) + jest.clearAllMocks(); + mockedAccessService.getAccess.mockResolvedValue({ + canView: true, + canEdit: true, + canShare: true, + }); + mockedConfigService.get.mockResolvedValue(null); + mockedConfigService.set.mockResolvedValue(undefined); + }); - it('persists tool snapshots together with part snapshots for a shared demo', async () => { - const service = new InstallationDemoShareService() - const snapshot = await service.create(user, createInput()) + it("persists tool snapshots together with part snapshots for a shared demo", async () => { + const service = new InstallationDemoShareService(); + const snapshot = await service.create(user, createInput()); expect(snapshot.numberingToolSnapshotUrls).toEqual({ - tool: '/api/storage/object?key=snapshots%2Ftool.png', - }) + tool: "/api/storage/object?key=snapshots%2Ftool.png", + }); expect(mockedConfigService.set).toHaveBeenCalledWith( - expect.stringContaining('installation-demo-workbench:share:'), + expect.stringContaining("installation-demo-workbench:share:"), expect.objectContaining({ - numberingSnapshotUrls: { part: '/api/storage/object?key=snapshots%2Fpart.png' }, - numberingToolSnapshotUrls: { tool: '/api/storage/object?key=snapshots%2Ftool.png' }, + numberingSnapshotUrls: { + part: "/api/storage/object?key=snapshots%2Fpart.png", + }, + numberingToolSnapshotUrls: { + tool: "/api/storage/object?key=snapshots%2Ftool.png", + }, }), expect.any(String), - ) - }) + ); + }); - it('keeps older shared demos readable when they have no tool snapshot field', async () => { + it("freezes the completed drawing document into the share snapshot", async () => { + const service = new InstallationDemoShareService(); + const snapshot = await service.create(user, createInput()); + + expect(snapshot.drawingDocument).toMatchObject({ + templateId: "set-1", + imageMode: "original", + snapshotLineArtUrls: { + part: "/api/storage/object?key=snapshots%2Fpart-line.png", + }, + toolSnapshotLineArtUrls: { + tool: "/api/storage/object?key=snapshots%2Ftool-line.png", + }, + }); + expect(snapshot.drawingDocument?.captures.finished.originalUrl).toContain( + "finished.png", + ); + }); + + it("keeps older shared demos readable when they have no tool snapshot field", async () => { mockedConfigService.get.mockResolvedValue({ version: 1, - token: 'a'.repeat(32), - drawing: { id: 'drawing-1', code: 'CJ0267', modelCode: 'M-1', spuCode: 'SPU-1' }, - modelUrl: '/api/storage/object?key=models%2Fcabinet.glb', + token: "a".repeat(32), + drawing: { + id: "drawing-1", + code: "CJ0267", + modelCode: "M-1", + spuCode: "SPU-1", + }, + modelUrl: "/api/storage/object?key=models%2Fcabinet.glb", uploadedModel: null, parts: [], numberingParts: [], @@ -82,14 +150,44 @@ describe('InstallationDemoShareService', () => { stepMaterialsByStepId: {}, steps: [], initialView: null, - createdAt: '2026-07-27T00:00:00.000Z', + createdAt: "2026-07-27T00:00:00.000Z", expiresAt: new Date(Date.now() + 60_000).toISOString(), - createdBy: { id: 'user-1', name: 'Test user' }, - }) - const service = new InstallationDemoShareService() + createdBy: { id: "user-1", name: "Test user" }, + }); + const service = new InstallationDemoShareService(); - await expect(service.getPublic('a'.repeat(32))).resolves.toMatchObject({ + await expect(service.getPublic("a".repeat(32))).resolves.toMatchObject({ numberingToolSnapshotUrls: {}, - }) - }) -}) + drawingDocument: null, + }); + }); + + it("defaults a historical drawing document without an image mode to line art", async () => { + const service = new InstallationDemoShareService(); + const snapshot = await service.create(user, createInput()); + const legacyDocument = snapshot.drawingDocument as unknown as Record< + string, + unknown + >; + delete legacyDocument.imageMode; + mockedConfigService.get.mockResolvedValue(snapshot); + + await expect(service.getPublic(snapshot.token)).resolves.toMatchObject({ + drawingDocument: { imageMode: "line-art" }, + }); + }); + + it("rejects a new drawing document with an invalid image mode", async () => { + const input = createInput() as ReturnType & { + drawingDocument: ReturnType["drawingDocument"] & { + imageMode: unknown; + }; + }; + input.drawingDocument.imageMode = "screen"; + const service = new InstallationDemoShareService(); + + await expect(service.create(user, input)).rejects.toThrow( + "图纸图片模式格式不正确", + ); + }); +}); diff --git a/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.ts b/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.ts index 60981385..6eef23ff 100644 --- a/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.ts +++ b/furniture-3d-system/src/server/services/installation-demo/installation-demo-share.service.ts @@ -1,270 +1,429 @@ -import 'server-only' +import "server-only"; -import { nanoid } from 'nanoid' -import { isInstallationCameraViewSnapshot } from '@/app/installation-demo-workbench/lib/installationCameraView' -import { AppError, ErrorCode } from '@/server/core/errors' -import type { AuthUser } from '@/server/core/types' +import { nanoid } from "nanoid"; +import { isInstallationCameraViewSnapshot } from "@/app/installation-demo-workbench/lib/installationCameraView"; +import { AppError, ErrorCode } from "@/server/core/errors"; +import type { AuthUser } from "@/server/core/types"; import type { CreateInstallationDemoShareInput, + InstallationDemoShareDrawingDocument, InstallationDemoShareDrawingSnapshot, InstallationDemoShareInitialViewState, InstallationDemoShareSnapshot, -} from '@/lib/installation-demo-share-types' -import type { InstallationStepMaterialShareSnapshot } from '@/app/installation-demo-workbench/lib/installationStepMaterialShareSnapshots' -import { systemConfigService } from '../system-config.service' -import { installationDemoAccessService } from './installation-demo-access.service' +} from "@/lib/installation-demo-share-types"; +import type { InstallationStepMaterialShareSnapshot } from "@/app/installation-demo-workbench/lib/installationStepMaterialShareSnapshots"; +import { systemConfigService } from "../system-config.service"; +import { installationDemoAccessService } from "./installation-demo-access.service"; -const SHARE_CONFIG_KEY_PREFIX = 'installation-demo-workbench:share:' -const SHARE_TOKEN_LENGTH = 32 -const SHARE_TTL_MS = 24 * 60 * 60 * 1000 +const SHARE_CONFIG_KEY_PREFIX = "installation-demo-workbench:share:"; +const SHARE_TOKEN_LENGTH = 32; +const SHARE_TTL_MS = 24 * 60 * 60 * 1000; function buildShareConfigKey(token: string) { - return `${SHARE_CONFIG_KEY_PREFIX}${token}` + return `${SHARE_CONFIG_KEY_PREFIX}${token}`; } function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) + return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function normalizeText(value: unknown, fieldLabel: string) { - if (typeof value !== 'string') { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `缺少${fieldLabel}`) + if (typeof value !== "string") { + throw new AppError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + `缺少${fieldLabel}`, + ); } - const normalized = value.trim() + const normalized = value.trim(); if (!normalized) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, `缺少${fieldLabel}`) + throw new AppError( + ErrorCode.VALIDATION_REQUIRED_FIELD, + `缺少${fieldLabel}`, + ); } - return normalized + return normalized; } function normalizeNullableText(value: unknown, fieldLabel: string) { - if (value === undefined || value === null || value === '') return null - return normalizeText(value, fieldLabel) + if (value === undefined || value === null || value === "") return null; + return normalizeText(value, fieldLabel); } export function normalizeInstallationDemoShareToken(value: unknown) { - const token = normalizeText(value, '分享码') + const token = normalizeText(value, "分享码"); if (!/^[A-Za-z0-9_-]{16,80}$/.test(token)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '分享码格式不正确') + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, "分享码格式不正确"); } - return token + return token; } -function normalizeDrawing(value: unknown): InstallationDemoShareDrawingSnapshot { +function normalizeDrawing( + value: unknown, +): InstallationDemoShareDrawingSnapshot { if (!isRecord(value)) { - throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少图纸信息') + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "缺少图纸信息"); } return { - id: normalizeText(value.id, '图纸 ID'), - code: normalizeText(value.code, '图纸编号'), - modelCode: normalizeText(value.modelCode, '型号'), - spuCode: normalizeText(value.spuCode, 'SPU'), - } + id: normalizeText(value.id, "图纸 ID"), + code: normalizeText(value.code, "图纸编号"), + modelCode: normalizeText(value.modelCode, "型号"), + spuCode: normalizeText(value.spuCode, "SPU"), + }; } function normalizeArray(value: unknown, fieldLabel: string): T[] { if (!Array.isArray(value)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${fieldLabel}格式不正确`) + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + `${fieldLabel}格式不正确`, + ); } - return value as T[] + return value as T[]; } function normalizeOptionalRecord(value: unknown): T | null { - if (value === undefined || value === null) return null + if (value === undefined || value === null) return null; if (!isRecord(value)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '数据格式不正确') + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, "数据格式不正确"); } - return value as T + return value as T; } -function normalizeStringRecord(value: unknown, fieldLabel: string): Record { - if (value === undefined || value === null) return {} +function normalizeStringRecord( + value: unknown, + fieldLabel: string, +): Record { + if (value === undefined || value === null) return {}; if (!isRecord(value)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${fieldLabel}格式不正确`) + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + `${fieldLabel}格式不正确`, + ); } return Object.fromEntries( Object.entries(value).filter((entry): entry is [string, string] => { - const [key, entryValue] = entry - return key.trim().length > 0 && typeof entryValue === 'string' && entryValue.trim().length > 0 + const [key, entryValue] = entry; + return ( + key.trim().length > 0 && + typeof entryValue === "string" && + entryValue.trim().length > 0 + ); }), - ) + ); } -function normalizeStepMaterialShareSnapshot(value: unknown): InstallationStepMaterialShareSnapshot { +function normalizeStepMaterialShareSnapshot( + value: unknown, +): InstallationStepMaterialShareSnapshot { if (!isRecord(value)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '安装步骤材料快照格式不正确') + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "安装步骤材料快照格式不正确", + ); } return { - items: normalizeArray(value.items, '安装步骤材料项'), - thumbnailUrls: normalizeStringRecord(value.thumbnailUrls, '安装步骤材料缩略图'), - } + items: normalizeArray(value.items, "安装步骤材料项"), + thumbnailUrls: normalizeStringRecord( + value.thumbnailUrls, + "安装步骤材料缩略图", + ), + }; } function normalizeStepMaterialShareSnapshots( value: unknown, { stored = false }: { stored?: boolean } = {}, ) { - if (value === undefined || value === null) return {} + if (value === undefined || value === null) return {}; if (!isRecord(value)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '安装步骤材料快照格式不正确') + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "安装步骤材料快照格式不正确", + ); } - return Object.entries(value).reduce>( - (result, [stepId, entryValue]) => { - if (stepId.trim().length === 0) return result + return Object.entries(value).reduce< + Record + >((result, [stepId, entryValue]) => { + if (stepId.trim().length === 0) return result; - if (stored) { - try { - result[stepId] = normalizeStepMaterialShareSnapshot(entryValue) - } catch { - return result - } - return result + if (stored) { + try { + result[stepId] = normalizeStepMaterialShareSnapshot(entryValue); + } catch { + return result; } + return result; + } - result[stepId] = normalizeStepMaterialShareSnapshot(entryValue) - return result - }, - {}, - ) + result[stepId] = normalizeStepMaterialShareSnapshot(entryValue); + return result; + }, {}); +} + +function normalizeDrawingImageMode( + value: unknown, +): InstallationDemoShareDrawingDocument["imageMode"] { + if (value === undefined) return "line-art"; + if (value === "original" || value === "line-art") return value; + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "图纸图片模式格式不正确", + ); +} + +function normalizeDrawingDocument( + value: unknown, +): InstallationDemoShareDrawingDocument | null { + if (value === undefined || value === null) return null; + if ( + !isRecord(value) || + value.templateId !== "set-1" || + !isRecord(value.captures) + ) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "安装图纸包格式不正确", + ); + } + if ( + !isRecord(value.captures.finished) || + !isRecord(value.captures.exploded) || + !Array.isArray(value.captures.actions) || + !isRecord(value.captures.inventoryTransforms) + ) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "安装图纸截图格式不正确", + ); + } + + return { + templateId: "set-1", + imageMode: normalizeDrawingImageMode(value.imageMode), + captures: + value.captures as unknown as InstallationDemoShareDrawingDocument["captures"], + snapshotLineArtUrls: normalizeStringRecord( + value.snapshotLineArtUrls, + "部件线稿快照", + ), + toolSnapshotLineArtUrls: normalizeStringRecord( + value.toolSnapshotLineArtUrls, + "工具线稿快照", + ), + }; +} + +function normalizeStoredDrawingDocument(value: unknown) { + try { + return normalizeDrawingDocument(value); + } catch { + return null; + } } function normalizeNonNegativeInteger(value: unknown, fieldLabel: string) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${fieldLabel}格式不正确`) + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + `${fieldLabel}格式不正确`, + ); } if (value < 0) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${fieldLabel}不能小于 0`) + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + `${fieldLabel}不能小于 0`, + ); } - return Math.round(value) + return Math.round(value); } function normalizeBoolean(value: unknown, fieldLabel: string) { - if (typeof value !== 'boolean') { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${fieldLabel}格式不正确`) + if (typeof value !== "boolean") { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + `${fieldLabel}格式不正确`, + ); } - return value + return value; } -function normalizeDetailView(value: unknown): InstallationDemoShareInitialViewState['detailView'] { - if (value === 'finished' || value === 'exploded' || value === 'numbering' || value === 'steps') { - return value +function normalizeDetailView( + value: unknown, +): InstallationDemoShareInitialViewState["detailView"] { + if ( + value === "finished" || + value === "exploded" || + value === "numbering" || + value === "steps" + ) { + return value; } - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '当前展示阶段格式不正确') + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "当前展示阶段格式不正确", + ); } -function normalizeWorkbenchMode(value: unknown): InstallationDemoShareInitialViewState['mode'] { - if (value === 'edit' || value === 'preview') { - return value +function normalizeWorkbenchMode( + value: unknown, +): InstallationDemoShareInitialViewState["mode"] { + if (value === "edit" || value === "preview") { + return value; } - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '当前模式格式不正确') + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, "当前模式格式不正确"); } function normalizeCameraView(value: unknown, fieldLabel: string) { - if (value === undefined || value === null) return null + if (value === undefined || value === null) return null; if (!isInstallationCameraViewSnapshot(value)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, `${fieldLabel}格式不正确`) + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + `${fieldLabel}格式不正确`, + ); } - return value + return value; } -function normalizeInitialView(value: unknown): InstallationDemoShareInitialViewState | null { - if (value === undefined || value === null) return null +function normalizeInitialView( + value: unknown, +): InstallationDemoShareInitialViewState | null { + if (value === undefined || value === null) return null; if (!isRecord(value)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '当前分享视图格式不正确') + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "当前分享视图格式不正确", + ); } - const detailView = normalizeDetailView(value.detailView) - const mode = normalizeWorkbenchMode(value.mode) - if (detailView !== 'steps' && mode === 'preview') { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '只有安装步骤支持预览模式') + const detailView = normalizeDetailView(value.detailView); + const mode = normalizeWorkbenchMode(value.mode); + if (detailView !== "steps" && mode === "preview") { + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "只有安装步骤支持预览模式", + ); } return { detailView, mode, - activeStepId: normalizeNullableText(value.activeStepId, '当前安装步骤'), - numberingRevealCount: normalizeNonNegativeInteger(value.numberingRevealCount, '当前编号序号'), - selectedNumberingPartId: normalizeNullableText(value.selectedNumberingPartId, '当前编号部件'), - cameraView: normalizeCameraView(value.cameraView, '当前镜头'), - playing: normalizeBoolean(value.playing, '播放状态'), - } + activeStepId: normalizeNullableText(value.activeStepId, "当前安装步骤"), + numberingRevealCount: normalizeNonNegativeInteger( + value.numberingRevealCount, + "当前编号序号", + ), + selectedNumberingPartId: normalizeNullableText( + value.selectedNumberingPartId, + "当前编号部件", + ), + cameraView: normalizeCameraView(value.cameraView, "当前镜头"), + playing: normalizeBoolean(value.playing, "播放状态"), + }; } -function normalizeStoredInitialView(value: unknown): InstallationDemoShareInitialViewState | null { +function normalizeStoredInitialView( + value: unknown, +): InstallationDemoShareInitialViewState | null { try { - return normalizeInitialView(value) + return normalizeInitialView(value); } catch { - return null + return null; } } async function assertCanShare(user: AuthUser) { if (!(await installationDemoAccessService.getAccess(user)).canShare) { - throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限分享安装演示') + throw new AppError( + ErrorCode.AUTH_PERMISSION_DENIED, + "没有权限分享安装演示", + ); } } -function normalizeCreateInput(input: unknown): CreateInstallationDemoShareInput { +function normalizeCreateInput( + input: unknown, +): CreateInstallationDemoShareInput { if (!isRecord(input)) { - throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '分享数据格式不正确') + throw new AppError( + ErrorCode.VALIDATION_INVALID_FORMAT, + "分享数据格式不正确", + ); } return { drawing: normalizeDrawing(input.drawing), - modelUrl: normalizeText(input.modelUrl, '模型地址'), + modelUrl: normalizeText(input.modelUrl, "模型地址"), uploadedModel: normalizeOptionalRecord(input.uploadedModel), - parts: normalizeArray(input.parts, '爆炸图配置'), - numberingParts: normalizeArray(input.numberingParts, '部件编号配置'), - drawingTools: normalizeArray(input.drawingTools ?? [], '工具配置'), - numberingView: normalizeCameraView(input.numberingView, '部件编号镜头'), - numberingSnapshotUrls: normalizeStringRecord(input.numberingSnapshotUrls, '部件编号快照'), - numberingToolSnapshotUrls: normalizeStringRecord(input.numberingToolSnapshotUrls, '工具编号快照'), - steps: normalizeArray(input.steps, '安装步骤'), - stepMaterialsByStepId: normalizeStepMaterialShareSnapshots(input.stepMaterialsByStepId), + parts: normalizeArray(input.parts, "爆炸图配置"), + numberingParts: normalizeArray(input.numberingParts, "部件编号配置"), + drawingTools: normalizeArray(input.drawingTools ?? [], "工具配置"), + numberingView: normalizeCameraView(input.numberingView, "部件编号镜头"), + numberingSnapshotUrls: normalizeStringRecord( + input.numberingSnapshotUrls, + "部件编号快照", + ), + numberingToolSnapshotUrls: normalizeStringRecord( + input.numberingToolSnapshotUrls, + "工具编号快照", + ), + steps: normalizeArray(input.steps, "安装步骤"), + drawingDocument: normalizeDrawingDocument(input.drawingDocument), + stepMaterialsByStepId: normalizeStepMaterialShareSnapshots( + input.stepMaterialsByStepId, + ), initialView: normalizeInitialView(input.initialView), - } + }; } export function normalizeStoredInstallationDemoShareSnapshot( value: unknown, ): InstallationDemoShareSnapshot | null { - if (!isRecord(value)) return null - if (value.version !== 1) return null - if (typeof value.token !== 'string') return null - if (typeof value.modelUrl !== 'string') return null - if (typeof value.createdAt !== 'string') return null - if (value.expiresAt !== null && typeof value.expiresAt !== 'string') return null - if (!Array.isArray(value.parts) || !Array.isArray(value.numberingParts) || !Array.isArray(value.steps)) { - return null + if (!isRecord(value)) return null; + if (value.version !== 1) return null; + if (typeof value.token !== "string") return null; + if (typeof value.modelUrl !== "string") return null; + if (typeof value.createdAt !== "string") return null; + if (value.expiresAt !== null && typeof value.expiresAt !== "string") + return null; + if ( + !Array.isArray(value.parts) || + !Array.isArray(value.numberingParts) || + !Array.isArray(value.steps) + ) { + return null; } - if (!isRecord(value.drawing) || !isRecord(value.createdBy)) return null + if (!isRecord(value.drawing) || !isRecord(value.createdBy)) return null; return { ...(value as unknown as InstallationDemoShareSnapshot), drawingTools: Array.isArray(value.drawingTools) ? value.drawingTools : [], - numberingView: normalizeCameraView(value.numberingView, '部件编号镜头'), + numberingView: normalizeCameraView(value.numberingView, "部件编号镜头"), numberingSnapshotUrls: isRecord(value.numberingSnapshotUrls) ? Object.fromEntries( Object.entries(value.numberingSnapshotUrls).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string', + (entry): entry is [string, string] => typeof entry[1] === "string", ), ) : {}, numberingToolSnapshotUrls: isRecord(value.numberingToolSnapshotUrls) ? Object.fromEntries( Object.entries(value.numberingToolSnapshotUrls).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string', + (entry): entry is [string, string] => typeof entry[1] === "string", ), ) : {}, - stepMaterialsByStepId: normalizeStepMaterialShareSnapshots(value.stepMaterialsByStepId, { - stored: true, - }), + stepMaterialsByStepId: normalizeStepMaterialShareSnapshots( + value.stepMaterialsByStepId, + { + stored: true, + }, + ), + drawingDocument: normalizeStoredDrawingDocument(value.drawingDocument), initialView: normalizeStoredInitialView(value.initialView), - } + }; } export class InstallationDemoShareService { @@ -273,10 +432,10 @@ export class InstallationDemoShareService { rawInput: unknown, options: { token: string; expiresAt: string | null }, ): Promise { - await assertCanShare(user) - const input = normalizeCreateInput(rawInput) - const token = normalizeInstallationDemoShareToken(options.token) - const now = new Date() + await assertCanShare(user); + const input = normalizeCreateInput(rawInput); + const token = normalizeInstallationDemoShareToken(options.token); + const now = new Date(); return { version: 1, @@ -292,6 +451,7 @@ export class InstallationDemoShareService { numberingToolSnapshotUrls: input.numberingToolSnapshotUrls ?? {}, stepMaterialsByStepId: input.stepMaterialsByStepId ?? {}, steps: input.steps, + drawingDocument: input.drawingDocument ?? null, initialView: input.initialView ?? null, createdAt: now.toISOString(), expiresAt: options.expiresAt, @@ -299,43 +459,46 @@ export class InstallationDemoShareService { id: user.id, name: user.name, }, - } + }; } - async create(user: AuthUser, rawInput: unknown): Promise { - const token = nanoid(SHARE_TOKEN_LENGTH) - const now = new Date() - const expiresAt = new Date(now.getTime() + SHARE_TTL_MS) + async create( + user: AuthUser, + rawInput: unknown, + ): Promise { + const token = nanoid(SHARE_TOKEN_LENGTH); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SHARE_TTL_MS); const snapshot = await this.createSnapshot(user, rawInput, { token, expiresAt: expiresAt.toISOString(), - }) + }); await systemConfigService.set( buildShareConfigKey(token), snapshot, `Installation demo temporary share ${snapshot.drawing.code}`, - ) + ); - return snapshot + return snapshot; } async getPublic(rawToken: unknown): Promise { - const token = normalizeInstallationDemoShareToken(rawToken) + const token = normalizeInstallationDemoShareToken(rawToken); const snapshot = normalizeStoredInstallationDemoShareSnapshot( await systemConfigService.get(buildShareConfigKey(token)), - ) + ); if (!snapshot || !snapshot.expiresAt) { - throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, '安装演示链接不存在') + throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, "安装演示链接不存在"); } if (Date.parse(snapshot.expiresAt) <= Date.now()) { - throw new AppError(ErrorCode.SHARE_LINK_EXPIRED, '该安装演示链接已过期') + throw new AppError(ErrorCode.SHARE_LINK_EXPIRED, "该安装演示链接已过期"); } - return snapshot + return snapshot; } } -export const installationDemoShareService = new InstallationDemoShareService() +export const installationDemoShareService = new InstallationDemoShareService();