Merge installation demo drawing generator

This commit is contained in:
WuYongJie 2026-08-15 16:55:43 +08:00
commit 8b1cb357ea
86 changed files with 11702 additions and 1284 deletions

View File

@ -34,6 +34,20 @@ export async function GET(
let allowedKeys = new Set<string>()
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)),

View File

@ -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 },
);
}

View File

@ -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) {

View File

@ -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<string, string>
}
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' ? (
<InstallationDrawingOverviewPage
drawing={snapshot.drawing}
selectedSpu={null}
captures={captures}
imageMode={imageMode}
allowManualCapture={false}
readOnly
/>
) : null}
{panel.kind === 'inventory' ? (
<InstallationDrawingInventoryPage
items={inventoryItems.slice(panel.inventoryStart, panel.inventoryEnd)}
imageMode={imageMode}
transforms={captures.inventoryTransforms}
readOnly
/>
) : null}
{panel.kind === 'actions' ? (
<InstallationDrawingActionsPage
groups={panel.actionGroups ?? []}
captures={captures.actions}
imageMode={imageMode}
snapshotUrls={snapshot.numberingSnapshotUrls}
snapshotLineArtUrls={snapshotLineArtUrls}
allowManualCapture={false}
readOnly
/>
) : null}
</>
)
}
export function InstallationDemoShareDrawingPage({
snapshot,
page,
captures,
inventoryItems,
imageMode,
snapshotLineArtUrls,
}: InstallationDemoShareDrawingPageProps) {
return (
<article
className="grid grid-cols-2 overflow-hidden bg-white p-6 text-[#202526]"
style={{ width: SHARED_DRAWING_PAGE_WIDTH, height: SHARED_DRAWING_PAGE_HEIGHT }}
>
{page.panels.map((panel, panelIndex) => (
<section
key={panel.id}
className={`min-h-0 min-w-0 ${panelIndex > 0 ? 'border-l border-[#202526]/20 pl-4' : 'pr-4'}`}
>
<InstallationDemoShareDrawingPanel
snapshot={snapshot}
panel={panel}
captures={captures}
inventoryItems={inventoryItems}
imageMode={imageMode}
snapshotLineArtUrls={snapshotLineArtUrls}
/>
</section>
))}
{page.panels.length === 1 ? (
<section className="border-l border-[#202526]/20 pl-4" />
) : null}
</article>
)
}
export function InstallationDemoShareDrawingHalfPage({
snapshot,
panel,
captures,
inventoryItems,
imageMode,
snapshotLineArtUrls,
}: InstallationDemoShareDrawingPanelProps) {
return (
<article
className="overflow-hidden bg-white px-5 py-6 text-[#202526]"
style={{ width: SHARED_DRAWING_HALF_PAGE_WIDTH, height: SHARED_DRAWING_PAGE_HEIGHT }}
>
<section className="h-full min-h-0 min-w-0">
<InstallationDemoShareDrawingPanel
snapshot={snapshot}
panel={panel}
captures={captures}
inventoryItems={inventoryItems}
imageMode={imageMode}
snapshotLineArtUrls={snapshotLineArtUrls}
/>
</section>
</article>
)
}

View File

@ -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: () => <div data-testid="animation-view" />,
}));
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;
}) => (
<div data-image-mode={imageMode} data-testid="full-page">
{page.id}
</div>
),
InstallationDemoShareDrawingHalfPage: ({
panel,
imageMode,
}: {
panel: { id: string };
imageMode: string;
}) => (
<div data-image-mode={imageMode} data-testid="half-page">
{panel.id}
</div>
),
}));
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(
<InstallationDemoShareDrawingViewer
snapshot={snapshot}
presentation={presentation}
/>,
);
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(
<InstallationDemoShareDrawingViewer
snapshot={snapshot}
presentation={presentation}
/>,
);
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(<InstallationDemoShareViewerShell snapshot={snapshot} />);
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(
<InstallationDemoShareDrawingViewer
snapshot={snapshot}
presentation={{ ...presentation, imageMode: "original" }}
/>,
);
expect(screen.getAllByTestId("half-page")[0]).toHaveAttribute(
"data-image-mode",
"original",
);
});
});

View File

@ -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<HTMLDivElement | null>(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 (
<main className="flex h-dvh items-center justify-center bg-[#eef1f2] px-5 pt-16 text-[#2d3335]">
<section className="w-full max-w-[420px] border border-black/[0.08] bg-white p-6 text-center shadow-sm">
<FileText className="mx-auto text-[#7a8284]" size={24} />
<p className="mt-3 text-[16px] font-black">图纸尚未完成</p>
<p className="mt-2 text-[12px] font-semibold text-[#697173]">
{presentation.unavailableReason ??
"请联系业务人员重新同步分享内容。"}
</p>
</section>
</main>
);
}
const captures = presentation.captures;
return (
<main className="flex h-dvh min-h-0 flex-col overflow-hidden bg-[#e9edef] pt-16 text-[#202526]">
<div
ref={stageRef}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
>
<div
className={`mx-auto grid w-full ${mobileLayout ? "gap-3 px-2 py-3" : "gap-6 px-6 py-6"}`}
>
{mobileLayout
? halfPages.map((panel) => (
<div
key={panel.id}
className="relative mx-auto shrink-0 shadow-[0_10px_30px_rgba(41,50,52,0.14)]"
style={{
width: SHARED_DRAWING_HALF_PAGE_WIDTH * halfPageScale,
height: SHARED_DRAWING_PAGE_HEIGHT * halfPageScale,
}}
>
<div
className="absolute left-0 top-0 origin-top-left"
style={{ transform: `scale(${halfPageScale})` }}
>
<InstallationDemoShareDrawingHalfPage
snapshot={snapshot}
panel={panel}
captures={captures}
inventoryItems={presentation.inventoryItems}
imageMode={presentation.imageMode}
snapshotLineArtUrls={presentation.snapshotLineArtUrls}
/>
</div>
</div>
))
: pages.map((page) => (
<div
key={page.id}
className="relative mx-auto shrink-0 shadow-[0_14px_45px_rgba(41,50,52,0.16)]"
style={{
width: SHARED_DRAWING_PAGE_WIDTH * pageScale,
height: SHARED_DRAWING_PAGE_HEIGHT * pageScale,
}}
>
<div
className="absolute left-0 top-0 origin-top-left"
style={{ transform: `scale(${pageScale})` }}
>
<InstallationDemoShareDrawingPage
snapshot={snapshot}
page={page}
captures={captures}
inventoryItems={presentation.inventoryItems}
imageMode={presentation.imageMode}
snapshotLineArtUrls={presentation.snapshotLineArtUrls}
/>
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@ -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<ShareView>("animation");
const drawingPresentation = useMemo(
() => buildInstallationDemoShareDrawingPresentation(snapshot),
[snapshot],
);
return (
<div className="relative h-dvh min-h-[100svh] overflow-hidden bg-[#eef1f2]">
<nav
className="absolute left-1/2 top-3 z-50 grid h-12 -translate-x-1/2 grid-cols-2 rounded-full border border-black/[0.08] bg-white/92 p-0.5 shadow-sm backdrop-blur-xl"
aria-label="安装演示查看方式"
>
<button
type="button"
onClick={() => setActiveView("animation")}
className={`inline-flex h-11 min-w-[84px] items-center justify-center gap-1.5 whitespace-nowrap rounded-full px-2 text-[11px] font-black sm:min-w-[108px] sm:px-3 ${activeView === "animation" ? "bg-[#2d3335] text-white" : "text-[#697173]"}`}
>
<Box size={13} /> 安装动画
</button>
<button
type="button"
disabled={!drawingPresentation.available}
onClick={() => setActiveView("drawing")}
title={drawingPresentation.unavailableReason ?? "查看安装图纸"}
className={`inline-flex h-11 min-w-[84px] items-center justify-center gap-1.5 whitespace-nowrap rounded-full px-2 text-[11px] font-black sm:min-w-[108px] sm:px-3 ${activeView === "drawing" ? "bg-[#2d3335] text-white" : "text-[#697173]"} disabled:cursor-not-allowed disabled:text-[#a6adaf]`}
>
<FileText size={13} />
{drawingPresentation.available ? "安装图纸" : "图纸未完成"}
</button>
</nav>
{activeView === "animation" ? (
<InstallationDemoShareViewerClient snapshot={snapshot} />
) : (
<InstallationDemoShareDrawingViewer
snapshot={snapshot}
presentation={drawingPresentation}
/>
)}
</div>
);
}

View File

@ -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",
}),
);
});
});

View File

@ -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<string, string>;
}
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<string, InstallationDrawingOverviewTool>(
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<typeof buildInstallationDrawingActionMarkerSources>,
) {
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,
};
}

