Merge installation demo drawing generator
This commit is contained in:
commit
8b1cb357ea
@ -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)),
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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} />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)} />
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
])
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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, "画面与编号位置保存失败");
|
||||
}
|
||||
@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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))}`
|
||||
}
|
||||
@ -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 }])
|
||||
})
|
||||
})
|
||||
@ -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())
|
||||
}
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}]
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
83
furniture-3d-system/src/lib/3d/technical-line-camera.ts
Normal file
83
furniture-3d-system/src/lib/3d/technical-line-camera.ts
Normal 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
|
||||
}
|
||||
@ -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('当前场景没有可用于生成线稿的模型几何。')
|
||||
})
|
||||
})
|
||||
137
furniture-3d-system/src/lib/3d/technical-line-capture.ts
Normal file
137
furniture-3d-system/src/lib/3d/technical-line-capture.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
233
furniture-3d-system/src/lib/3d/technical-line-geometry.ts
Normal file
233
furniture-3d-system/src/lib/3d/technical-line-geometry.ts
Normal 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
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
"图纸图片模式格式不正确",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user