View File

@ -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<string, string>) =>
Object.fromEntries(Object.entries(urls).map(([id, url]) => [id, toPublicObjectUrl(url)]))
const toPublicDrawingImage = <T extends { originalUrl: string | null; lineArtUrl: string | null }>(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 <InstallationDemoShareViewerClient snapshot={withPublicModelUrl(snapshot)} />
return <InstallationDemoShareViewerShell snapshot={withPublicModelUrl(snapshot)} />
}
return <ShareUnavailable title={unavailableTitle} description={unavailableDescription} />

View File

@ -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}
<section className="mt-3 rounded-[8px] border border-[#2d3335]/15 bg-white p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[12px] font-black text-[#2d3335]">安装图纸</p>
<p className="mt-1 text-[10px] font-semibold leading-4 text-[#8b9092]">
先打开固定模板,再手动截取成品、爆炸图和每个动作画面。
</p>
</div>
<button
type="button"
onClick={onGenerateDrawing}
disabled={steps.length === 0}
className="shrink-0 rounded-[6px] border border-[#2d3335]/30 px-3 py-2 text-[11px] font-black text-[#2d3335] transition hover:bg-[#f5f7f8] disabled:cursor-not-allowed disabled:opacity-40"
>
制作安装图纸
</button>
</div>
</section>
</div>
</div>
)

View File

@ -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<string, string>
snapshotLineArtUrls: Record<string, string>
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<string, string>
snapshotLineArtUrls: Record<string, string>
imageMode: InstallationDrawingImageMode
}
function ActionMaterials({ capture, snapshotUrls, snapshotLineArtUrls, imageMode }: ActionMaterialProps) {
if (capture.materials.length === 0) return null
return (
<div className="flex min-w-0 flex-1 flex-wrap justify-end gap-1">
{capture.materials.map((item) => {
const imageUrl = item.snapshotPartId
? (imageMode === 'line-art' ? snapshotLineArtUrls[item.snapshotPartId] : snapshotUrls[item.snapshotPartId])
: null
return (
<div
key={`${item.kind}:${item.code}:${item.name}`}
className="flex h-11 min-w-0 max-w-[124px] items-center gap-1.5 border border-[#202526]/15 bg-white px-1.5"
title={`${item.code} ${item.name} x ${item.quantity}`}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden bg-white">
{imageUrl ? (
<img src={imageUrl} alt={item.name} className="h-full w-full object-contain" />
) : (
<span className="text-[5px] font-black text-[#7a8284]">{imageMode === 'line-art' ? '未生成线稿' : item.kind === 'hardware' ? '五金' : '板件'}</span>
)}
</div>
<div className="min-w-0">
<p className="truncate text-[8px] font-black leading-none">{item.code || '-'}</p>
<p className="mt-1 text-[8px] font-black leading-none text-[#596164]">x {item.quantity}</p>
</div>
</div>
)
})}
</div>
)
}
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 (
<div className="flex h-full min-h-0 flex-col">
<div className="shrink-0 border-b-2 border-[#202526] pb-1.5">
<p className="text-[15px] font-black">安装动作分解</p>
<p className="text-[6px] font-bold text-[#687072]">ASSEMBLY ACTIONS</p>
</div>
<div className="mt-2 grid min-h-0 flex-1 grid-cols-2 grid-rows-2 gap-2">
{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 (
<figure
key={action.id}
className="relative flex min-h-0 min-w-0 flex-col border border-[#202526]/15 bg-white p-1.5"
>
<div className="flex min-h-11 shrink-0 items-start gap-1.5">
<span className="flex h-5 min-w-5 shrink-0 items-center justify-center bg-[#202526] px-1 text-[8px] font-black text-white">
{action.sequence}
</span>
{capture ? <ActionMaterials capture={capture} snapshotUrls={snapshotUrls} snapshotLineArtUrls={snapshotLineArtUrls} imageMode={imageMode} /> : null}
</div>
<div className="relative mt-1 min-h-0 flex-1 overflow-hidden bg-white">
{capture && captured ? (
<InstallationDrawingImageFrame
image={capture.image}
mode={imageMode}
alt={`安装动作 ${action.sequence}`}
/>
) : (
<div className="flex h-full items-center justify-center text-[8px] font-bold text-[#8a9293]">待截取</div>
)}
{capture && captured && capture.image.numberingMarkers.length > 0 ? (
<InstallationDrawingNumberMarkers
markers={capture.image.numberingMarkers}
size="large"
editable={!readOnly}
onCommit={onCommitNumberingMarkers
? (markers) => onCommitNumberingMarkers(target, markers)
: undefined}
/>
) : null}
</div>
{capture && capture.tools.length > 0 ? (
<div className="mt-1 flex shrink-0 flex-wrap gap-1">
{capture.tools.map((tool) => (
<span key={`${tool.code}:${tool.name}`} className="border border-[#202526]/15 px-1 py-0.5 text-[6px] font-black text-[#596164]">工具 {tool.name}</span>
))}
</div>
) : null}
{!readOnly ? <div className="absolute bottom-1.5 right-1.5 z-10 flex gap-1 print:hidden">
{capture && captured && capture.materials.some((item) => item.kind === 'part') ? (
<button
type="button"
onClick={() => onManageNumberingMarkers?.(target)}
title="管理编号"
aria-label={`管理安装动作 ${action.sequence} 编号`}
className="inline-flex h-6 w-6 items-center justify-center rounded-[4px] border border-black/10 bg-white text-[#596164] shadow-sm"
>
<ListChecks size={10} />
</button>
) : null}
{capture && captured ? (
<button
type="button"
onClick={() => onAdjustImage?.(target)}
title="调整画面"
className="inline-flex h-6 w-6 items-center justify-center rounded-[4px] border border-black/10 bg-white text-[#596164] shadow-sm"
>
<Maximize2 size={10} />
</button>
) : null}
{allowManualCapture ? (
<button
type="button"
onClick={() => onCaptureTarget?.(target)}
className="h-6 rounded-[4px] bg-[#2d3335] px-1.5 text-[6px] font-black text-white shadow-sm"
>
{captured ? '重新截取' : '去截取'}
</button>
) : null}
</div> : null}
</figure>
)
})}
</div>
</div>
)
}

View File

@ -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 (
<div className="flex min-w-0 items-center gap-2">
{settled ? (
<span className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-[#c7cecf] bg-white px-3 text-[11px] font-black text-[#4e585a]">
<CheckCircle2 size={13} />
已带入 {completedCount}/{totalCount}
</span>
) : running ? (
<button
type="button"
onClick={onPause}
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-[#aeb6b8] bg-white px-3 text-[11px] font-black text-[#303638]"
>
{phase === "finalizing" ? (
<LoaderCircle size={13} className="animate-spin" />
) : (
<Square size={11} />
)}
{phase === "finalizing"
? "正在固定"
: `暂停 ${completedCount}/${totalCount}`}
</button>
) : (
<button
type="button"
onClick={onStart}
disabled={loading || totalCount === 0}
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] bg-[#2d3335] px-3 text-[11px] font-black text-white disabled:cursor-not-allowed disabled:opacity-45"
>
{loading ? (
<LoaderCircle size={13} className="animate-spin" />
) : (
<Camera size={13} />
)}
{loading ? "读取中" : startLabel}
</button>
)}
{hasDraftBatch && !running ? (
<span className="text-[10px] font-bold text-[#6b7477]">
已完成 {completedCount}/{totalCount}
</span>
) : null}
{configurationChanged ? (
<span className="text-[10px] font-black text-[#9a6513]">
配置已变更
</span>
) : null}
{error && !running ? (
<span
className="max-w-[220px] truncate text-[10px] font-bold text-[#b3473f]"
title={error}
>
{error}
</span>
) : null}
</div>
);
}

View File

@ -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 (
<div className="fixed bottom-5 left-1/2 z-[75] w-[min(680px,calc(100vw-32px))] -translate-x-1/2 border border-black/10 bg-white p-3 shadow-[0_12px_34px_rgba(34,42,44,0.2)]">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1"><p className="text-[10px] font-bold text-[#8a9293]">当前图纸位置</p><p className="mt-0.5 truncate text-[13px] font-black text-[#2d3335]">{getTargetLabel(target)}</p></div>
<button type="button" onClick={onReturnToTemplate} className="inline-flex h-9 items-center gap-1.5 rounded-[6px] border border-black/10 px-3 text-[11px] font-black text-[#596164]"><LayoutTemplate size={14} /> 返回模板</button>
<button type="button" onClick={onCapture} disabled={capturing || !captureReady} className="inline-flex h-9 min-w-[150px] items-center justify-center gap-1.5 rounded-[6px] bg-[#2d3335] px-4 text-[11px] font-black text-white disabled:opacity-50">
{!captureReady ? <LoaderCircle size={14} className="animate-spin" /> : <Camera size={14} />}
{!captureReady ? '正在准备完整画面' : capturing ? '正在生成原图和线稿' : '截取当前画面'}
</button>
<button type="button" onClick={onCancel} aria-label="退出截图" title="退出截图" className="inline-flex h-9 w-9 items-center justify-center rounded-[6px] border border-black/10 text-[#596164]"><X size={15} /></button>
</div>
{error ? <p className="mt-2 bg-[#fff2f0] px-2.5 py-1.5 text-[10px] font-bold text-[#a63f32]">{error}</p> : null}
</div>
)
}

View File

@ -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(
<InstallationDrawingConfirmationControl
confirmedAt={null}
confirming={false}
disabledReason={null}
onConfirm={onConfirm}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "确认图纸样式" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("shows a confirmed drawing as a fixed state", () => {
render(
<InstallationDrawingConfirmationControl
confirmedAt="2026-08-15T06:00:00.000Z"
confirming={false}
disabledReason={null}
onConfirm={jest.fn()}
/>,
);
expect(screen.getByRole("button", { name: "图纸已确认" })).toBeDisabled();
});
});

View File

@ -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 (
<button
type="button"
onClick={onConfirm}
disabled={disabled}
title={
confirmed
? `已于 ${new Date(confirmedAt as string).toLocaleString("zh-CN")} 确认`
: disabledReason ?? "固定当前图纸样式并同步永久二维码"
}
className={`inline-flex h-8 min-w-[112px] items-center justify-center gap-1 rounded-[6px] px-3 text-[11px] font-black ${
confirmed
? "border border-[#8bb69d] bg-[#edf7f1] text-[#397253]"
: "bg-[#2d3335] text-white disabled:cursor-not-allowed disabled:bg-[#aeb4b5]"
}`}
>
{confirming ? (
<LoaderCircle size={13} className="animate-spin" />
) : (
<CheckCircle2 size={13} />
)}
{label}
</button>
);
}

View File

@ -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<HTMLDivElement | null>(null)
const dragRef = useRef<DragState | null>(null)
const [mode, setMode] = useState<InstallationDrawingImageMode>(initialMode)
const [transform, setTransform] = useState<InstallationDrawingImageTransform>(image.transform)
const [numberingMarkers, setNumberingMarkers] = useState<InstallationDrawingNumberMarker[]>(
() => image.numberingMarkers.map((marker) => ({ ...marker })),
)
const beginDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div className="fixed inset-0 z-[95] flex items-center justify-center bg-black/35 p-5 print:hidden">
<div role="dialog" aria-modal="true" aria-label="调整图纸画面" className="w-full max-w-[760px] border border-black/10 bg-white shadow-[0_24px_70px_rgba(25,31,33,0.28)]">
<header className="flex items-center justify-between border-b border-black/10 px-5 py-3">
<div>
<p className="text-[14px] font-black text-[#2d3335]">调整画面</p>
<p className="mt-0.5 text-[10px] font-bold text-[#7a8284]">拖动空白处调整模型;红色编号可单独拖动</p>
</div>
<button type="button" onClick={onClose} aria-label="关闭调整" title="关闭" className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-black/10 text-[#596164]"><X size={15} /></button>
</header>
<div className="grid gap-5 p-5 md:grid-cols-[1fr_220px]">
<div
ref={frameRef}
onPointerDown={beginDrag}
onPointerMove={moveImage}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={`relative mx-auto max-h-[58vh] w-full max-w-[460px] touch-none cursor-grab overflow-hidden border border-[#202526]/20 bg-white active:cursor-grabbing ${frameRatioClass}`}
>
<InstallationDrawingImageFrame
image={previewImage}
mode={mode}
alt="待调整图纸画面"
/>
{numberingSize && numberingMarkers.length > 0 ? (
<InstallationDrawingNumberMarkers
markers={numberingMarkers}
size={numberingSize}
editable
onChange={setNumberingMarkers}
/>
) : null}
<span className="pointer-events-none absolute left-2 top-2 inline-flex h-7 items-center gap-1 border border-black/10 bg-white/95 px-2 text-[9px] font-black text-[#596164]"><Move size={12} /> 拖动定位</span>
</div>
<div className="flex flex-col gap-5">
<div>
<p className="mb-2 text-[10px] font-black text-[#596164]">显示版本</p>
<div className="grid grid-cols-2 border border-black/10 bg-[#f4f6f6] p-1">
<button type="button" onClick={() => setMode('original')} className={`h-8 text-[10px] font-black ${mode === 'original' ? 'bg-white text-[#202526] shadow-sm' : 'text-[#7a8284]'}`}>原图</button>
<button type="button" onClick={() => setMode('line-art')} className={`h-8 text-[10px] font-black ${mode === 'line-art' ? 'bg-white text-[#202526] shadow-sm' : 'text-[#7a8284]'}`}>打印线稿</button>
</div>
</div>
<label className="block">
<span className="mb-2 flex items-center justify-between text-[10px] font-black text-[#596164]"><span>画面缩放</span><span>{Math.round(transform.scale * 100)}%</span></span>
<input
type="range"
min="50"
max="250"
step="1"
value={Math.round(transform.scale * 100)}
onChange={(event) => setTransform((current) => ({ ...current, scale: Number(event.target.value) / 100 }))}
className="w-full accent-[#2d3335]"
/>
</label>
<button type="button" onClick={() => setTransform({ ...DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM })} className="inline-flex h-9 items-center justify-center gap-1.5 rounded-[6px] border border-black/10 text-[10px] font-black text-[#596164]"><RotateCcw size={13} /> 重置画面</button>
<div className="mt-auto grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className="h-9 rounded-[6px] border border-black/10 text-[10px] font-black text-[#596164]">取消</button>
<button type="button" onClick={() => onSave(transform, numberingMarkers)} className="inline-flex h-9 items-center justify-center gap-1.5 rounded-[6px] bg-[#2d3335] text-[10px] font-black text-white"><Check size={13} /> 保存位置</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -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(
<InstallationDrawingImageFrame
image={{
originalUrl: 'original.png',
lineArtUrl: 'line-art.png',
transform: { scale: 1.4, offsetX: 3, offsetY: -2 },
numberingMarkers: [],
numberingMarkerCountOverrides: {},
captureAspectRatio: 1.4801,
}}
mode="line-art"
alt="产品爆炸图"
/>,
)
const screenImage = container.querySelector<HTMLElement>('.installation-drawing-screen-image')
const printImage = container.querySelector<HTMLElement>('.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')
})
})

View File

@ -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 (
<>
<div
className="installation-drawing-screen-image pointer-events-none absolute w-full select-none print:hidden"
style={{ ...transformStyle, aspectRatio: image.captureAspectRatio }}
>
<img
src={src}
alt={alt}
draggable={false}
className="absolute inset-0 h-full w-full object-fill"
/>
</div>
<div
aria-hidden="true"
className="installation-drawing-print-image pointer-events-none absolute hidden select-none print:block"
style={{
...printPositionStyle,
width: `${image.transform.scale * 100}%`,
aspectRatio: image.captureAspectRatio,
}}
>
<img
src={src}
alt=""
draggable={false}
className="block h-full w-full object-fill"
/>
</div>
</>
)
}
return (
<>
<img
src={src}
alt={alt}
draggable={false}
className="installation-drawing-screen-image pointer-events-none absolute h-full w-full select-none object-contain print:hidden"
style={transformStyle}
/>
<div
aria-hidden="true"
className="installation-drawing-print-image pointer-events-none absolute hidden select-none print:block"
style={{
...printPositionStyle,
width: `${image.transform.scale * 100}%`,
height: `${image.transform.scale * 100}%`,
}}
>
<img
src={src}
alt=""
draggable={false}
className="block h-full w-full object-contain"
/>
</div>
</>
)
}

View File

@ -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<string, InstallationDrawingImageTransform>
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 (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-7 shrink-0 items-end border-b-2 border-[#202526] pb-1.5">
<p className="text-[15px] font-black">03 部件</p>
</div>
<div className="mt-2 grid min-h-0 flex-1 grid-cols-4 grid-rows-4 gap-1.5">
{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 (
<article
key={itemKey}
className="relative flex min-h-0 min-w-0 flex-col overflow-hidden border border-[#202526]/25 bg-white p-1"
>
<div className="shrink-0 leading-none">
<p className="truncate text-[10px] font-black text-[#202526]">{item.code || '-'}</p>
<p className="mt-1 text-[9px] font-black text-[#202526]">x{item.quantity}</p>
</div>
<div className="relative mt-1 flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-white">
{imageUrl ? (
<InstallationDrawingImageFrame
image={image}
mode={imageMode}
alt={item.name || item.code}
/>
) : (
<span className="max-w-full truncate px-1 text-[8px] font-black text-[#7b8586]">
{imageMode === 'line-art' ? '未生成线稿' : '暂无图片'}
</span>
)}
{imageUrl && !readOnly ? (
<button
type="button"
onClick={() => onAdjustImage?.({ kind: 'inventory', id: itemKey })}
title="调整部件图片"
aria-label={`调整 ${item.code || item.name || '部件'} 图片`}
className="absolute bottom-1 right-1 z-10 inline-flex h-6 w-6 items-center justify-center border border-black/10 bg-white/95 text-[#596164] shadow-sm print:hidden"
>
<Maximize2 size={11} />
</button>
) : null}
</div>
<p className="mt-1 shrink-0 truncate text-[9px] font-black leading-tight text-[#202526]">
{item.name || item.code || '未命名'}
</p>
</article>
)
})}
</div>
</div>
)
}

View File

@ -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(
<InstallationDrawingMarkerManagerDialog
title="产品爆炸图"
image={image}
sources={sources}
onSave={onSave}
onClose={jest.fn()}
/>,
)
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(
<InstallationDrawingMarkerManagerDialog
title="产品爆炸图"
image={image}
sources={sources}
onSave={onSave}
onClose={jest.fn()}
/>,
)
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()
})
})

View File

@ -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<string, number>,
) => 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<Record<string, number>>(
() => ({ ...image.numberingMarkerCountOverrides }),
)
const [markers, setMarkers] = useState<InstallationDrawingNumberMarker[]>(
() => 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 (
<div className="fixed inset-0 z-[98] flex items-center justify-center bg-black/35 p-5 print:hidden">
<div
role="dialog"
aria-modal="true"
aria-label="管理图纸编号"
className="flex max-h-[82vh] w-full max-w-[820px] flex-col border border-black/10 bg-white shadow-[0_24px_70px_rgba(25,31,33,0.28)]"
>
<header className="flex shrink-0 items-center justify-between border-b border-black/10 px-5 py-3">
<div>
<p className="text-[14px] font-black text-[#2d3335]">编号管理 · {title}</p>
<p className="mt-0.5 text-[10px] font-bold text-[#7a8284]">
产品用量保持原值;图纸标注数可独立调整。待放置编号保存后可直接拖到模型上。
</p>
</div>
<button type="button" onClick={onClose} aria-label="关闭编号管理" title="关闭" className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-black/10 text-[#596164]"><X size={15} /></button>
</header>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
<div className="mb-3 flex items-center justify-between border-b border-black/10 pb-2 text-[10px] font-black text-[#596164]">
<span>共 {markers.length} 个图纸编号</span>
<span className={totalUnplaced > 0 ? 'text-[#b14b37]' : 'text-[#477b69]'}>
{totalUnplaced > 0 ? `待放置 ${totalUnplaced}` : '全部已放置'}
</span>
</div>
<div className="grid gap-2">
{sources.map((source) => {
const sourceMarkers = markers.filter((marker) => markerBelongsToSource(marker, source))
const unplacedCount = sourceMarkers.filter((marker) => !marker.placed).length
return (
<section key={source.id} data-drawing-marker-source={source.id} data-drawing-marker-code={source.code} className="grid grid-cols-[minmax(130px,1fr)_90px_150px_minmax(180px,1.4fr)] items-center gap-3 border border-black/10 px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-[12px] font-black text-[#202526]">{source.code} · {source.name}</p>
<p className="mt-1 text-[9px] font-bold text-[#7a8284]">产品用量 {source.quantity}</p>
</div>
<div className="text-center">
<p className="text-[9px] font-bold text-[#7a8284]">待放置</p>
<p data-drawing-marker-unplaced-count className={`mt-1 text-[12px] font-black ${unplacedCount > 0 ? 'text-[#b14b37]' : 'text-[#477b69]'}`}>{unplacedCount}</p>
</div>
<div className="flex items-center justify-center gap-1.5">
<button type="button" onClick={() => setSourceCount(source, sourceMarkers.length - 1)} disabled={sourceMarkers.length === 0} aria-label={`减少 ${source.code} 标注`} title="减少一个标注" className="inline-flex h-7 w-7 items-center justify-center border border-black/10 text-[#596164] disabled:opacity-30"><Minus size={12} /></button>
<span data-drawing-marker-count className="min-w-9 text-center text-[13px] font-black text-[#202526]">{sourceMarkers.length}</span>
<button type="button" onClick={() => setSourceCount(source, sourceMarkers.length + 1)} disabled={sourceMarkers.length >= 240} aria-label={`增加 ${source.code} 标注`} title="增加一个标注" className="inline-flex h-7 w-7 items-center justify-center border border-black/10 text-[#596164] disabled:opacity-30"><Plus size={12} /></button>
</div>
<div className="flex min-w-0 flex-wrap gap-1">
{sourceMarkers.map((marker, index) => (
<span key={marker.id} className={`inline-flex h-7 items-center gap-1 border px-1.5 text-[9px] font-black ${marker.placed ? 'border-black/10 text-[#596164]' : 'border-[#d79a62] bg-[#fff7ed] text-[#a75d29]'}`}>
{source.code}-{index + 1}{marker.placed ? '' : ' 待放置'}
<button type="button" onClick={() => deleteMarker(source, marker.id)} aria-label={`删除 ${source.code}-${index + 1}`} title="删除这个编号" className="inline-flex h-4 w-4 items-center justify-center text-[#8a9293] hover:text-[#b14b37]"><Trash2 size={10} /></button>
</span>
))}
{sourceMarkers.length === 0 ? <span className="text-[9px] font-bold text-[#a1a7a8]">不在图纸标注</span> : null}
</div>
</section>
)
})}
</div>
</div>
<footer className="grid shrink-0 grid-cols-2 gap-2 border-t border-black/10 px-5 py-3">
<button type="button" onClick={onClose} className="h-9 rounded-[6px] border border-black/10 text-[10px] font-black text-[#596164]">取消</button>
<button type="button" onClick={() => onSave(markers, countOverrides)} className="inline-flex h-9 items-center justify-center gap-1.5 rounded-[6px] bg-[#2d3335] text-[10px] font-black text-white"><Check size={13} /> 保存编号</button>
</footer>
</div>
</div>
)
}

View File

@ -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(
<div className="relative h-[100px] w-[200px]">
<InstallationDrawingNumberMarkers
markers={[{ id: 'part-1:0', partId: 'part-1', code: '1', x: 0.25, y: 0.5, placed: true }]}
size="large"
editable
onChange={onChange}
/>
</div>,
)
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(
<div className="relative h-[100px] w-[200px]">
<InstallationDrawingNumberMarkers
markers={[{ id: 'part-2:0', partId: 'part-2', code: '2', x: 0.4, y: 0.3, placed: true }]}
size="small"
editable
onCommit={onCommit}
/>
</div>,
)
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(
<div className="relative h-[100px] w-[200px]">
<InstallationDrawingNumberMarkers
markers={[{ id: 'part-8:0', partId: 'part-8', code: '8', x: 0.9, y: 0.1, placed: false }]}
size="small"
editable
onChange={onChange}
/>
</div>,
)
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(
<div className="relative h-[100px] w-[200px]">
<InstallationDrawingNumberMarkers
markers={[{ id: 'part-9:0', partId: 'part-9', code: '9', x: 0.35, y: 0.45, placed: true }]}
size="small"
editable
onCommit={jest.fn()}
/>
</div>,
)
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')
})
})

View File

@ -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<HTMLDivElement | null>(null)
const dragRef = useRef<MarkerDragState | null>(null)
const latestMarkersRef = useRef(markers)
const [draggedMarkers, setDraggedMarkers] = useState<InstallationDrawingNumberMarker[] | null>(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<HTMLSpanElement>,
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<HTMLSpanElement>) => {
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<HTMLSpanElement>) => {
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 (
<>
<div
ref={layerRef}
className="drawing-number-marker-screen-layer pointer-events-none absolute inset-0 z-[2] select-none print:hidden"
aria-hidden={editable ? undefined : true}
>
{currentMarkers.map((marker) => (
<span
key={marker.id}
role={editable ? 'button' : undefined}
aria-label={editable ? `移动编号 ${marker.code}` : undefined}
title={editable ? `拖动编号 ${marker.code}` : undefined}
data-drawing-number-marker={marker.id}
data-drawing-number-marker-placed={marker.placed ? 'true' : 'false'}
data-drawing-number-marker-editable={editable ? 'true' : 'false'}
onPointerDown={(event) => 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}
</span>
))}
</div>
<div className="drawing-number-marker-print-layer pointer-events-none absolute inset-0 z-[2] hidden overflow-hidden select-none print:block">
{markers.map((marker) => (
<span
key={marker.id}
data-drawing-print-number-marker={marker.id}
className={`drawing-number-marker-print drawing-number-marker-print-${size} absolute flex items-center justify-center rounded-full border-[#ef3828] bg-white font-black leading-none text-[#ef3828]`}
style={{ left: `${marker.x * 100}%`, top: `${marker.y * 100}%` }}
>
{marker.code}
</span>
))}
</div>
</>
)
}

View File

@ -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 (
<section className="mt-2 min-h-0 flex-1 border-t border-[#202526]/25 pt-1.5">
<div className="mb-1 flex items-baseline justify-between">
<p className="text-[11px] font-black">04 安装</p>
<p className="text-[6px] font-bold text-[#8a9293]">ASSEMBLY ACTIONS</p>
</div>
<div className="grid min-h-0 grid-cols-2 gap-1 overflow-hidden">
{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 (
<div key={action.id} className="relative min-w-0 border border-[#202526]/12 bg-white p-1">
<div className="relative h-[38px] overflow-hidden bg-white">
{capture && captured ? <InstallationDrawingImageFrame image={capture.image} mode="line-art" alt={`安装动作 ${action.sequence}`} /> : <div className="flex h-full items-center justify-center text-[7px] font-bold text-[#8a9293]">待截取</div>}
</div>
<div className="mt-0.5 flex items-center gap-1">
<p className="min-w-0 flex-1 truncate text-[6px] font-black">安装动作 {action.sequence}</p>
<button type="button" onClick={() => onCaptureTarget(target)} className="shrink-0 rounded-[3px] border border-[#202526]/25 px-1 py-0.5 text-[6px] font-black print:hidden">{captured ? '重截' : '截取'}</button>
</div>
</div>
)
})}
</div>
</section>
)
}

View File

@ -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<InstallationDrawingInventoryItem, 'kind' | 'id'>,
) {
return `${item.kind}:${item.id}`
}
interface Props {
parts: InstallationPartItem[]
numberingParts: InstallationPartItem[]
tools: InstallationDrawingOverviewTool[]
snapshotUrls: Record<string, string>
snapshotLineArtUrls: Record<string, string>
toolSnapshotUrls: Record<string, string>
toolSnapshotLineArtUrls: Record<string, string>
}
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 (
<section className="min-h-0 shrink-0">
<div className="mb-2 flex items-end justify-between border-b border-[#202526]/25 pb-1">
<div><p className="text-[12px] font-black">{title}</p><p className="text-[6px] font-bold text-[#8a9293]">PARTS / HARDWARE / TOOLS</p></div>
{showCount ? <span className="text-[7px] font-bold text-[#687072]">共 {items.length} 项</span> : null}
</div>
{items.length > 0 ? (
<div className="grid grid-cols-4 gap-2">
{items.map((item) => (
<div key={`${item.kind}:${item.id}`} className="min-w-0 border border-[#202526]/12 p-1.5">
<div className="flex aspect-square w-full items-center justify-center overflow-hidden bg-[#f3f5f5]">
{item.originalImageUrl ? <img src={item.originalImageUrl} alt={item.name} className="h-full w-full object-contain" /> : <span className="max-w-full truncate px-1 text-[8px] font-black text-[#7b8586]">{item.code || '-'}</span>}
</div>
<div className="mt-1 min-w-0"><div className="flex items-center justify-between gap-1"><p className="truncate text-[8px] font-black">{item.code || '-'}</p><p className="shrink-0 text-[7px] font-black">x {item.quantity}</p></div><p className="truncate text-[7px] font-bold text-[#697274]">{labels[item.kind]} · {item.name || '未命名'}</p></div>
</div>
))}
</div>
) : <div className="flex h-[60px] items-center justify-center border border-dashed border-[#202526]/20 text-[8px] font-bold text-[#8a9293]">暂无部件数据</div>}
</section>
)
}
export function InstallationDrawingOverviewInventory(props: Props) {
return <InstallationDrawingInventoryGrid items={buildInstallationDrawingInventoryItems(props)} />
}

View File

@ -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<InstallationDrawingNode, 'code' | 'modelCode' | 'spuCode'> | null
selectedSpu: Pick<InstallationSpuNode, 'code'> | 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 (
<div className="relative min-h-0 overflow-hidden border border-[#202526]/15 bg-white">
{captured ? (
<InstallationDrawingImageFrame
image={image}
mode={mode}
alt={alt}
/>
) : (
<div className="flex h-full items-center justify-center text-[8px] font-black text-[#8a9293]">
{emptyLabel}
</div>
)}
{captured && numberingSize && image.numberingMarkers.length > 0 ? (
<InstallationDrawingNumberMarkers
markers={image.numberingMarkers}
size={numberingSize}
editable={!readOnly}
onCommit={onCommitNumberingMarkers
? (markers) => onCommitNumberingMarkers(target, markers)
: undefined}
/>
) : null}
{!readOnly ? <div className="absolute bottom-1.5 right-1.5 z-10 flex gap-1 print:hidden">
{captured && onManageNumberingMarkers ? (
<button type="button" onClick={() => onManageNumberingMarkers(target)} title="管理编号" aria-label="管理编号" className="inline-flex h-7 w-7 items-center justify-center rounded-[4px] border border-black/10 bg-white text-[#596164] shadow-sm"><ListChecks size={11} /></button>
) : null}
{captured ? (
<button type="button" onClick={() => onAdjustImage?.(target)} title="调整画面" className="inline-flex h-7 w-7 items-center justify-center rounded-[4px] border border-black/10 bg-white text-[#596164] shadow-sm"><Maximize2 size={11} /></button>
) : null}
{allowManualCapture ? (
<button
type="button"
onClick={() => onCaptureTarget?.(target)}
className="h-7 rounded-[4px] bg-[#2d3335] px-2 text-[7px] font-black text-white shadow-sm"
>
{captured ? '重新截取' : '去截取'}
</button>
) : null}
</div> : null}
</div>
)
}
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 (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-7 shrink-0 items-end justify-center border-b-2 border-[#202526] pb-1.5">
<p className="text-center text-[11px] font-black text-[#202526]">{drawingIdentity}</p>
</div>
<div className="mt-2 grid min-h-0 flex-1 grid-rows-2 gap-1.5">
<section className="grid min-h-0 grid-cols-[52px_1fr] gap-2">
<div className="border-l-4 border-[#202526] pl-2 pt-1">
<p className="text-[18px] font-black">01</p>
<p className="text-[8px] font-black">成品展示</p>
<p className="text-[6px] font-bold text-[#8a9293]">FINISHED</p>
</div>
<DrawingImage
image={captures.finished}
mode={imageMode}
alt="成品展示"
target={{ kind: 'finished' }}
emptyLabel="待截取成品图"
onCaptureTarget={onCaptureTarget}
onAdjustImage={onAdjustImage}
onCommitNumberingMarkers={onCommitNumberingMarkers}
allowManualCapture={allowManualCapture}
readOnly={readOnly}
/>
</section>
<section className="grid min-h-0 grid-cols-[52px_1fr] gap-2">
<div className="border-l-4 border-[#202526] pl-2 pt-1">
<p className="text-[18px] font-black">02</p>
<p className="text-[8px] font-black">产品爆炸图</p>
<p className="text-[6px] font-bold text-[#8a9293]">EXPLODED</p>
</div>
<DrawingImage
image={captures.exploded}
mode={imageMode}
alt="产品爆炸图"
target={{ kind: 'exploded' }}
emptyLabel="待截取爆炸图"
onCaptureTarget={onCaptureTarget}
onAdjustImage={onAdjustImage}
onManageNumberingMarkers={onManageNumberingMarkers}
onCommitNumberingMarkers={onCommitNumberingMarkers}
allowManualCapture={allowManualCapture}
numberingSize="small"
readOnly={readOnly}
/>
</section>
</div>
</div>
)
}

View File

@ -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<string, string>;
snapshotLineArtUrls: Record<string, string>;
toolSnapshotUrls: Record<string, string>;
toolSnapshotLineArtUrls: Record<string, string>;
lineArtGenerating?: boolean;
captures: InstallationDrawingCaptures;
initialScrollTop: number;
onCaptureTarget: (
target: InstallationDrawingCaptureTarget,
scrollTop: number,
) => void;
onUpdateImageLayout: (
target: InstallationDrawingCaptureTarget,
transform: InstallationDrawingImageTransform,
numberingMarkers: InstallationDrawingNumberMarker[],
numberingMarkerCountOverrides: Record<string, number>,
) => 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<string, string>,
toolSnapshotLineArtUrls: Record<string, string>,
): InstallationDrawingOverviewTool[] {
const capturedTools = captures.actions.flatMap((action) => action.tools);
return Array.from(
new Map<string, InstallationDrawingOverviewTool>([
...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<InstallationDrawingTemplateId>(
DEFAULT_INSTALLATION_DRAWING_TEMPLATE_ID,
);
const [imageModeOverride, setImageModeOverride] =
useState<InstallationDrawingImageMode | null>(null);
const imageMode = resolveInstallationDrawingImageMode(
imageModeOverride,
confirmation.confirmedImageMode,
);
const [adjustTarget, setAdjustTarget] =
useState<InstallationDrawingCaptureTarget | null>(null);
const scrollContainerRef = useRef<HTMLElement | null>(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(
<div
className={`installation-drawing-root fixed inset-0 z-[80] flex min-h-0 flex-col bg-[#e9edef] print:block print:bg-white ${totalUnplacedMarkers > 0 ? "drawing-print-blocked" : ""}`}
>
<style>{`
@page { size: ${layout.paper} ${layout.orientation}; margin: 0; }
@media print {
html, body {
width: 297mm !important;
height: auto !important;
overflow: visible !important;
background: white !important;
}
body > *:not(.installation-drawing-root) {
display: none !important;
}
.installation-drawing-root {
position: static !important;
inset: auto !important;
display: block !important;
width: 297mm !important;
height: auto !important;
min-height: 0 !important;
overflow: visible !important;
background: white !important;
}
.drawing-toolbar { display: none !important; }
.installation-drawing-main {
display: block !important;
width: 297mm !important;
height: auto !important;
min-height: 0 !important;
overflow: visible !important;
}
.installation-drawing-pages {
display: block !important;
width: 297mm !important;
max-width: none !important;
}
.drawing-page {
position: relative !important;
width: 297mm !important;
height: 210mm !important;
max-width: none !important;
aspect-ratio: auto !important;
overflow: hidden !important;
isolation: isolate;
contain: layout paint;
break-inside: avoid-page;
page-break-inside: avoid;
break-after: page;
page-break-after: always;
box-shadow: none !important;
margin: 0 !important;
}
.drawing-page:last-child {
break-after: auto;
page-break-after: auto;
}
.drawing-number-marker-screen-layer {
display: none !important;
}
.installation-drawing-screen-image {
display: none !important;
}
.installation-drawing-print-image {
display: block !important;
pointer-events: none !important;
}
.drawing-number-marker-print-layer {
display: block !important;
overflow: hidden !important;
contain: layout paint;
pointer-events: none !important;
}
.drawing-number-marker-print {
box-sizing: border-box !important;
transform: translate(-50%, -50%) !important;
border-style: solid !important;
box-shadow: none !important;
pointer-events: none !important;
}
.drawing-number-marker-print-small {
width: 3.175mm !important;
height: 3.175mm !important;
border-width: 0.33mm !important;
font-size: 1.852mm !important;
}
.drawing-number-marker-print-large {
width: 5.292mm !important;
height: 5.292mm !important;
border-width: 0.529mm !important;
font-size: 2.91mm !important;
}
.drawing-print-blocked .drawing-page { display: none !important; }
.drawing-print-blocked .drawing-print-warning { display: flex !important; }
}
`}</style>
<header className="drawing-toolbar flex shrink-0 items-center justify-between border-b border-black/[0.08] bg-white px-5 py-3 print:hidden">
<div>
<p className="text-[14px] font-black text-[#2d3335]">安装图纸制作</p>
<p className="mt-1 text-[11px] font-semibold text-[#7a8284]">
{drawing?.code ?? "未选择图纸"} · {layout.pages.length} 张纸 /{" "}
{panelCount} 个半页 · 已截取 {displayedCaptureCount}/
{totalCaptureCount}
{lineArtGenerating ? " · 正在生成部件线稿" : ""}
</p>
{confirmation.message ? (
<p
className="mt-1 max-w-[420px] truncate text-[10px] font-bold text-[#397253]"
title={confirmation.message}
>
{confirmation.message}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
<InstallationDrawingAutoCaptureControl
phase={autoCapture.phase}
hasActiveBatch={autoCapture.hasActiveBatch}
hasDraftBatch={autoCapture.hasDraftBatch}
configurationChanged={autoCapture.isConfigurationChanged}
completedCount={autoCapture.completedCount}
totalCount={autoCapture.totalCount}
error={autoCapture.error}
onStart={autoCapture.onStart}
onPause={autoCapture.onPause}
/>
<label className="flex items-center gap-2 text-[11px] font-bold text-[#596164]">
模板
<select
value={templateId}
onChange={(event) =>
setTemplateId(
event.target.value as InstallationDrawingTemplateId,
)
}
className="h-8 min-w-[116px] rounded-[6px] border border-black/10 bg-white px-2 text-[11px] font-bold"
>
{INSTALLATION_DRAWING_TEMPLATES.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</label>
<div
className="grid h-8 grid-cols-2 border border-black/10 bg-[#f4f6f6] p-0.5"
aria-label="图纸图片模式"
>
<button
type="button"
onClick={() => setImageModeOverride("original")}
className={`min-w-[58px] px-2 text-[10px] font-black ${imageMode === "original" ? "bg-white text-[#202526] shadow-sm" : "text-[#7a8284]"}`}
>
原图
</button>
<button
type="button"
onClick={() => setImageModeOverride("line-art")}
className={`min-w-[68px] px-2 text-[10px] font-black ${imageMode === "line-art" ? "bg-white text-[#202526] shadow-sm" : "text-[#7a8284]"}`}
>
打印线稿
</button>
</div>
<span className="inline-flex h-8 items-center rounded-[6px] border border-black/10 bg-[#f5f7f8] px-3 text-[11px] font-bold text-[#596164]">
纸张 {activeTemplate.paper}
</span>
<span className="inline-flex h-8 items-center rounded-[6px] border border-black/10 bg-[#f5f7f8] px-3 text-[11px] font-bold text-[#596164]">
方向 横向
</span>
{totalUnplacedMarkers > 0 ? (
<span className="inline-flex h-8 items-center border border-[#d79a62] bg-[#fff7ed] px-3 text-[11px] font-black text-[#a75d29]">
待放置 {totalUnplacedMarkers}
</span>
) : null}
<InstallationDrawingConfirmationControl
confirmedAt={resolveInstallationDrawingConfirmedAtForMode(
confirmation.confirmedAt,
confirmation.confirmedImageMode,
imageMode,
)}
confirming={confirmation.confirming}
disabledReason={confirmationDisabledReason}
onConfirm={() => confirmation.onConfirm(imageMode)}
/>
<button
type="button"
onClick={() => {
if (totalUnplacedMarkers === 0) window.print();
}}
disabled={totalUnplacedMarkers > 0}
title={
totalUnplacedMarkers > 0
? `还有 ${totalUnplacedMarkers} 个编号待放置`
: "打印 / 保存 PDF"
}
className="inline-flex h-8 items-center gap-1 rounded-[6px] bg-[#2d3335] px-3 text-[11px] font-black text-white disabled:cursor-not-allowed disabled:bg-[#aeb4b5]"
>
<Printer size={13} /> 打印 / 保存 PDF
</button>
<button
type="button"
onClick={() => {
autoCapture.onPause();
onClose();
}}
aria-label="关闭图纸模板"
title="关闭图纸模板"
className="inline-flex h-8 w-8 items-center justify-center rounded-[6px] border border-black/10 bg-white text-[#596164]"
>
<X size={15} />
</button>
</div>
</header>
<div className="drawing-print-warning hidden h-[210mm] w-[297mm] items-center justify-center bg-white p-16 text-center text-[#202526]">
<div>
<p className="text-[22px] font-black">图纸尚未完成</p>
<p className="mt-3 text-[13px] font-bold text-[#596164]">
还有 {totalUnplacedMarkers} 个编号待放置,完成后才能打印或保存 PDF。
</p>
</div>
</div>
<main
ref={scrollContainerRef}
className="installation-drawing-main min-h-0 flex-1 overflow-auto px-6 py-7 print:block print:p-0"
>
<div className="installation-drawing-pages mx-auto grid max-w-[1120px] gap-7 print:block">
{layout.pages.map((page) => (
<article
key={page.id}
className="drawing-page mx-auto grid aspect-[1.414/1] w-full max-w-[1120px] grid-cols-2 overflow-hidden bg-white p-6 text-[#202526] shadow-[0_14px_45px_rgba(41,50,52,0.13)]"
>
{page.panels.map((panel, panelIndex) => (
<section
key={panel.id}
className={`min-h-0 min-w-0 ${panelIndex > 0 ? "border-l border-[#202526]/20 pl-4" : "pr-4"}`}
>
{panel.kind === "overview" ? (
<InstallationDrawingOverviewPage
drawing={drawing}
selectedSpu={selectedSpu}
captures={reconciledCaptures}
imageMode={imageMode}
onCaptureTarget={captureFromCurrentPosition}
onAdjustImage={setAdjustTarget}
onManageNumberingMarkers={setMarkerManagerTarget}
onCommitNumberingMarkers={commitNumberingMarkers}
allowManualCapture={
!autoCapture.running && !autoCapture.hasDraftBatch
}
/>
) : null}
{panel.kind === "inventory" ? (
<InstallationDrawingInventoryPage
items={inventoryItems.slice(
panel.inventoryStart,
panel.inventoryEnd,
)}
imageMode={imageMode}
transforms={captures.inventoryTransforms}
onAdjustImage={setAdjustTarget}
/>
) : null}
{panel.kind === "actions" ? (
<InstallationDrawingActionsPage
groups={panel.actionGroups ?? []}
captures={reconciledCaptures.actions}
imageMode={imageMode}
snapshotUrls={snapshotUrls}
snapshotLineArtUrls={snapshotLineArtUrls}
onCaptureTarget={captureFromCurrentPosition}
onAdjustImage={setAdjustTarget}
onManageNumberingMarkers={setMarkerManagerTarget}
onCommitNumberingMarkers={commitNumberingMarkers}
allowManualCapture={
!autoCapture.running && !autoCapture.hasDraftBatch
}
/>
) : null}
</section>
))}
{page.panels.length === 1 ? (
<section className="border-l border-[#202526]/20 pl-4" />
) : null}
</article>
))}
</div>
</main>
{adjustTarget &&
activeAdjustImage &&
isInstallationDrawingImageAvailable(activeAdjustImage) ? (
<InstallationDrawingImageAdjustDialog
target={adjustTarget}
image={activeAdjustImage}
initialMode={imageMode}
onSave={(transform, numberingMarkers) => {
onUpdateImageLayout(
adjustTarget,
transform,
numberingMarkers,
activeAdjustImage.numberingMarkerCountOverrides,
);
setAdjustTarget(null);
}}
onClose={() => setAdjustTarget(null)}
/>
) : null}
{markerManagerTarget &&
markerManagerImage &&
markerManagerSources.length > 0 ? (
<InstallationDrawingMarkerManagerDialog
title={
markerManagerTarget.kind === "action"
? `安装动作 ${markerManagerTarget.sequence}`
: "产品爆炸图"
}
image={markerManagerImage}
sources={markerManagerSources}
onSave={(numberingMarkers, numberingMarkerCountOverrides) => {
onUpdateImageLayout(
markerManagerTarget,
markerManagerImage.transform,
numberingMarkers,
numberingMarkerCountOverrides,
);
setMarkerManagerTarget(null);
}}
onClose={() => setMarkerManagerTarget(null)}
/>
) : null}
</div>,
document.body,
);
}

View File

@ -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;
}) => (
<button type="button" onClick={() => onCaptureTarget({ kind: "finished" })}>
去截取
</button>
),
}));
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(
<InstallationDrawingSheet {...buildProps(0, onCaptureTarget)} />,
);
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(<InstallationDrawingSheet {...buildProps(6420, jest.fn())} />);
const restoredScroller = document.querySelector(
".installation-drawing-main",
) as HTMLElement;
expect(restoredScroller.scrollTop).toBe(6420);
});
});

View File

@ -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<typeof import('react')>('react')
useEffect(() => {
onEngineReady?.({ engine: { capturePngDataUrl } })
}, [onEngineReady])
return <div data-testid="tool-model-preview" />
},
}))
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(
<InstallationDrawingToolLineArtGenerator
tool={{
id: 'tool-1',
toolCode: 'TOOL-1',
name: 'Screwdriver',
modelUrl: '/models/screwdriver.obj',
createdAt: '2026-08-15T00:00:00.000Z',
updatedAt: '2026-08-15T00:00:00.000Z',
}}
onCapture={onCapture}
/>,
)
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(
<InstallationDrawingToolLineArtGenerator
tool={{
id: 'tool-1',
toolCode: 'TOOL-1',
name: 'Screwdriver',
modelUrl: '/models/screwdriver.obj',
createdAt: '2026-08-15T00:00:00.000Z',
updatedAt: '2026-08-15T00:00:00.000Z',
}}
onCapture={onCapture}
/>,
)
await act(async () => {
jest.advanceTimersByTime(1080)
})
expect(onCapture).toHaveBeenCalledTimes(2)
})
})

View File

@ -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 (
<div aria-hidden="true" className="pointer-events-none fixed left-[-10000px] top-0 h-[320px] w-[320px] opacity-0 print:hidden">
<ModelPreviewPanel
key={tool.id}
modelUrl={tool.modelUrl}
preserveDrawingBuffer
hideEmptyPrompt
showFpsBadge={false}
showCameraActionButtons={false}
viewportControlsEnabled={false}
onEngineReady={handleEngineReady}
/>
</div>
)
}

View File

@ -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}

View File

@ -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<HTMLCanvasElement | null>(null)
const [layerSize, setLayerSize] = useState<MarkerLayerSize>({ 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

View File

@ -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<HTMLDivElement | null>(null)
const frameRef = useRef<number | null>(null)
@ -226,6 +229,7 @@ export function InstallationNumberingOverlay({
onSelectPart={onSelectPart}
tone={tone}
interactive={interactive}
onMarkerPositionsChange={onMarkerPositionsChange}
/>
) : null}
</div>

View File

@ -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<ComponentProps<typeof ModelPreviewPanel>['onNodeSelect']>
type PreviewNodeContextMenuHandler = NonNullable<ComponentProps<typeof ModelPreviewPanel>['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={<AssetWorkbenchEmptyBackdrop />}
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({
</div>
}
overlayCenter={
showNumberingPresentation ? (
showDrawingCaptureNumberingOverlay ? (
<InstallationNumberingOverlay
engine={previewEngine}
parts={drawingCaptureNumberingParts}
selectedPartId={null}
onSelectPart={() => {}}
layoutVariant={drawingCaptureTarget?.kind === 'exploded' ? 'numbering' : 'materials'}
tone="part"
interactive={false}
followSceneTransforms={drawingCaptureTarget?.kind === 'action'}
onMarkerPositionsChange={onDrawingCaptureMarkersChange}
/>
) : showNumberingPresentation ? (
<InstallationNumberingPresentation
parts={numberingParts}
thumbnailUrls={numberingPartThumbnailUrls}

View File

@ -0,0 +1,150 @@
'use client'
import { useEffect, useMemo, type MutableRefObject } from 'react'
import type { ModelPreviewCapture } from '@/components/platform/ModelPreviewPanel'
import type { ThreeDEngine } from '@/lib/3d/engine'
import {
applyInstallationDrawingActionCaptureCamera,
applyInstallationDrawingActionCaptureFrame,
isInstallationDrawingActionCaptureCameraApplied,
isInstallationDrawingActionCaptureFrameApplied,
isInstallationDrawingActionWorkspaceReady,
resolveInstallationDrawingActionCaptureFrame,
} from '../lib/installationDrawingActionCaptureFrame'
import type { InstallationDrawingActionCaptureTarget } from '../lib/installationDrawingCaptureTypes'
import type {
InstallationPartItem,
InstallationStepDraft,
InstallationWorkbenchView,
} from '../types'
interface UseInstallationDrawingActionCaptureFrameOptions {
target: InstallationDrawingActionCaptureTarget | null
steps: InstallationStepDraft[]
numberingParts: InstallationPartItem[]
modelNodeNames: string[]
engine: ThreeDEngine | null
captureRef: MutableRefObject<ModelPreviewCapture | null>
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
}

View File

@ -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<InstallationDrawingCaptureSnapshot | null>(null);
const [phase, setPhase] = useState<AutoCapturePhase>("loading");
const [session, setSession] = useState<CaptureSession | null>(null);
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const startedTargetKeyRef = useRef<string | null>(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<string, number>,
) => {
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,
};
}

View File

@ -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<ModelPreviewCapture | null>
numberingMarkersRef: MutableRefObject<InstallationDrawingMarkerSnapshot>
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<InstallationStepDraft['actions']>[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<InstallationDrawingCaptures>(template)
const [captureTarget, setCaptureTarget] = useState<InstallationDrawingCaptureTarget | null>(null)
const [capturing, setCapturing] = useState(false)
const [captureReady, setCaptureReady] = useState(false)
const [error, setError] = useState<string | null>(null)
const previousDrawingIdRef = useRef<string | null>(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<string, number>,
) => {
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,
}
}

View File

@ -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<InstallationNumberingSnapshotBatch | null>;
confirmCaptureBatch: (
imageMode: InstallationDrawingImageMode,
) => Promise<string | null>;
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<string | null>(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 };
}

View File

@ -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<InstallationDrawingCaptureTarget | null>(null)
const explodedMarkerSources = useMemo(
() => buildInstallationDrawingExplodedMarkerSources(numberingParts),
[numberingParts],
)
const reconciledCaptures = useMemo<InstallationDrawingCaptures>(() => ({
...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,
}
}

View File

@ -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,
])
}

View File

@ -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(

View File

@ -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<InstallationStepWorkspaceMode>('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,

View File

@ -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)
})
})

View File

@ -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 ?? [],
}
}

View File

@ -0,0 +1,27 @@
import { resolveInstallationDrawingPendingTargets } from './installationDrawingAutoCapturePlan'
import type { InstallationDrawingCaptureTarget } from './installationDrawingCaptureTypes'
const targets: Array<Exclude<InstallationDrawingCaptureTarget, { kind: 'inventory' }>> = [
{ 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)
})
})

View File

@ -0,0 +1,17 @@
import {
getInstallationDrawingCaptureTargetKey,
type InstallationDrawingCaptureTarget,
} from './installationDrawingCaptureTypes'
type CaptureTarget = Exclude<InstallationDrawingCaptureTarget, { kind: 'inventory' }>
export function resolveInstallationDrawingPendingTargets(
targets: CaptureTarget[],
completedTargetKeys: Iterable<string>,
) {
const completed = new Set(completedTargetKeys)
return targets.filter(
(target) => !completed.has(getInstallationDrawingCaptureTargetKey(target)),
)
}

View File

@ -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<string, InstallationDrawingImageTransform>;
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<string, number>;
}) {
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, "画面与编号位置保存失败");
}

View File

@ -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),
)
})
})

View File

@ -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<string, unknown>
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))}`
}

View File

@ -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 }])
})
})

View File

@ -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<string, InstallationDrawingNumberMarker>()
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())
}

View File

@ -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'])
})
})

View File

@ -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,
}]
})
}

View File

@ -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)
})
})

View File

@ -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,
},
}
}

View File

@ -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();
});
});

View File

@ -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<string, number>;
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<string, InstallationDrawingImageTransform>;
}
export interface InstallationDrawingPersistedImage {
originalUrl: string;
lineArtUrl: string;
transform: InstallationDrawingImageTransform;
numberingMarkers: InstallationDrawingNumberMarker[];
numberingMarkerCountOverrides: Record<string, number>;
captureAspectRatio: number | null;
}
export interface InstallationDrawingCaptureBatch {
batchId: string;
configFingerprint: string;
images: Record<string, InstallationDrawingPersistedImage>;
inventoryTransforms: Record<string, InstallationDrawingImageTransform>;
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<InstallationDrawingCaptureTarget, { kind: "inventory" }>,
) {
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<Exclude<InstallationDrawingCaptureTarget, { kind: "inventory" }>> {
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;
}

View File

@ -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 })
})
})
})

View File

@ -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),
}
}

View File

@ -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)
})
})

View File

@ -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<string, number>
}) {
const claimedIds = new Set<string>()
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
}

View File

@ -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]
}

View File

@ -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'])
})
})

View File

@ -0,0 +1,21 @@
interface InstallationDrawingToolIdentity {
id: string
}
export function reconcileInstallationDrawingToolIds(
selectedToolIds: string[],
availableTools: ReadonlyArray<InstallationDrawingToolIdentity>,
) {
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
}

View File

@ -33,19 +33,19 @@ function dataUrlToBlob(value: string) {
async function appendSnapshots(
formData: FormData,
fieldPrefix: 'part' | 'tool',
manifestField: string,
snapshots: Record<string, string>,
) {
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',

View File

@ -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()
})
})

View File

@ -0,0 +1,110 @@
import type { InstallationNumberingSnapshotBatch } from './installationNumberingSnapshotStorage'
import {
installationNumberingSnapshotMatchesModel,
resolveInstallationNumberingSnapshotModelIdentity,
} from './installationNumberingSnapshotModel'
interface BuildInstallationNumberingSnapshotBatchInput {
modelUrl: string
partIds: string[]
toolIds: string[]
snapshotUrls: Record<string, string>
snapshotLineArtUrls: Record<string, string>
toolSnapshotUrls: Record<string, string>
toolSnapshotLineArtUrls: Record<string, string>
}
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<string, string>) {
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,
}
}

View File

@ -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))
)
}

View File

@ -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,

View File

@ -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
}

View File

@ -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('当前场景没有可用于生成线稿的模型几何。')
})
})

View File

@ -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<string, THREE.Mesh>()
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<T>(
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))
}
}

View File

@ -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)
})
})

View File

@ -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<TechnicalLineCaptureStyle, {
creaseAngle: number
minBoundaryLengthPx: number
minFeatureLengthPx: number
}> = {
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<string, EdgeFaceRecord>()
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<string, { direction: THREE.Vector3; anchor: THREE.Vector3; intervals: Array<[number, number]> }>()
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
}

View File

@ -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<string, string>;
toolSnapshotLineArtUrls: Record<string, string>;
}
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<string, string>
numberingToolSnapshotUrls: Record<string, string>
stepMaterialsByStepId: Record<string, InstallationStepMaterialShareSnapshot>
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<string, string>;
numberingToolSnapshotUrls: Record<string, string>;
stepMaterialsByStepId: Record<string, InstallationStepMaterialShareSnapshot>;
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<string, string> | null
numberingToolSnapshotUrls?: Record<string, string> | null
stepMaterialsByStepId?: Record<string, InstallationStepMaterialShareSnapshot> | null
steps: InstallationStepDraft[]
initialView?: InstallationDemoShareInitialViewState | null
drawing: InstallationDemoShareDrawingSnapshot;
modelUrl: string;
uploadedModel?: InstallationUploadedModel | null;
parts: InstallationPartItem[];
numberingParts: InstallationPartItem[];
drawingTools?: InstallationToolItem[];
numberingView?: CameraViewSnapshot | null;
numberingSnapshotUrls?: Record<string, string> | null;
numberingToolSnapshotUrls?: Record<string, string> | 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;
}

View File

@ -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 {};
}
}

View File

@ -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,
};
}

View File

@ -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<string, number>;
captureAspectRatio: number | null;
}): Promise<StoredCaptureImage> {
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;
}
}

View File

@ -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<Record<string, unknown>>;
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");
});
});

View File

@ -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<string, number>;
captureAspectRatio: number | null;
}
export interface StoredCaptureBatch {
batchId: string;
configFingerprint: string;
images: Record<string, StoredCaptureImage>;
inventoryTransforms: Record<string, ImageTransform>;
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<string, unknown>)
: {};
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<string, unknown>)
: {};
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<unknown>(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();

View File

@ -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()

View File

@ -24,7 +24,9 @@ export interface InstallationDemoNumberingSnapshotBatchDTO {
modelUrl: string
modelIdentity: string
snapshotUrls: Record<string, string>
snapshotLineArtUrls: Record<string, string>
toolSnapshotUrls: Record<string, string>
toolSnapshotLineArtUrls: Record<string, string>
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<ReturnType<typeof uploadSnapshots>>[] = []
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
}
}

View File

@ -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<typeof createInput> & {
drawingDocument: ReturnType<typeof createInput>["drawingDocument"] & {
imageMode: unknown;
};
};
input.drawingDocument.imageMode = "screen";
const service = new InstallationDemoShareService();
await expect(service.create(user, input)).rejects.toThrow(
"图纸图片模式格式不正确",
);
});
});

View File

@ -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<string, unknown> {
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<T>(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<T>(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<string, string> {
if (value === undefined || value === null) return {}
function normalizeStringRecord(
value: unknown,
fieldLabel: string,
): Record<string, string> {
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<Record<string, InstallationStepMaterialShareSnapshot>>(
(result, [stepId, entryValue]) => {
if (stepId.trim().length === 0) return result
return Object.entries(value).reduce<
Record<string, InstallationStepMaterialShareSnapshot>
>((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<InstallationDemoShareSnapshot> {
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<InstallationDemoShareSnapshot> {
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<InstallationDemoShareSnapshot> {
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<InstallationDemoShareSnapshot> {
const token = normalizeInstallationDemoShareToken(rawToken)
const token = normalizeInstallationDemoShareToken(rawToken);
const snapshot = normalizeStoredInstallationDemoShareSnapshot(
await systemConfigService.get<unknown>(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();