feat(installation-demo): refine printable drawing workflow
This commit is contained in:
parent
330cf30ac9
commit
5cfbcaa5fe
@ -0,0 +1,50 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { AppError } from "@/server/core/errors";
|
||||
import { handleRequest, jsonError, jsonSuccess } from "@/server/core/response";
|
||||
import { installationDemoDrawingTextAnnotationService } from "@/server/services/installation-demo/installation-demo-drawing-text-annotation.service";
|
||||
|
||||
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 installationDemoDrawingTextAnnotationService.get(
|
||||
ctx.user,
|
||||
request.nextUrl.searchParams.get("drawingId"),
|
||||
);
|
||||
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 installationDemoDrawingTextAnnotationService.updateTarget(
|
||||
ctx.user,
|
||||
await request.json(),
|
||||
);
|
||||
return jsonSuccess({ snapshot }, 200, ctx.requestId);
|
||||
} catch (error) {
|
||||
return respondWithAppError(error, ctx.requestId);
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
);
|
||||
}
|
||||
@ -65,6 +65,9 @@ function InstallationDemoShareDrawingPanel({
|
||||
imageMode={imageMode}
|
||||
snapshotUrls={snapshot.numberingSnapshotUrls}
|
||||
snapshotLineArtUrls={snapshotLineArtUrls}
|
||||
textAnnotationsByTargetKey={
|
||||
snapshot.drawingDocument?.textAnnotationsByTargetKey ?? {}
|
||||
}
|
||||
allowManualCapture={false}
|
||||
readOnly
|
||||
/>
|
||||
|
||||
@ -41,6 +41,7 @@ function createSnapshot(): InstallationDemoShareSnapshot {
|
||||
actions: [],
|
||||
inventoryTransforms: {},
|
||||
},
|
||||
textAnnotationsByTargetKey: {},
|
||||
snapshotLineArtUrls: {},
|
||||
toolSnapshotLineArtUrls: {},
|
||||
},
|
||||
|
||||
@ -107,6 +107,7 @@ import { InstallationDrawingToolLineArtGenerator } from "./components/Installati
|
||||
import { useInstallationDrawingCapture } from "./hooks/useInstallationDrawingCapture";
|
||||
import { useInstallationDrawingAutoCapture } from "./hooks/useInstallationDrawingAutoCapture";
|
||||
import { useInstallationDrawingConfirmation } from "./hooks/useInstallationDrawingConfirmation";
|
||||
import { useInstallationDrawingTextAnnotations } from "./hooks/useInstallationDrawingTextAnnotations";
|
||||
import { buildInstallationDrawingCaptureFingerprint } from "./lib/installationDrawingCaptureFingerprint";
|
||||
import {
|
||||
isInstallationDrawingActionCaptureTarget,
|
||||
@ -401,6 +402,10 @@ export default function InstallationDemoWorkbenchClient({
|
||||
setMode,
|
||||
setActiveStepId: stepEditor.setActiveStepId,
|
||||
});
|
||||
const drawingTextAnnotations = useInstallationDrawingTextAnnotations(
|
||||
selectedDrawingId,
|
||||
catalog.access.canEdit,
|
||||
);
|
||||
const drawingOverviewCaptureActive =
|
||||
isInstallationDrawingOverviewCaptureTarget(drawingCapture.captureTarget);
|
||||
const drawingActionCaptureTarget = isInstallationDrawingActionCaptureTarget(
|
||||
@ -1732,6 +1737,8 @@ export default function InstallationDemoWorkbenchClient({
|
||||
templateId: "set-1",
|
||||
imageMode: drawingImageMode ?? "line-art",
|
||||
captures: drawingCapture.captures,
|
||||
textAnnotationsByTargetKey:
|
||||
drawingTextAnnotations.annotationsByTargetKey,
|
||||
snapshotLineArtUrls:
|
||||
shareNumberingSnapshotBatch.snapshotLineArtUrls ?? {},
|
||||
toolSnapshotLineArtUrls:
|
||||
@ -1763,6 +1770,7 @@ export default function InstallationDemoWorkbenchClient({
|
||||
numberingModelParts.length,
|
||||
numberingPlaying,
|
||||
drawingCapture.captures,
|
||||
drawingTextAnnotations.annotationsByTargetKey,
|
||||
drawingTools.selectedTools,
|
||||
numberingVisibleModelParts,
|
||||
playing,
|
||||
@ -2614,6 +2622,10 @@ export default function InstallationDemoWorkbenchClient({
|
||||
numberingLineArtGenerating || Boolean(missingDrawingToolLineArt)
|
||||
}
|
||||
captures={drawingCapture.captures}
|
||||
textAnnotationsByTargetKey={
|
||||
drawingTextAnnotations.annotationsByTargetKey
|
||||
}
|
||||
textAnnotationMessage={drawingTextAnnotations.message}
|
||||
initialScrollTop={
|
||||
drawingSheetScrollPosition.drawingId === selectedDrawingId
|
||||
? drawingSheetScrollPosition.scrollTop
|
||||
@ -2638,6 +2650,9 @@ export default function InstallationDemoWorkbenchClient({
|
||||
numberingMarkerCountOverrides,
|
||||
);
|
||||
}}
|
||||
onUpdateTextAnnotations={
|
||||
drawingTextAnnotations.saveTargetAnnotations
|
||||
}
|
||||
autoCapture={{
|
||||
phase: drawingAutoCapture.phase,
|
||||
error: drawingAutoCapture.error,
|
||||
|
||||
@ -1,14 +1,22 @@
|
||||
import { ListChecks, Maximize2 } from 'lucide-react'
|
||||
import { ListChecks, Maximize2, Type } from 'lucide-react'
|
||||
import type {
|
||||
InstallationDrawingActionCapture,
|
||||
InstallationDrawingCaptureTarget,
|
||||
InstallationDrawingImageMode,
|
||||
InstallationDrawingNumberMarker,
|
||||
} from '../lib/installationDrawingCaptureTypes'
|
||||
import { isInstallationDrawingImageCaptured } from '../lib/installationDrawingCaptureTypes'
|
||||
import {
|
||||
getInstallationDrawingCaptureTargetKey,
|
||||
isInstallationDrawingImageCaptured,
|
||||
} from '../lib/installationDrawingCaptureTypes'
|
||||
import type { InstallationDrawingActionGroup } from '../lib/installationDrawingLayout'
|
||||
import type {
|
||||
InstallationDrawingTextAnnotation,
|
||||
InstallationDrawingTextAnnotationsByTarget,
|
||||
} from '../lib/installationDrawingTextAnnotations'
|
||||
import { InstallationDrawingImageFrame } from './InstallationDrawingImageFrame'
|
||||
import { InstallationDrawingNumberMarkers } from './InstallationDrawingNumberMarkers'
|
||||
import { InstallationDrawingTextAnnotations } from './InstallationDrawingTextAnnotations'
|
||||
|
||||
interface Props {
|
||||
groups: InstallationDrawingActionGroup[]
|
||||
@ -23,6 +31,12 @@ interface Props {
|
||||
target: InstallationDrawingCaptureTarget,
|
||||
markers: InstallationDrawingNumberMarker[],
|
||||
) => void
|
||||
textAnnotationsByTargetKey?: InstallationDrawingTextAnnotationsByTarget
|
||||
onManageTextAnnotations?: (target: InstallationDrawingCaptureTarget) => void
|
||||
onCommitTextAnnotations?: (
|
||||
target: InstallationDrawingCaptureTarget,
|
||||
annotations: InstallationDrawingTextAnnotation[],
|
||||
) => void
|
||||
allowManualCapture: boolean
|
||||
readOnly?: boolean
|
||||
}
|
||||
@ -77,6 +91,9 @@ export function InstallationDrawingActionsPage({
|
||||
onAdjustImage,
|
||||
onManageNumberingMarkers,
|
||||
onCommitNumberingMarkers,
|
||||
textAnnotationsByTargetKey = {},
|
||||
onManageTextAnnotations,
|
||||
onCommitTextAnnotations,
|
||||
allowManualCapture,
|
||||
readOnly = false,
|
||||
}: Props) {
|
||||
@ -100,6 +117,8 @@ export function InstallationDrawingActionsPage({
|
||||
actionId: action.actionId,
|
||||
sequence: action.sequence,
|
||||
}
|
||||
const targetKey = getInstallationDrawingCaptureTargetKey(target)
|
||||
const textAnnotations = textAnnotationsByTargetKey[targetKey] ?? []
|
||||
const captured = capture ? isInstallationDrawingImageCaptured(capture.image) : false
|
||||
return (
|
||||
<figure
|
||||
@ -133,6 +152,15 @@ export function InstallationDrawingActionsPage({
|
||||
: undefined}
|
||||
/>
|
||||
) : null}
|
||||
{captured && textAnnotations.length > 0 ? (
|
||||
<InstallationDrawingTextAnnotations
|
||||
annotations={textAnnotations}
|
||||
editable={!readOnly}
|
||||
onCommit={onCommitTextAnnotations
|
||||
? (annotations) => onCommitTextAnnotations(target, annotations)
|
||||
: undefined}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{capture && capture.tools.length > 0 ? (
|
||||
@ -155,6 +183,17 @@ export function InstallationDrawingActionsPage({
|
||||
<ListChecks size={10} />
|
||||
</button>
|
||||
) : null}
|
||||
{capture && captured ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onManageTextAnnotations?.(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"
|
||||
>
|
||||
<Type size={10} />
|
||||
</button>
|
||||
) : null}
|
||||
{capture && captured ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { InstallationDrawingAutoCaptureControl } from "./InstallationDrawingAutoCaptureControl";
|
||||
|
||||
function renderControl(input?: {
|
||||
hasActiveBatch?: boolean;
|
||||
onStart?: jest.Mock;
|
||||
}) {
|
||||
const onStart = input?.onStart ?? jest.fn();
|
||||
render(
|
||||
<InstallationDrawingAutoCaptureControl
|
||||
phase="idle"
|
||||
hasActiveBatch={input?.hasActiveBatch ?? true}
|
||||
hasDraftBatch={false}
|
||||
configurationChanged={false}
|
||||
completedCount={75}
|
||||
totalCount={75}
|
||||
error={null}
|
||||
onStart={onStart}
|
||||
onPause={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
return onStart;
|
||||
}
|
||||
|
||||
describe("InstallationDrawingAutoCaptureControl", () => {
|
||||
it("asks for confirmation before replacing a completed batch", () => {
|
||||
const onStart = renderControl();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "重新批量带入 75/75" }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "重新批量带入" })).toBeInTheDocument();
|
||||
expect(onStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts replacement only after confirmation", () => {
|
||||
const onStart = renderControl();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "重新批量带入 75/75" }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "开始重新带入" }));
|
||||
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("dialog", { name: "重新批量带入" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the first automatic capture as a direct action", () => {
|
||||
const onStart = renderControl({ hasActiveBatch: false });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "自动抓取" }));
|
||||
|
||||
expect(onStart).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Camera, CheckCircle2, LoaderCircle, Square } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Camera,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
Square,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface InstallationDrawingAutoCaptureControlProps {
|
||||
phase:
|
||||
@ -32,6 +40,7 @@ export function InstallationDrawingAutoCaptureControl({
|
||||
onStart,
|
||||
onPause,
|
||||
}: InstallationDrawingAutoCaptureControlProps) {
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const running =
|
||||
phase === "seeding" || phase === "running" || phase === "finalizing";
|
||||
const loading = phase === "loading";
|
||||
@ -39,60 +48,121 @@ export function InstallationDrawingAutoCaptureControl({
|
||||
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>
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{settled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRestartDialogOpen(true)}
|
||||
title="重新批量带入全部画面"
|
||||
className="inline-flex h-8 items-center gap-1.5 whitespace-nowrap rounded-[6px] border border-[#aeb6b8] bg-white px-3 text-[11px] font-black text-[#303638] hover:bg-[#f4f6f6]"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
重新批量带入 {completedCount}/{totalCount}
|
||||
</button>
|
||||
) : 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>
|
||||
|
||||
{restartDialogOpen ? (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 print:hidden">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="重新批量带入"
|
||||
className="w-full max-w-[430px] 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 className="flex items-center gap-2">
|
||||
<TriangleAlert size={16} className="text-[#9a6513]" />
|
||||
<p className="text-[14px] font-black text-[#2d3335]">
|
||||
重新批量带入全部画面?
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRestartDialogOpen(false)}
|
||||
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="px-5 py-4">
|
||||
<p className="text-[11px] font-bold leading-5 text-[#596164]">
|
||||
将重新抓取 {totalCount} 张原图和打印线稿。当前版本会保留到新批次全部完成,已有画面位置和编号设置继续沿用。
|
||||
</p>
|
||||
<div className="mt-5 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRestartDialogOpen(false)}
|
||||
className="h-9 rounded-[6px] border border-black/10 text-[10px] font-black text-[#596164]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRestartDialogOpen(false);
|
||||
onStart();
|
||||
}}
|
||||
className="inline-flex h-9 items-center justify-center gap-1.5 rounded-[6px] bg-[#2d3335] text-[10px] font-black text-white"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
开始重新带入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
} from "../types";
|
||||
import type {
|
||||
InstallationDrawingCaptures,
|
||||
InstallationDrawingActionCaptureTarget,
|
||||
InstallationDrawingCaptureTarget,
|
||||
InstallationDrawingCapturedImage,
|
||||
InstallationDrawingImageMode,
|
||||
@ -20,11 +21,16 @@ import type {
|
||||
} from "../lib/installationDrawingCaptureTypes";
|
||||
import {
|
||||
DEFAULT_INSTALLATION_DRAWING_IMAGE_TRANSFORM,
|
||||
getInstallationDrawingCaptureTargetKey,
|
||||
isInstallationDrawingImageAvailable,
|
||||
isInstallationDrawingImageCaptured,
|
||||
resolveInstallationDrawingConfirmedAtForMode,
|
||||
resolveInstallationDrawingImageMode,
|
||||
} from "../lib/installationDrawingCaptureTypes";
|
||||
import type {
|
||||
InstallationDrawingTextAnnotation,
|
||||
InstallationDrawingTextAnnotationsByTarget,
|
||||
} from "../lib/installationDrawingTextAnnotations";
|
||||
import { buildInstallationDrawingLayout } from "../lib/installationDrawingLayout";
|
||||
import { useInstallationDrawingMarkerInventory } from "../hooks/useInstallationDrawingMarkerInventory";
|
||||
import {
|
||||
@ -39,6 +45,7 @@ import { InstallationDrawingInventoryPage } from "./InstallationDrawingInventory
|
||||
import { InstallationDrawingOverviewPage } from "./InstallationDrawingOverviewPage";
|
||||
import { InstallationDrawingImageAdjustDialog } from "./InstallationDrawingImageAdjustDialog";
|
||||
import { InstallationDrawingMarkerManagerDialog } from "./InstallationDrawingMarkerManagerDialog";
|
||||
import { InstallationDrawingTextAnnotationDialog } from "./InstallationDrawingTextAnnotationDialog";
|
||||
import {
|
||||
buildInstallationDrawingInventoryItems,
|
||||
type InstallationDrawingOverviewTool,
|
||||
@ -57,6 +64,8 @@ interface InstallationDrawingSheetProps {
|
||||
toolSnapshotLineArtUrls: Record<string, string>;
|
||||
lineArtGenerating?: boolean;
|
||||
captures: InstallationDrawingCaptures;
|
||||
textAnnotationsByTargetKey: InstallationDrawingTextAnnotationsByTarget;
|
||||
textAnnotationMessage?: string | null;
|
||||
initialScrollTop: number;
|
||||
onCaptureTarget: (
|
||||
target: InstallationDrawingCaptureTarget,
|
||||
@ -68,6 +77,10 @@ interface InstallationDrawingSheetProps {
|
||||
numberingMarkers: InstallationDrawingNumberMarker[],
|
||||
numberingMarkerCountOverrides: Record<string, number>,
|
||||
) => void;
|
||||
onUpdateTextAnnotations: (
|
||||
targetKey: string,
|
||||
annotations: InstallationDrawingTextAnnotation[],
|
||||
) => void;
|
||||
autoCapture: {
|
||||
phase:
|
||||
| "loading"
|
||||
@ -162,9 +175,12 @@ export function InstallationDrawingSheet({
|
||||
toolSnapshotLineArtUrls,
|
||||
lineArtGenerating = false,
|
||||
captures,
|
||||
textAnnotationsByTargetKey,
|
||||
textAnnotationMessage = null,
|
||||
initialScrollTop,
|
||||
onCaptureTarget,
|
||||
onUpdateImageLayout,
|
||||
onUpdateTextAnnotations,
|
||||
autoCapture,
|
||||
confirmation,
|
||||
onClose,
|
||||
@ -180,6 +196,8 @@ export function InstallationDrawingSheet({
|
||||
);
|
||||
const [adjustTarget, setAdjustTarget] =
|
||||
useState<InstallationDrawingCaptureTarget | null>(null);
|
||||
const [textAnnotationTarget, setTextAnnotationTarget] =
|
||||
useState<InstallationDrawingActionCaptureTarget | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const {
|
||||
markerManagerTarget,
|
||||
@ -297,6 +315,23 @@ export function InstallationDrawingSheet({
|
||||
image.numberingMarkerCountOverrides,
|
||||
);
|
||||
};
|
||||
const commitTextAnnotations = (
|
||||
target: InstallationDrawingActionCaptureTarget,
|
||||
annotations: InstallationDrawingTextAnnotation[],
|
||||
) => {
|
||||
const image = resolveCapturedImage(reconciledCaptures, target);
|
||||
if (!image) return;
|
||||
onUpdateTextAnnotations(
|
||||
getInstallationDrawingCaptureTargetKey(target),
|
||||
annotations,
|
||||
);
|
||||
onUpdateImageLayout(
|
||||
target,
|
||||
image.transform,
|
||||
image.numberingMarkers,
|
||||
image.numberingMarkerCountOverrides,
|
||||
);
|
||||
};
|
||||
const captureFromCurrentPosition = (
|
||||
target: InstallationDrawingCaptureTarget,
|
||||
) => {
|
||||
@ -429,6 +464,14 @@ export function InstallationDrawingSheet({
|
||||
{confirmation.message}
|
||||
</p>
|
||||
) : null}
|
||||
{textAnnotationMessage ? (
|
||||
<p
|
||||
className="mt-1 max-w-[420px] truncate text-[10px] font-bold text-[#a75d29]"
|
||||
title={textAnnotationMessage}
|
||||
>
|
||||
{textAnnotationMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<InstallationDrawingAutoCaptureControl
|
||||
@ -591,6 +634,15 @@ export function InstallationDrawingSheet({
|
||||
onAdjustImage={setAdjustTarget}
|
||||
onManageNumberingMarkers={setMarkerManagerTarget}
|
||||
onCommitNumberingMarkers={commitNumberingMarkers}
|
||||
textAnnotationsByTargetKey={textAnnotationsByTargetKey}
|
||||
onManageTextAnnotations={(target) => {
|
||||
if (target.kind === "action") setTextAnnotationTarget(target);
|
||||
}}
|
||||
onCommitTextAnnotations={(target, annotations) => {
|
||||
if (target.kind === "action") {
|
||||
commitTextAnnotations(target, annotations);
|
||||
}
|
||||
}}
|
||||
allowManualCapture={
|
||||
!autoCapture.running && !autoCapture.hasDraftBatch
|
||||
}
|
||||
@ -647,6 +699,21 @@ export function InstallationDrawingSheet({
|
||||
onClose={() => setMarkerManagerTarget(null)}
|
||||
/>
|
||||
) : null}
|
||||
{textAnnotationTarget ? (
|
||||
<InstallationDrawingTextAnnotationDialog
|
||||
title={`安装动作 ${textAnnotationTarget.sequence}`}
|
||||
annotations={
|
||||
textAnnotationsByTargetKey[
|
||||
getInstallationDrawingCaptureTargetKey(textAnnotationTarget)
|
||||
] ?? []
|
||||
}
|
||||
onSave={(annotations) => {
|
||||
commitTextAnnotations(textAnnotationTarget, annotations);
|
||||
setTextAnnotationTarget(null);
|
||||
}}
|
||||
onClose={() => setTextAnnotationTarget(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@ -80,9 +80,11 @@ function buildProps(initialScrollTop: number, onCaptureTarget: jest.Mock) {
|
||||
actions: [],
|
||||
inventoryTransforms: {},
|
||||
},
|
||||
textAnnotationsByTargetKey: {},
|
||||
initialScrollTop,
|
||||
onCaptureTarget,
|
||||
onUpdateImageLayout: jest.fn(),
|
||||
onUpdateTextAnnotations: jest.fn(),
|
||||
autoCapture: {
|
||||
phase: "idle" as const,
|
||||
error: null,
|
||||
|
||||
@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Minus, Plus, Trash2, Type, X } from "lucide-react";
|
||||
import {
|
||||
MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET,
|
||||
MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
|
||||
MAX_INSTALLATION_DRAWING_TEXT_LENGTH,
|
||||
MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
|
||||
clampInstallationDrawingTextFontSize,
|
||||
cloneInstallationDrawingTextAnnotations,
|
||||
createInstallationDrawingTextAnnotation,
|
||||
type InstallationDrawingTextAnnotation,
|
||||
} from "../lib/installationDrawingTextAnnotations";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
annotations: InstallationDrawingTextAnnotation[];
|
||||
onSave: (annotations: InstallationDrawingTextAnnotation[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function InstallationDrawingTextAnnotationDialog({
|
||||
title,
|
||||
annotations,
|
||||
onSave,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [items, setItems] = useState(() =>
|
||||
cloneInstallationDrawingTextAnnotations(annotations),
|
||||
);
|
||||
|
||||
const updateItem = (
|
||||
id: string,
|
||||
patch: Partial<InstallationDrawingTextAnnotation>,
|
||||
) => {
|
||||
setItems((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[99] 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-[720px] 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 className="min-w-0">
|
||||
<p className="truncate 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 shrink-0 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">
|
||||
<span className="text-[10px] font-black text-[#596164]">共 {items.length} 个文本框</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setItems((current) => [
|
||||
...current,
|
||||
createInstallationDrawingTextAnnotation(current.length),
|
||||
])
|
||||
}
|
||||
disabled={items.length >= MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] border border-black/10 px-3 text-[11px] font-black text-[#2d3335] disabled:opacity-40"
|
||||
>
|
||||
<Type size={13} /> 添加文本
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="flex min-h-32 items-center justify-center border border-dashed border-black/15 text-[11px] font-bold text-[#7a8284]">
|
||||
点击“添加文本”创建第一个文字标注
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2.5">
|
||||
{items.map((item, index) => (
|
||||
<section
|
||||
key={item.id}
|
||||
className="grid grid-cols-[34px_minmax(0,1fr)_32px] items-center gap-3 border border-black/10 px-3 py-2.5 md:grid-cols-[34px_minmax(0,1fr)_146px_32px]"
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center bg-[#2d3335] text-[10px] font-black text-white">
|
||||
{index + 1}
|
||||
</span>
|
||||
<textarea
|
||||
value={item.text}
|
||||
onChange={(event) =>
|
||||
updateItem(item.id, {
|
||||
text: event.target.value.slice(0, MAX_INSTALLATION_DRAWING_TEXT_LENGTH),
|
||||
})
|
||||
}
|
||||
rows={2}
|
||||
maxLength={MAX_INSTALLATION_DRAWING_TEXT_LENGTH}
|
||||
placeholder="输入安装说明"
|
||||
aria-label={`文字标注 ${index + 1} 内容`}
|
||||
className="min-h-[52px] w-full resize-y border border-black/10 px-2.5 py-2 text-[12px] font-semibold leading-5 text-[#202526] outline-none focus:border-[#596164]"
|
||||
/>
|
||||
<div className="col-start-2 row-start-2 flex items-center justify-start gap-1 md:col-auto md:row-auto md:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateItem(item.id, {
|
||||
fontSize: clampInstallationDrawingTextFontSize(item.fontSize - 1),
|
||||
})
|
||||
}
|
||||
disabled={item.fontSize <= MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE}
|
||||
aria-label={`缩小文字标注 ${index + 1}`}
|
||||
title="缩小字号"
|
||||
className="inline-flex h-8 w-8 items-center justify-center border border-black/10 text-[#596164] disabled:opacity-30"
|
||||
>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
<label className="flex h-8 w-[64px] items-center border border-black/10 px-1.5 text-[10px] font-black text-[#596164]">
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE}
|
||||
max={MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE}
|
||||
value={item.fontSize}
|
||||
onChange={(event) =>
|
||||
updateItem(item.id, {
|
||||
fontSize: clampInstallationDrawingTextFontSize(Number(event.target.value)),
|
||||
})
|
||||
}
|
||||
aria-label={`文字标注 ${index + 1} 字号`}
|
||||
className="min-w-0 flex-1 bg-transparent text-center outline-none"
|
||||
/>
|
||||
px
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateItem(item.id, {
|
||||
fontSize: clampInstallationDrawingTextFontSize(item.fontSize + 1),
|
||||
})
|
||||
}
|
||||
disabled={item.fontSize >= MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE}
|
||||
aria-label={`放大文字标注 ${index + 1}`}
|
||||
title="放大字号"
|
||||
className="inline-flex h-8 w-8 items-center justify-center border border-black/10 text-[#596164] disabled:opacity-30"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setItems((current) => current.filter((entry) => entry.id !== item.id))}
|
||||
aria-label={`删除文字标注 ${index + 1}`}
|
||||
title="删除"
|
||||
className="col-start-3 row-start-1 inline-flex h-8 w-8 items-center justify-center text-[#a94b3d] md:col-auto md:row-auto"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 justify-end gap-2 border-t border-black/10 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-8 rounded-[6px] border border-black/10 px-4 text-[11px] font-black text-[#596164]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSave(
|
||||
items
|
||||
.map((item) => ({ ...item, text: item.text.trim() }))
|
||||
.filter((item) => item.text.length > 0),
|
||||
)
|
||||
}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-[6px] bg-[#2d3335] px-4 text-[11px] font-black text-white"
|
||||
>
|
||||
<Check size={13} /> 保存
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { InstallationDrawingTextAnnotations } from "./InstallationDrawingTextAnnotations";
|
||||
|
||||
describe("InstallationDrawingTextAnnotations", () => {
|
||||
const annotation = {
|
||||
id: "text-1",
|
||||
text: "锁紧四颗螺丝",
|
||||
x: 0.4,
|
||||
y: 0.3,
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
it("commits the dragged template position", () => {
|
||||
const onCommit = jest.fn();
|
||||
render(
|
||||
<div className="relative h-[100px] w-[200px]">
|
||||
<InstallationDrawingTextAnnotations
|
||||
annotations={[annotation]}
|
||||
editable
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
const text = screen.getByRole("button", { name: "移动文字标注 锁紧四颗螺丝" });
|
||||
const layer = text.parentElement as HTMLDivElement;
|
||||
Object.defineProperty(layer, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: () => ({ width: 200, height: 100 }),
|
||||
});
|
||||
Object.defineProperty(text, "setPointerCapture", {
|
||||
configurable: true,
|
||||
value: jest.fn(),
|
||||
});
|
||||
Object.defineProperty(text, "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: 5 });
|
||||
fireEvent(text, event);
|
||||
};
|
||||
dispatchPointer("pointerdown", 80, 30);
|
||||
dispatchPointer("pointermove", 120, 50);
|
||||
dispatchPointer("pointerup", 120, 50);
|
||||
|
||||
expect(onCommit).toHaveBeenCalledTimes(1);
|
||||
const committed = onCommit.mock.calls[0][0][0];
|
||||
expect(committed).toMatchObject({
|
||||
id: "text-1",
|
||||
text: "锁紧四颗螺丝",
|
||||
fontSize: 14,
|
||||
});
|
||||
expect(committed.x).toBeCloseTo(0.6);
|
||||
expect(committed.y).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it("keeps a separate static print layer", () => {
|
||||
const { container } = render(
|
||||
<InstallationDrawingTextAnnotations
|
||||
annotations={[annotation]}
|
||||
editable
|
||||
onCommit={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const printText = container.querySelector(
|
||||
'[data-drawing-print-text-annotation="text-1"]',
|
||||
);
|
||||
expect(printText).not.toBeNull();
|
||||
expect(printText).not.toHaveAttribute("role");
|
||||
expect(printText).not.toHaveAttribute("title");
|
||||
expect(printText).toHaveStyle({ fontSize: "14px" });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import {
|
||||
clampInstallationDrawingTextPosition,
|
||||
type InstallationDrawingTextAnnotation,
|
||||
} from "../lib/installationDrawingTextAnnotations";
|
||||
|
||||
interface Props {
|
||||
annotations: InstallationDrawingTextAnnotation[];
|
||||
editable?: boolean;
|
||||
onCommit?: (annotations: InstallationDrawingTextAnnotation[]) => void;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: string;
|
||||
pointerId: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function InstallationDrawingTextAnnotations({
|
||||
annotations,
|
||||
editable = false,
|
||||
onCommit,
|
||||
}: Props) {
|
||||
const layerRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const latestRef = useRef(annotations);
|
||||
const [draggedAnnotations, setDraggedAnnotations] = useState<InstallationDrawingTextAnnotation[] | null>(null);
|
||||
const currentAnnotations = draggedAnnotations ?? annotations;
|
||||
|
||||
const beginDrag = (
|
||||
event: ReactPointerEvent<HTMLSpanElement>,
|
||||
annotation: InstallationDrawingTextAnnotation,
|
||||
) => {
|
||||
if (!editable || !onCommit) return;
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
latestRef.current = annotations;
|
||||
setDraggedAnnotations(annotations);
|
||||
dragRef.current = {
|
||||
id: annotation.id,
|
||||
pointerId: event.pointerId,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
x: annotation.x,
|
||||
y: annotation.y,
|
||||
};
|
||||
};
|
||||
|
||||
const moveText = (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 = clampInstallationDrawingTextPosition(
|
||||
drag.x + (event.clientX - drag.clientX) / bounds.width,
|
||||
);
|
||||
const nextY = clampInstallationDrawingTextPosition(
|
||||
drag.y + (event.clientY - drag.clientY) / bounds.height,
|
||||
);
|
||||
const nextAnnotations = latestRef.current.map((item) =>
|
||||
item.id === drag.id ? { ...item, x: nextX, y: nextY } : item,
|
||||
);
|
||||
latestRef.current = nextAnnotations;
|
||||
setDraggedAnnotations(nextAnnotations);
|
||||
};
|
||||
|
||||
const endDrag = (event: ReactPointerEvent<HTMLSpanElement>) => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return;
|
||||
event.stopPropagation();
|
||||
dragRef.current = null;
|
||||
onCommit?.(latestRef.current);
|
||||
setDraggedAnnotations(null);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={layerRef}
|
||||
className="drawing-text-annotation-screen-layer pointer-events-none absolute inset-0 z-[3] select-none print:hidden"
|
||||
>
|
||||
{currentAnnotations.map((annotation) => (
|
||||
<span
|
||||
key={annotation.id}
|
||||
role={editable ? "button" : undefined}
|
||||
aria-label={editable ? `移动文字标注 ${annotation.text}` : undefined}
|
||||
title={editable ? "拖动文字标注" : undefined}
|
||||
data-drawing-text-annotation={annotation.id}
|
||||
onPointerDown={(event) => beginDrag(event, annotation)}
|
||||
onPointerMove={moveText}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
className={`absolute -translate-x-1/2 -translate-y-1/2 whitespace-pre-wrap break-words text-left font-semibold leading-[1.25] text-[#202526] ${editable ? "pointer-events-auto cursor-move rounded-[2px] bg-white/85 px-1 py-0.5 shadow-[0_1px_4px_rgba(32,37,38,0.18)] ring-1 ring-[#202526]/25 touch-none" : "pointer-events-none"}`}
|
||||
style={{
|
||||
left: `${annotation.x * 100}%`,
|
||||
top: `${annotation.y * 100}%`,
|
||||
maxWidth: "84%",
|
||||
fontSize: `${annotation.fontSize}px`,
|
||||
}}
|
||||
>
|
||||
{annotation.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="drawing-text-annotation-print-layer pointer-events-none absolute inset-0 z-[3] hidden overflow-hidden select-none print:block">
|
||||
{annotations.map((annotation) => (
|
||||
<span
|
||||
key={annotation.id}
|
||||
data-drawing-print-text-annotation={annotation.id}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2 whitespace-pre-wrap break-words text-left font-semibold leading-[1.25] text-[#202526]"
|
||||
style={{
|
||||
left: `${annotation.x * 100}%`,
|
||||
top: `${annotation.y * 100}%`,
|
||||
maxWidth: "84%",
|
||||
fontSize: `${annotation.fontSize}px`,
|
||||
}}
|
||||
>
|
||||
{annotation.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -24,7 +24,10 @@ import {
|
||||
type InstallationDrawingImageMode,
|
||||
type InstallationDrawingNumberMarker,
|
||||
} from "../lib/installationDrawingCaptureTypes";
|
||||
import { resolveInstallationDrawingPendingTargets } from "../lib/installationDrawingAutoCapturePlan";
|
||||
import {
|
||||
resolveInstallationDrawingAutoCaptureStartMode,
|
||||
resolveInstallationDrawingPendingTargets,
|
||||
} from "../lib/installationDrawingAutoCapturePlan";
|
||||
import { preserveInstallationDrawingCaptureAnnotations } from "../lib/installationDrawingCapturePreservation";
|
||||
|
||||
type AutoCapturePhase =
|
||||
@ -177,14 +180,14 @@ export function useInstallationDrawingAutoCapture({
|
||||
);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (
|
||||
!drawingId ||
|
||||
!modelUrl ||
|
||||
!configFingerprint ||
|
||||
phase === "loading" ||
|
||||
(snapshot?.active && !snapshot.draft)
|
||||
)
|
||||
return;
|
||||
const startMode = resolveInstallationDrawingAutoCaptureStartMode({
|
||||
drawingId,
|
||||
modelUrl,
|
||||
configFingerprint,
|
||||
hasActiveBatch: Boolean(snapshot?.active),
|
||||
loading: phase === "loading",
|
||||
});
|
||||
if (!startMode || !drawingId || !modelUrl || !configFingerprint) return;
|
||||
pauseRequestedRef.current = false;
|
||||
setError(null);
|
||||
setPhase("seeding");
|
||||
@ -195,7 +198,7 @@ export function useInstallationDrawingAutoCapture({
|
||||
configFingerprint,
|
||||
expectedTargetKeys: targetKeys,
|
||||
inventoryTransforms: captures.inventoryTransforms,
|
||||
mode: snapshot?.active ? "replace" : "fill",
|
||||
mode: startMode,
|
||||
});
|
||||
const draft = workingSnapshot?.draft;
|
||||
if (!draft) throw new Error("自动抓取草稿批次创建失败");
|
||||
|
||||
@ -11,6 +11,7 @@ import type {
|
||||
import { buildInstallationStepMaterialItems } from '../lib/installationStepMaterials'
|
||||
import { collectInstallationToolAttachmentIdentities } from '../lib/installationToolAttachmentIdentity'
|
||||
import { preserveInstallationDrawingCaptureAnnotations } from '../lib/installationDrawingCapturePreservation'
|
||||
import { resolveInstallationDrawingTechnicalLineAnnotation } from '../lib/installationDrawingActionCaptureFrame'
|
||||
import type {
|
||||
InstallationDrawingActionCapture,
|
||||
InstallationDrawingCaptures,
|
||||
@ -290,6 +291,13 @@ export function useInstallationDrawingCapture({
|
||||
const lineArtUrl = capture({
|
||||
...INSTALLATION_DRAWING_LINE_CAPTURE_OPTIONS,
|
||||
technicalLineStyle: captureTarget.kind === 'finished' ? 'clean' : 'detail',
|
||||
technicalLineAnnotation: captureTarget.kind === 'action'
|
||||
? resolveInstallationDrawingTechnicalLineAnnotation({
|
||||
target: captureTarget,
|
||||
steps,
|
||||
numberingParts,
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
if (!lineArtUrl) throw new Error('当前模型无法生成打印线稿,请确认模型几何已完整加载。')
|
||||
const capturedImage = {
|
||||
@ -323,7 +331,16 @@ export function useInstallationDrawingCapture({
|
||||
} finally {
|
||||
setCapturing(false)
|
||||
}
|
||||
}, [captureReady, captureRef, captureTarget, captures, capturing, numberingMarkersRef])
|
||||
}, [
|
||||
captureReady,
|
||||
captureRef,
|
||||
captureTarget,
|
||||
captures,
|
||||
capturing,
|
||||
numberingMarkersRef,
|
||||
numberingParts,
|
||||
steps,
|
||||
])
|
||||
|
||||
const applyPersistedBatch = useCallback((batch: InstallationDrawingCaptureBatch | null) => {
|
||||
if (!batch) {
|
||||
|
||||
@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
cloneInstallationDrawingTextAnnotations,
|
||||
sanitizeInstallationDrawingTextAnnotations,
|
||||
type InstallationDrawingTextAnnotation,
|
||||
type InstallationDrawingTextAnnotationsByTarget,
|
||||
} from "../lib/installationDrawingTextAnnotations";
|
||||
|
||||
interface AnnotationSnapshot {
|
||||
annotationsByTargetKey?: unknown;
|
||||
}
|
||||
|
||||
interface AnnotationApiResponse {
|
||||
success?: boolean;
|
||||
data?: { snapshot?: AnnotationSnapshot };
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
async function readResponse(response: Response, fallback: string) {
|
||||
const payload = (await response.json().catch(() => null)) as AnnotationApiResponse | null;
|
||||
if (!response.ok || !payload?.success) {
|
||||
throw new Error(payload?.error?.message || fallback);
|
||||
}
|
||||
return sanitizeInstallationDrawingTextAnnotations(
|
||||
payload.data?.snapshot?.annotationsByTargetKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function useInstallationDrawingTextAnnotations(
|
||||
drawingId: string | null,
|
||||
canEdit: boolean,
|
||||
) {
|
||||
const [snapshotState, setSnapshotState] = useState<{
|
||||
drawingId: string | null;
|
||||
annotations: InstallationDrawingTextAnnotationsByTarget;
|
||||
}>({ drawingId: null, annotations: {} });
|
||||
const [messageState, setMessageState] = useState<{
|
||||
drawingId: string | null;
|
||||
message: string | null;
|
||||
}>({ drawingId: null, message: null });
|
||||
const saveQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const annotationsByTargetKey =
|
||||
snapshotState.drawingId === drawingId ? snapshotState.annotations : {};
|
||||
const message = messageState.drawingId === drawingId ? messageState.message : null;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!drawingId) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
const searchParams = new URLSearchParams({ drawingId });
|
||||
void fetch(
|
||||
`/api/installation-demo-workbench/drawing-text-annotations?${searchParams.toString()}`,
|
||||
{ credentials: "same-origin" },
|
||||
)
|
||||
.then((response) => readResponse(response, "图纸文字标注读取失败"))
|
||||
.then((annotations) => {
|
||||
if (!cancelled) setSnapshotState({ drawingId, annotations });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
setMessageState({
|
||||
drawingId,
|
||||
message: error instanceof Error ? error.message : "图纸文字标注读取失败",
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [drawingId]);
|
||||
|
||||
const saveTargetAnnotations = useCallback(
|
||||
(targetKey: string, annotations: InstallationDrawingTextAnnotation[]) => {
|
||||
if (!drawingId || !canEdit) return;
|
||||
const nextAnnotations = cloneInstallationDrawingTextAnnotations(annotations);
|
||||
setSnapshotState((current) => {
|
||||
const next = current.drawingId === drawingId ? { ...current.annotations } : {};
|
||||
if (nextAnnotations.length === 0) delete next[targetKey];
|
||||
else next[targetKey] = nextAnnotations;
|
||||
return { drawingId, annotations: next };
|
||||
});
|
||||
setMessageState({ drawingId, message: null });
|
||||
saveQueueRef.current = saveQueueRef.current
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const response = await fetch(
|
||||
"/api/installation-demo-workbench/drawing-text-annotations",
|
||||
{
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ drawingId, targetKey, annotations: nextAnnotations }),
|
||||
},
|
||||
);
|
||||
await readResponse(response, "图纸文字标注保存失败");
|
||||
})
|
||||
.catch((error) => {
|
||||
setMessageState({
|
||||
drawingId,
|
||||
message: error instanceof Error ? error.message : "图纸文字标注保存失败",
|
||||
});
|
||||
});
|
||||
},
|
||||
[canEdit, drawingId],
|
||||
);
|
||||
|
||||
return {
|
||||
annotationsByTargetKey,
|
||||
message,
|
||||
saveTargetAnnotations,
|
||||
};
|
||||
}
|
||||
@ -8,6 +8,7 @@ import {
|
||||
isInstallationDrawingActionCaptureFrameApplied,
|
||||
isInstallationDrawingActionWorkspaceReady,
|
||||
resolveInstallationDrawingActionCaptureFrame,
|
||||
resolveInstallationDrawingTechnicalLineAnnotation,
|
||||
} from './installationDrawingActionCaptureFrame'
|
||||
import type { InstallationPartItem, InstallationStepDraft } from '../types'
|
||||
|
||||
@ -61,6 +62,87 @@ const step: InstallationStepDraft = {
|
||||
}
|
||||
|
||||
describe('resolveInstallationDrawingActionCaptureFrame', () => {
|
||||
it('builds red action nodes and blue hardware paths from the saved assembled transforms', () => {
|
||||
const actionStep = {
|
||||
...step,
|
||||
hardwareNodeNames: ['action-2'],
|
||||
}
|
||||
const target = {
|
||||
kind: 'action' as const,
|
||||
id: 'step-1:action-2',
|
||||
stepId: 'step-1',
|
||||
frameKind: 'action' as const,
|
||||
actionId: 'action-2',
|
||||
sequence: 3,
|
||||
}
|
||||
|
||||
expect(resolveInstallationDrawingTechnicalLineAnnotation({
|
||||
target,
|
||||
steps: [actionStep],
|
||||
numberingParts: [],
|
||||
})).toEqual({
|
||||
activeNodeNames: ['action-2'],
|
||||
hardwareNodeNames: ['action-2'],
|
||||
assembledTransforms: [
|
||||
{ nodeName: 'fixed', position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
{ nodeName: 'action-1', position: [1, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
{ nodeName: 'action-2', position: [2, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not add action annotations to a fixed frame', () => {
|
||||
expect(resolveInstallationDrawingTechnicalLineAnnotation({
|
||||
target: {
|
||||
kind: 'action',
|
||||
id: 'step-1:fixed',
|
||||
stepId: 'step-1',
|
||||
frameKind: 'fixed',
|
||||
actionId: null,
|
||||
sequence: 1,
|
||||
},
|
||||
steps: [step],
|
||||
numberingParts: [],
|
||||
})).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a part-only action red without inventing a hardware path', () => {
|
||||
expect(resolveInstallationDrawingTechnicalLineAnnotation({
|
||||
target: {
|
||||
kind: 'action',
|
||||
id: 'step-1:action-1',
|
||||
stepId: 'step-1',
|
||||
frameKind: 'action',
|
||||
actionId: 'action-1',
|
||||
sequence: 2,
|
||||
},
|
||||
steps: [step],
|
||||
numberingParts: [],
|
||||
})?.hardwareNodeNames).toEqual([])
|
||||
})
|
||||
|
||||
it('recognizes hardware from numbering bindings when legacy step role arrays are empty', () => {
|
||||
const numberingParts = [{
|
||||
id: 'hardware-part',
|
||||
nodeName: 'action-2',
|
||||
nodeNames: ['action-2'],
|
||||
kind: 'hardware',
|
||||
}] as InstallationPartItem[]
|
||||
|
||||
expect(resolveInstallationDrawingTechnicalLineAnnotation({
|
||||
target: {
|
||||
kind: 'action',
|
||||
id: 'step-1:action-2',
|
||||
stepId: 'step-1',
|
||||
frameKind: 'action',
|
||||
actionId: 'action-2',
|
||||
sequence: 3,
|
||||
},
|
||||
steps: [step],
|
||||
numberingParts,
|
||||
})?.hardwareNodeNames).toEqual(['action-2'])
|
||||
})
|
||||
|
||||
it('shows only the step fixed nodes in the fixed opening frame', () => {
|
||||
const frame = resolveInstallationDrawingActionCaptureFrame({
|
||||
target: {
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import type { NodeLocalTransformState, ThreeDEngine } from '@/lib/3d/engine'
|
||||
import type {
|
||||
EngineTechnicalLineAnnotation,
|
||||
NodeLocalTransformState,
|
||||
ThreeDEngine,
|
||||
} from '@/lib/3d/engine'
|
||||
import { buildInstallationStepEditFrame } from './installationStepActionPlayback'
|
||||
import { deriveInstallationFinishClosePlaybackStep } from './installationFinishCloseBinding'
|
||||
import type { InstallationDrawingActionCaptureTarget } from './installationDrawingCaptureTypes'
|
||||
@ -137,6 +141,42 @@ interface ResolveInstallationDrawingActionCaptureFrameInput {
|
||||
modelNodeNames: string[]
|
||||
}
|
||||
|
||||
export function resolveInstallationDrawingTechnicalLineAnnotation({
|
||||
target,
|
||||
steps,
|
||||
numberingParts,
|
||||
}: Omit<ResolveInstallationDrawingActionCaptureFrameInput, 'modelNodeNames'>):
|
||||
EngineTechnicalLineAnnotation | undefined {
|
||||
if (!target || target.frameKind !== 'action' || !target.actionId) return undefined
|
||||
const storedStep = steps.find((item) => item.id === target.stepId) ?? null
|
||||
if (!storedStep) return undefined
|
||||
const step = deriveInstallationFinishClosePlaybackStep({
|
||||
step: storedStep,
|
||||
numberingParts,
|
||||
})
|
||||
const action = step?.actions?.find((item) => item.id === target.actionId) ?? null
|
||||
if (!step || !action) return undefined
|
||||
|
||||
const hardwareNodeNameSet = new Set([
|
||||
...step.hardwareNodeNames,
|
||||
...numberingParts
|
||||
.filter((part) => part.kind === 'hardware')
|
||||
.flatMap((part) => [part.nodeName, ...part.nodeNames])
|
||||
.filter((nodeName): nodeName is string => Boolean(nodeName)),
|
||||
])
|
||||
const assembledTransforms = Object.entries(step.assembledTransforms ?? {}).flatMap(
|
||||
([nodeName, transform]) => transform.position
|
||||
? [{ nodeName, position: transform.position, rotation: transform.rotation }]
|
||||
: [],
|
||||
)
|
||||
|
||||
return {
|
||||
activeNodeNames: action.nodeNames,
|
||||
hardwareNodeNames: action.nodeNames.filter((nodeName) => hardwareNodeNameSet.has(nodeName)),
|
||||
assembledTransforms,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveInstallationDrawingActionCaptureFrame({
|
||||
target,
|
||||
steps,
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import { resolveInstallationDrawingPendingTargets } from './installationDrawingAutoCapturePlan'
|
||||
import {
|
||||
resolveInstallationDrawingAutoCaptureStartMode,
|
||||
resolveInstallationDrawingPendingTargets,
|
||||
} from './installationDrawingAutoCapturePlan'
|
||||
import type { InstallationDrawingCaptureTarget } from './installationDrawingCaptureTypes'
|
||||
|
||||
const targets: Array<Exclude<InstallationDrawingCaptureTarget, { kind: 'inventory' }>> = [
|
||||
@ -25,3 +28,33 @@ describe('resolveInstallationDrawingPendingTargets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveInstallationDrawingAutoCaptureStartMode', () => {
|
||||
const readyState = {
|
||||
drawingId: 'drawing-1',
|
||||
modelUrl: '/model.glb',
|
||||
configFingerprint: 'config-1',
|
||||
loading: false,
|
||||
}
|
||||
|
||||
it('starts a full replacement when a completed batch already exists', () => {
|
||||
expect(resolveInstallationDrawingAutoCaptureStartMode({
|
||||
...readyState,
|
||||
hasActiveBatch: true,
|
||||
})).toBe('replace')
|
||||
})
|
||||
|
||||
it('starts a fill batch when no completed batch exists', () => {
|
||||
expect(resolveInstallationDrawingAutoCaptureStartMode({
|
||||
...readyState,
|
||||
hasActiveBatch: false,
|
||||
})).toBe('fill')
|
||||
})
|
||||
|
||||
it('does not start before the drawing context is ready', () => {
|
||||
expect(resolveInstallationDrawingAutoCaptureStartMode({
|
||||
...readyState,
|
||||
hasActiveBatch: true,
|
||||
loading: true,
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,6 +5,22 @@ import {
|
||||
|
||||
type CaptureTarget = Exclude<InstallationDrawingCaptureTarget, { kind: 'inventory' }>
|
||||
|
||||
export function resolveInstallationDrawingAutoCaptureStartMode(input: {
|
||||
drawingId: string | null
|
||||
modelUrl: string | null
|
||||
configFingerprint: string | null
|
||||
hasActiveBatch: boolean
|
||||
loading: boolean
|
||||
}) {
|
||||
if (
|
||||
!input.drawingId
|
||||
|| !input.modelUrl
|
||||
|| !input.configFingerprint
|
||||
|| input.loading
|
||||
) return null
|
||||
return input.hasActiveBatch ? 'replace' : 'fill'
|
||||
}
|
||||
|
||||
export function resolveInstallationDrawingPendingTargets(
|
||||
targets: CaptureTarget[],
|
||||
completedTargetKeys: Iterable<string>,
|
||||
@ -14,4 +30,3 @@ export function resolveInstallationDrawingPendingTargets(
|
||||
(target) => !completed.has(getInstallationDrawingCaptureTargetKey(target)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
export interface InstallationDrawingTextAnnotation {
|
||||
id: string;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
export type InstallationDrawingTextAnnotationsByTarget = Record<
|
||||
string,
|
||||
InstallationDrawingTextAnnotation[]
|
||||
>;
|
||||
|
||||
export const DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE = 12;
|
||||
export const MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE = 8;
|
||||
export const MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE = 32;
|
||||
export const MAX_INSTALLATION_DRAWING_TEXT_LENGTH = 240;
|
||||
export const MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET = 24;
|
||||
|
||||
export function createInstallationDrawingTextAnnotation(
|
||||
index = 0,
|
||||
): InstallationDrawingTextAnnotation {
|
||||
return {
|
||||
id: `text-${Date.now()}-${index}`,
|
||||
text: "",
|
||||
x: Math.min(0.8, 0.42 + index * 0.04),
|
||||
y: Math.min(0.82, 0.42 + index * 0.05),
|
||||
fontSize: DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
export function clampInstallationDrawingTextPosition(value: number) {
|
||||
if (!Number.isFinite(value)) return 0.5;
|
||||
return Math.min(0.98, Math.max(0.02, value));
|
||||
}
|
||||
|
||||
export function clampInstallationDrawingTextFontSize(value: number) {
|
||||
if (!Number.isFinite(value)) return DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE;
|
||||
return Math.min(
|
||||
MAX_INSTALLATION_DRAWING_TEXT_FONT_SIZE,
|
||||
Math.max(MIN_INSTALLATION_DRAWING_TEXT_FONT_SIZE, Math.round(value)),
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeInstallationDrawingTextAnnotations(
|
||||
value: unknown,
|
||||
): InstallationDrawingTextAnnotationsByTarget {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const result: InstallationDrawingTextAnnotationsByTarget = {};
|
||||
for (const [targetKey, entries] of Object.entries(value)) {
|
||||
if (!targetKey.trim() || !Array.isArray(entries)) continue;
|
||||
result[targetKey] = entries
|
||||
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
|
||||
.map((entry, index) => {
|
||||
const text = typeof entry.text === "string" ? entry.text.slice(0, MAX_INSTALLATION_DRAWING_TEXT_LENGTH) : "";
|
||||
return {
|
||||
id: typeof entry.id === "string" && entry.id.trim() ? entry.id : `text-${targetKey}-${index}`,
|
||||
text,
|
||||
x: clampInstallationDrawingTextPosition(Number(entry.x)),
|
||||
y: clampInstallationDrawingTextPosition(Number(entry.y)),
|
||||
fontSize: clampInstallationDrawingTextFontSize(Number(entry.fontSize) || DEFAULT_INSTALLATION_DRAWING_TEXT_FONT_SIZE),
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.text.trim().length > 0)
|
||||
.slice(0, MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function cloneInstallationDrawingTextAnnotations(
|
||||
annotations: InstallationDrawingTextAnnotation[],
|
||||
) {
|
||||
return annotations.map((annotation) => ({ ...annotation }));
|
||||
}
|
||||
@ -76,6 +76,12 @@ export interface NodeLocalTransformState {
|
||||
rotation: [number, number, number, number];
|
||||
}
|
||||
|
||||
export interface EngineTechnicalLineAnnotation {
|
||||
activeNodeNames: string[];
|
||||
hardwareNodeNames: string[];
|
||||
assembledTransforms: NodeLocalTransformState[];
|
||||
}
|
||||
|
||||
export type EngineWorldNodeSource =
|
||||
| "desktop"
|
||||
| "leg"
|
||||
@ -113,6 +119,7 @@ export interface EngineCaptureOptions {
|
||||
cropAlphaThreshold?: number;
|
||||
renderMode?: "default" | "technical-line";
|
||||
technicalLineStyle?: "clean" | "detail";
|
||||
technicalLineAnnotation?: EngineTechnicalLineAnnotation;
|
||||
renderCamera?: THREE.Camera;
|
||||
}
|
||||
|
||||
@ -1676,6 +1683,33 @@ export class ThreeDEngine {
|
||||
...(this.modelRoot ? [this.modelRoot] : []),
|
||||
...Array.from(this.externalRaycastRoots),
|
||||
];
|
||||
const annotation = options.technicalLineAnnotation
|
||||
? {
|
||||
activeObjects: options.technicalLineAnnotation.activeNodeNames.flatMap(
|
||||
(nodeName) => {
|
||||
const object = this.resolveObjectByNodeName(nodeName);
|
||||
return object ? [object] : [];
|
||||
},
|
||||
),
|
||||
hardwareObjects: options.technicalLineAnnotation.hardwareNodeNames.flatMap(
|
||||
(nodeName) => {
|
||||
const object = this.resolveObjectByNodeName(nodeName);
|
||||
return object ? [object] : [];
|
||||
},
|
||||
),
|
||||
assembledTransforms:
|
||||
options.technicalLineAnnotation.assembledTransforms.flatMap((transform) => {
|
||||
const object = this.resolveObjectByNodeName(transform.nodeName);
|
||||
return object
|
||||
? [{
|
||||
object,
|
||||
position: transform.position,
|
||||
rotation: transform.rotation,
|
||||
}]
|
||||
: [];
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
const drawingBufferSize = this.renderer.getDrawingBufferSize(new THREE.Vector2());
|
||||
return withTechnicalLineCaptureScene(
|
||||
roots,
|
||||
@ -1698,6 +1732,7 @@ export class ThreeDEngine {
|
||||
viewportWidth: drawingBufferSize.x,
|
||||
viewportHeight: drawingBufferSize.y,
|
||||
style: options.technicalLineStyle ?? "detail",
|
||||
annotation,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,293 @@
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
collectTechnicalLineActiveMeshes,
|
||||
resolveTechnicalLineHardwarePaths,
|
||||
type TechnicalLineCaptureAnnotation,
|
||||
} from './technical-line-annotations'
|
||||
|
||||
function createMesh(name: string) {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(2, 2, 2),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
mesh.name = name
|
||||
return mesh
|
||||
}
|
||||
|
||||
function createFrontCamera() {
|
||||
const camera = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.1, 20)
|
||||
camera.position.set(0, 0, 10)
|
||||
camera.lookAt(0, 0, 0)
|
||||
camera.updateProjectionMatrix()
|
||||
camera.updateMatrixWorld(true)
|
||||
return camera
|
||||
}
|
||||
|
||||
function createBoardWithHole() {
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(-2, -2)
|
||||
shape.lineTo(2, -2)
|
||||
shape.lineTo(2, 2)
|
||||
shape.lineTo(-2, 2)
|
||||
shape.closePath()
|
||||
const hole = new THREE.Path()
|
||||
hole.absellipse(0, 0, 0.6, 0.6, 0, Math.PI * 2, false, 0)
|
||||
shape.holes.push(hole)
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
depth: 1,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 16,
|
||||
steps: 1,
|
||||
})
|
||||
geometry.translate(0, 0, -0.5)
|
||||
return new THREE.Mesh(geometry, new THREE.MeshBasicMaterial())
|
||||
}
|
||||
|
||||
describe('technical line annotations', () => {
|
||||
test('collects only meshes below the current action objects', () => {
|
||||
const scene = new THREE.Scene()
|
||||
const activeGroup = new THREE.Group()
|
||||
const activeMesh = createMesh('active')
|
||||
const inactiveMesh = createMesh('inactive')
|
||||
activeGroup.add(activeMesh)
|
||||
scene.add(activeGroup, inactiveMesh)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
expect(collectTechnicalLineActiveMeshes(
|
||||
[activeMesh, inactiveMesh],
|
||||
[activeGroup],
|
||||
)).toEqual([activeMesh])
|
||||
})
|
||||
|
||||
test('uses the visible opening of the real receiving hole instead of the saved hardware center', () => {
|
||||
const scene = new THREE.Scene()
|
||||
const modelRoot = new THREE.Group()
|
||||
const hardware = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 1, 0.4),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
const board = createBoardWithHole()
|
||||
hardware.position.set(0, 1, 3)
|
||||
board.position.set(0, 1, 0)
|
||||
modelRoot.add(hardware, board)
|
||||
scene.add(modelRoot)
|
||||
scene.updateMatrixWorld(true)
|
||||
modelRoot.matrixWorld.makeTranslation(4, 0, 0)
|
||||
hardware.updateMatrixWorld(true)
|
||||
board.updateMatrixWorld(true)
|
||||
|
||||
const [path] = resolveTechnicalLineHardwarePaths({
|
||||
activeObjects: [hardware],
|
||||
hardwareObjects: [hardware],
|
||||
assembledTransforms: [
|
||||
{ object: hardware, position: [0, 0, 0.2], rotation: [0, 0, 0, 1] },
|
||||
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
],
|
||||
}, [hardware, board], createFrontCamera())
|
||||
|
||||
expect(path.start.x).toBeCloseTo(4)
|
||||
expect(path.start.y).toBeCloseTo(1)
|
||||
expect(path.start.z).toBeCloseTo(2.8)
|
||||
expect(path.end.x).toBeCloseTo(4)
|
||||
expect(path.end.y).toBeCloseTo(1)
|
||||
expect(path.end.z).toBeCloseTo(0.5)
|
||||
})
|
||||
|
||||
test('skips a nearer hidden hole loop and uses the next visible opening', () => {
|
||||
const scene = new THREE.Scene()
|
||||
const hardware = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 1, 0.4),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
const board = createBoardWithHole()
|
||||
const internalBlocker = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(2, 2, 0.1),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
hardware.position.set(0, 0, 3)
|
||||
scene.add(hardware, board, internalBlocker)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
const [path] = resolveTechnicalLineHardwarePaths({
|
||||
activeObjects: [hardware],
|
||||
hardwareObjects: [hardware],
|
||||
assembledTransforms: [
|
||||
{ object: hardware, position: [0, 0, -0.2], rotation: [0, 0, 0, 1] },
|
||||
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
],
|
||||
}, [hardware, board, internalBlocker], createFrontCamera())
|
||||
|
||||
expect(path.start.z).toBeCloseTo(2.8)
|
||||
expect(path.end.x).toBeCloseTo(0)
|
||||
expect(path.end.y).toBeCloseTo(0)
|
||||
expect(path.end.z).toBeCloseTo(0.5)
|
||||
})
|
||||
|
||||
test('does not draw a receiving hole hidden behind another board', () => {
|
||||
const scene = new THREE.Scene()
|
||||
const hardware = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 1, 0.4),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
const board = createBoardWithHole()
|
||||
const blocker = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(2, 2, 0.5),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
hardware.position.set(0, 0, 3)
|
||||
blocker.position.set(0, 0, 2)
|
||||
scene.add(hardware, board, blocker)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
expect(resolveTechnicalLineHardwarePaths({
|
||||
activeObjects: [hardware],
|
||||
hardwareObjects: [hardware],
|
||||
assembledTransforms: [
|
||||
{ object: hardware, position: [0, 0, 0.2], rotation: [0, 0, 0, 1] },
|
||||
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
],
|
||||
}, [hardware, board, blocker], createFrontCamera())).toEqual([])
|
||||
})
|
||||
|
||||
test('draws from the hardware installation end along its own axis to the board surface', () => {
|
||||
const scene = new THREE.Scene()
|
||||
const hardware = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.2, 2, 0.2),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
const board = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(4, 1, 4),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
hardware.name = 'hardware'
|
||||
board.name = 'board'
|
||||
hardware.position.set(0, 2, 0)
|
||||
scene.add(hardware, board)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
const annotation: TechnicalLineCaptureAnnotation = {
|
||||
activeObjects: [hardware],
|
||||
hardwareObjects: [hardware],
|
||||
assembledTransforms: [
|
||||
{ object: hardware, position: [0, 0.8, 0], rotation: [0, 0, 0, 1] },
|
||||
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
],
|
||||
}
|
||||
const paths = resolveTechnicalLineHardwarePaths(annotation, [hardware, board])
|
||||
|
||||
expect(paths).toHaveLength(1)
|
||||
expect(paths[0].start.toArray()).toEqual([0, 1, 0])
|
||||
expect(paths[0].end.x).toBeCloseTo(0)
|
||||
expect(paths[0].end.y).toBeCloseTo(0.5)
|
||||
expect(paths[0].end.z).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
test('keeps the path on the hardware axis after rotation', () => {
|
||||
const scene = new THREE.Scene()
|
||||
const hardware = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.2, 2, 0.2),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
const board = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 4, 4),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
hardware.name = 'rotated-hardware'
|
||||
hardware.position.set(2, 0, 0)
|
||||
hardware.rotation.z = Math.PI / 2
|
||||
scene.add(hardware, board)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
const [path] = resolveTechnicalLineHardwarePaths({
|
||||
activeObjects: [hardware],
|
||||
hardwareObjects: [hardware],
|
||||
assembledTransforms: [
|
||||
{
|
||||
object: hardware,
|
||||
position: [0.8, 0, 0],
|
||||
rotation: [0, 0, Math.SQRT1_2, Math.SQRT1_2],
|
||||
},
|
||||
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
],
|
||||
}, [hardware, board])
|
||||
|
||||
expect(path.start.x).toBeCloseTo(1)
|
||||
expect(path.start.y).toBeCloseTo(0)
|
||||
expect(path.end.x).toBeCloseTo(0.5)
|
||||
expect(path.end.y).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
test('stops at the receiving surface when the saved hardware pose flips', () => {
|
||||
const scene = new THREE.Scene()
|
||||
const hardware = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.2, 2, 0.2),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
const board = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(4, 1, 4),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
hardware.position.set(0, 2, 0)
|
||||
scene.add(hardware, board)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
const [path] = resolveTechnicalLineHardwarePaths({
|
||||
activeObjects: [hardware],
|
||||
hardwareObjects: [hardware],
|
||||
assembledTransforms: [
|
||||
{ object: hardware, position: [0, 0.8, 0], rotation: [0, 0, 1, 0] },
|
||||
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
],
|
||||
}, [hardware, board])
|
||||
|
||||
expect(path.start.y).toBeCloseTo(1)
|
||||
expect(path.end.y).toBeCloseTo(0.5)
|
||||
})
|
||||
|
||||
test('creates one path per hardware object and skips missing final transforms', () => {
|
||||
const scene = new THREE.Scene()
|
||||
const first = new THREE.Mesh(new THREE.BoxGeometry(0.2, 2, 0.2), new THREE.MeshBasicMaterial())
|
||||
const second = new THREE.Mesh(new THREE.BoxGeometry(0.2, 2, 0.2), new THREE.MeshBasicMaterial())
|
||||
const missing = new THREE.Mesh(new THREE.BoxGeometry(0.2, 2, 0.2), new THREE.MeshBasicMaterial())
|
||||
const board = new THREE.Mesh(new THREE.BoxGeometry(10, 1, 4), new THREE.MeshBasicMaterial())
|
||||
first.name = 'first'
|
||||
second.name = 'second'
|
||||
missing.name = 'missing'
|
||||
first.position.set(-2, 2, 0)
|
||||
second.position.set(2, 2, 0)
|
||||
missing.position.set(0, 2, 0)
|
||||
scene.add(first, second, missing, board)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
const paths = resolveTechnicalLineHardwarePaths({
|
||||
activeObjects: [first, second, missing],
|
||||
hardwareObjects: [first, second, missing],
|
||||
assembledTransforms: [
|
||||
{ object: first, position: [-2, 0.8, 0], rotation: [0, 0, 0, 1] },
|
||||
{ object: second, position: [2, 0.8, 0], rotation: [0, 0, 0, 1] },
|
||||
{ object: board, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
],
|
||||
}, [first, second, missing, board])
|
||||
|
||||
expect(paths.map((path) => path.object.name)).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
test('does not fall back to a center line when no receiving surface is on the axis', () => {
|
||||
const scene = new THREE.Scene()
|
||||
const hardware = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.2, 2, 0.2),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
hardware.position.set(3, 0, 0)
|
||||
scene.add(hardware)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
expect(resolveTechnicalLineHardwarePaths({
|
||||
activeObjects: [hardware],
|
||||
hardwareObjects: [hardware],
|
||||
assembledTransforms: [
|
||||
{ object: hardware, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
],
|
||||
}, [hardware])).toEqual([])
|
||||
})
|
||||
})
|
||||
297
furniture-3d-system/src/lib/3d/technical-line-annotations.ts
Normal file
297
furniture-3d-system/src/lib/3d/technical-line-annotations.ts
Normal file
@ -0,0 +1,297 @@
|
||||
import * as THREE from 'three'
|
||||
import { collectClosedTechnicalLineFeatureLoops } from './technical-line-feature-loops'
|
||||
|
||||
export interface TechnicalLineAnnotationObjectTransform {
|
||||
object: THREE.Object3D
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number, number]
|
||||
}
|
||||
|
||||
export interface TechnicalLineCaptureAnnotation {
|
||||
activeObjects: THREE.Object3D[]
|
||||
hardwareObjects: THREE.Object3D[]
|
||||
assembledTransforms: TechnicalLineAnnotationObjectTransform[]
|
||||
}
|
||||
|
||||
export interface TechnicalLineHardwarePath {
|
||||
object: THREE.Object3D
|
||||
start: THREE.Vector3
|
||||
end: THREE.Vector3
|
||||
}
|
||||
|
||||
function isDescendantOf(object: THREE.Object3D, ancestor: THREE.Object3D) {
|
||||
let current: THREE.Object3D | null = object
|
||||
while (current) {
|
||||
if (current === ancestor) return true
|
||||
current = current.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function collectTechnicalLineActiveMeshes(
|
||||
meshes: THREE.Mesh[],
|
||||
activeObjects: THREE.Object3D[],
|
||||
) {
|
||||
if (activeObjects.length === 0) return []
|
||||
return meshes.filter((mesh) => activeObjects.some((object) => isDescendantOf(mesh, object)))
|
||||
}
|
||||
|
||||
function getObjectLocalGeometryBounds(
|
||||
object: THREE.Object3D,
|
||||
meshes: THREE.Mesh[],
|
||||
) {
|
||||
const objectWorldInverse = object.matrixWorld.clone().invert()
|
||||
const bounds = new THREE.Box3()
|
||||
let hasGeometry = false
|
||||
|
||||
meshes.forEach((mesh) => {
|
||||
if (!isDescendantOf(mesh, object)) return
|
||||
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
|
||||
if (!mesh.geometry.boundingBox) return
|
||||
const meshToObject = new THREE.Matrix4().multiplyMatrices(
|
||||
objectWorldInverse,
|
||||
mesh.matrixWorld,
|
||||
)
|
||||
bounds.union(mesh.geometry.boundingBox.clone().applyMatrix4(meshToObject))
|
||||
hasGeometry = true
|
||||
})
|
||||
|
||||
return hasGeometry && !bounds.isEmpty() ? bounds : null
|
||||
}
|
||||
|
||||
function getWorldBounds(meshes: THREE.Mesh[]) {
|
||||
const bounds = new THREE.Box3()
|
||||
meshes.forEach((mesh) => {
|
||||
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
|
||||
if (mesh.geometry.boundingBox) {
|
||||
bounds.union(mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld))
|
||||
}
|
||||
})
|
||||
return bounds
|
||||
}
|
||||
|
||||
function getPrimaryAxisIndex(size: THREE.Vector3) {
|
||||
if (size.y >= size.x && size.y >= size.z) return 1
|
||||
return size.z >= size.x ? 2 : 0
|
||||
}
|
||||
|
||||
function createWorldAxisPairs(
|
||||
bounds: THREE.Box3,
|
||||
worldMatrix: THREE.Matrix4,
|
||||
) {
|
||||
const center = bounds.getCenter(new THREE.Vector3())
|
||||
return [0, 1, 2].flatMap((axisIndex) => {
|
||||
const minimum = center.clone().setComponent(axisIndex, bounds.min.getComponent(axisIndex))
|
||||
.applyMatrix4(worldMatrix)
|
||||
const maximum = center.clone().setComponent(axisIndex, bounds.max.getComponent(axisIndex))
|
||||
.applyMatrix4(worldMatrix)
|
||||
const axis = maximum.clone().sub(minimum)
|
||||
if (axis.lengthSq() <= 1e-12) return []
|
||||
return [{ axisIndex, minimum, maximum, axis: axis.normalize() }]
|
||||
})
|
||||
}
|
||||
|
||||
function createAxialEndpoints(
|
||||
bounds: THREE.Box3,
|
||||
worldMatrix: THREE.Matrix4,
|
||||
) {
|
||||
const axisIndex = getPrimaryAxisIndex(bounds.getSize(new THREE.Vector3()))
|
||||
const pair = createWorldAxisPairs(bounds, worldMatrix)
|
||||
.find((candidate) => candidate.axisIndex === axisIndex)
|
||||
if (!pair) return []
|
||||
return [
|
||||
{ start: pair.minimum, direction: pair.axis.clone().multiplyScalar(-1) },
|
||||
{ start: pair.maximum, direction: pair.axis },
|
||||
]
|
||||
}
|
||||
|
||||
function buildFinalWorldMatrix(
|
||||
object: THREE.Object3D,
|
||||
transformByObjectUuid: Map<string, TechnicalLineAnnotationObjectTransform>,
|
||||
cache: Map<string, THREE.Matrix4>,
|
||||
): THREE.Matrix4 {
|
||||
const cached = cache.get(object.uuid)
|
||||
if (cached) return cached
|
||||
|
||||
const transform = transformByObjectUuid.get(object.uuid)
|
||||
if (!transform) {
|
||||
const currentWorldMatrix = object.matrixWorld.clone()
|
||||
cache.set(object.uuid, currentWorldMatrix)
|
||||
return currentWorldMatrix
|
||||
}
|
||||
|
||||
const parentWorld = object.parent
|
||||
? object.parent.matrixWorld.clone()
|
||||
: new THREE.Matrix4()
|
||||
const localMatrix = transform
|
||||
? new THREE.Matrix4().compose(
|
||||
new THREE.Vector3(...transform.position),
|
||||
new THREE.Quaternion(...transform.rotation).normalize(),
|
||||
object.scale,
|
||||
)
|
||||
: object.matrix.clone()
|
||||
const worldMatrix = parentWorld.clone().multiply(localMatrix)
|
||||
cache.set(object.uuid, worldMatrix)
|
||||
return worldMatrix
|
||||
}
|
||||
|
||||
function isWorldPointVisible(
|
||||
point: THREE.Vector3,
|
||||
camera: THREE.Camera,
|
||||
meshes: THREE.Mesh[],
|
||||
excludedObjects: THREE.Object3D[],
|
||||
tolerance: number,
|
||||
) {
|
||||
const projected = point.clone().project(camera)
|
||||
if (
|
||||
projected.x < -1 || projected.x > 1
|
||||
|| projected.y < -1 || projected.y > 1
|
||||
|| projected.z < -1 || projected.z > 1
|
||||
) return false
|
||||
|
||||
const visibleMeshes = meshes.filter((mesh) => (
|
||||
excludedObjects.every((object) => !isDescendantOf(mesh, object))
|
||||
))
|
||||
const raycaster = new THREE.Raycaster()
|
||||
raycaster.setFromCamera(new THREE.Vector2(projected.x, projected.y), camera)
|
||||
const targetDistance = point.clone().sub(raycaster.ray.origin).dot(raycaster.ray.direction)
|
||||
if (targetDistance <= tolerance) return false
|
||||
raycaster.near = 0
|
||||
raycaster.far = targetDistance + tolerance
|
||||
const firstHit = raycaster.intersectObjects(visibleMeshes, false)[0]
|
||||
return !firstHit || firstHit.distance >= targetDistance - tolerance
|
||||
}
|
||||
|
||||
export function resolveTechnicalLineHardwarePaths(
|
||||
annotation: TechnicalLineCaptureAnnotation,
|
||||
meshes: THREE.Mesh[],
|
||||
camera?: THREE.Camera,
|
||||
) {
|
||||
const transformByObjectUuid = new Map(
|
||||
annotation.assembledTransforms.map((transform) => [transform.object.uuid, transform] as const),
|
||||
)
|
||||
const finalWorldMatrixCache = new Map<string, THREE.Matrix4>()
|
||||
const uniqueHardwareObjects = Array.from(
|
||||
new Map(annotation.hardwareObjects.map((object) => [object.uuid, object] as const)).values(),
|
||||
)
|
||||
const hardwareObjectUuids = new Set(uniqueHardwareObjects.map((object) => object.uuid))
|
||||
const receivingObjects = annotation.assembledTransforms
|
||||
.map((transform) => transform.object)
|
||||
.filter((object) => !hardwareObjectUuids.has(object.uuid))
|
||||
const receivingMeshes = meshes.filter((mesh) => (
|
||||
uniqueHardwareObjects.every((object) => !isDescendantOf(mesh, object))
|
||||
&& receivingObjects.some((object) => isDescendantOf(mesh, object))
|
||||
))
|
||||
const referenceWorldMatrixByReceivingObjectUuid = new Map(
|
||||
receivingObjects.map((object) => [
|
||||
object.uuid,
|
||||
buildFinalWorldMatrix(object, transformByObjectUuid, finalWorldMatrixCache),
|
||||
] as const),
|
||||
)
|
||||
const referenceWorldMatrixByMeshUuid = new Map(
|
||||
receivingMeshes.flatMap((mesh) => {
|
||||
const receivingObject = receivingObjects.find((object) => isDescendantOf(mesh, object))
|
||||
const referenceReceivingWorld = receivingObject
|
||||
? referenceWorldMatrixByReceivingObjectUuid.get(receivingObject.uuid)
|
||||
: null
|
||||
if (!receivingObject || !referenceReceivingWorld) return []
|
||||
const meshToReceiving = receivingObject.matrixWorld.clone().invert().multiply(mesh.matrixWorld)
|
||||
return [[mesh.uuid, referenceReceivingWorld.clone().multiply(meshToReceiving)] as const]
|
||||
}),
|
||||
)
|
||||
const worldBounds = getWorldBounds(meshes)
|
||||
const sceneDiagonal = worldBounds.isEmpty()
|
||||
? 1
|
||||
: Math.max(worldBounds.getSize(new THREE.Vector3()).length(), 1e-4)
|
||||
const rayOffset = sceneDiagonal * 1e-6
|
||||
const minimumPathLength = sceneDiagonal * 1e-4
|
||||
const raycaster = new THREE.Raycaster()
|
||||
raycaster.near = rayOffset
|
||||
raycaster.far = sceneDiagonal * 2
|
||||
|
||||
return uniqueHardwareObjects.flatMap((object): TechnicalLineHardwarePath[] => {
|
||||
if (!transformByObjectUuid.has(object.uuid)) return []
|
||||
const localBounds = getObjectLocalGeometryBounds(object, meshes)
|
||||
if (!localBounds) return []
|
||||
if (camera) {
|
||||
const currentCenter = localBounds.getCenter(new THREE.Vector3())
|
||||
.applyMatrix4(object.matrixWorld)
|
||||
const finalCenter = localBounds.getCenter(new THREE.Vector3()).applyMatrix4(
|
||||
buildFinalWorldMatrix(object, transformByObjectUuid, finalWorldMatrixCache),
|
||||
)
|
||||
const hardwareDiameter = localBounds.getSize(new THREE.Vector3()).length()
|
||||
* object.matrixWorld.getMaxScaleOnAxis()
|
||||
const featureSearchDistance = Math.max(hardwareDiameter * 2.5, minimumPathLength * 10)
|
||||
const currentAxisPairs = createWorldAxisPairs(localBounds, object.matrixWorld)
|
||||
const finalAxisPairs = createWorldAxisPairs(
|
||||
localBounds,
|
||||
buildFinalWorldMatrix(object, transformByObjectUuid, finalWorldMatrixCache),
|
||||
)
|
||||
const nearbyReceivingMeshes = receivingMeshes.filter((mesh) => {
|
||||
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
|
||||
const referenceWorldMatrix = referenceWorldMatrixByMeshUuid.get(mesh.uuid)
|
||||
if (!mesh.geometry.boundingBox || !referenceWorldMatrix) return false
|
||||
return mesh.geometry.boundingBox.clone().applyMatrix4(referenceWorldMatrix)
|
||||
.distanceToPoint(finalCenter) <= featureSearchDistance
|
||||
})
|
||||
const featureLoops = collectClosedTechnicalLineFeatureLoops(
|
||||
nearbyReceivingMeshes,
|
||||
referenceWorldMatrixByMeshUuid,
|
||||
)
|
||||
const candidateLoops = featureLoops
|
||||
.filter((loop) => loop.diameter <= featureSearchDistance)
|
||||
.map((loop) => ({ loop, distance: loop.referenceCenter.distanceTo(finalCenter) }))
|
||||
.filter(({ distance }) => distance <= featureSearchDistance)
|
||||
.sort((left, right) => left.distance - right.distance)
|
||||
|
||||
if (candidateLoops.length > 0) {
|
||||
const isAlignedWithAxis = (
|
||||
point: THREE.Vector3,
|
||||
pairs: ReturnType<typeof createWorldAxisPairs>,
|
||||
tolerance: number,
|
||||
) => pairs.some((pair) => {
|
||||
const offset = point.clone().sub(pair.minimum)
|
||||
return offset.addScaledVector(pair.axis, -offset.dot(pair.axis)).length() <= tolerance
|
||||
})
|
||||
const targetLoop = candidateLoops.find(({ loop }) => {
|
||||
const alignmentTolerance = Math.max(loop.diameter * 0.5, minimumPathLength * 10)
|
||||
return currentCenter.distanceTo(loop.center) >= minimumPathLength
|
||||
&& isAlignedWithAxis(loop.referenceCenter, finalAxisPairs, alignmentTolerance)
|
||||
&& isAlignedWithAxis(loop.center, currentAxisPairs, alignmentTolerance)
|
||||
&& isWorldPointVisible(
|
||||
loop.center,
|
||||
camera,
|
||||
meshes,
|
||||
uniqueHardwareObjects,
|
||||
rayOffset,
|
||||
)
|
||||
})?.loop
|
||||
if (!targetLoop) return []
|
||||
const start = currentAxisPairs
|
||||
.flatMap((pair) => [pair.minimum, pair.maximum])
|
||||
.sort((left, right) => (
|
||||
left.distanceToSquared(targetLoop.center) - right.distanceToSquared(targetLoop.center)
|
||||
))[0]
|
||||
if (start && start.distanceTo(targetLoop.center) >= minimumPathLength) {
|
||||
return [{ object, start, end: targetLoop.center }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
const candidates = createAxialEndpoints(localBounds, object.matrixWorld).flatMap(
|
||||
({ start, direction }) => {
|
||||
raycaster.set(start.clone().addScaledVector(direction, rayOffset), direction)
|
||||
const hit = raycaster.intersectObjects(receivingMeshes, false)[0]
|
||||
if (!hit || hit.distance < minimumPathLength) return []
|
||||
return [{
|
||||
start,
|
||||
end: hit.point.clone(),
|
||||
pathLength: hit.distance,
|
||||
}]
|
||||
},
|
||||
)
|
||||
candidates.sort((left, right) => left.pathLength - right.pathLength)
|
||||
const candidate = candidates[0]
|
||||
return candidate ? [{ object, start: candidate.start, end: candidate.end }] : []
|
||||
})
|
||||
}
|
||||
@ -46,15 +46,73 @@ describe('withTechnicalLineCaptureScene', () => {
|
||||
|
||||
test('restores the scene when capture throws', () => {
|
||||
const { scene, root, mesh, originalMaterial } = createSceneMesh()
|
||||
const receivingMesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 4, 4),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
root.add(receivingMesh)
|
||||
mesh.position.set(2, 0, 0)
|
||||
scene.updateMatrixWorld(true)
|
||||
expect(() => withTechnicalLineCaptureScene([root], () => {
|
||||
throw new Error('capture failed')
|
||||
}, {
|
||||
sourceCamera: createSourceCamera(),
|
||||
...viewport,
|
||||
annotation: {
|
||||
activeObjects: [mesh],
|
||||
hardwareObjects: [mesh],
|
||||
assembledTransforms: [
|
||||
{ object: mesh, position: [0, 3, 0], rotation: [0, 0, 0, 1] },
|
||||
{ object: receivingMesh, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
],
|
||||
},
|
||||
})).toThrow('capture failed')
|
||||
|
||||
expect(mesh.material).toBe(originalMaterial)
|
||||
expect(scene.children.some((child) => child.name === '__technical_line_capture_edge__')).toBe(false)
|
||||
expect(scene.getObjectByName('__technical_line_capture_active_edge__')).toBeUndefined()
|
||||
expect(scene.getObjectByName('__technical_line_capture_hardware_path__')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('adds red current-part edges and one blue dashed hardware path temporarily', () => {
|
||||
const { scene, root, mesh, originalMaterial } = createSceneMesh()
|
||||
const receivingMesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 4, 4),
|
||||
new THREE.MeshBasicMaterial(),
|
||||
)
|
||||
root.add(receivingMesh)
|
||||
mesh.position.set(3, 0, 0)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
withTechnicalLineCaptureScene([root], () => {
|
||||
const activeEdges = scene.getObjectByName('__technical_line_capture_active_edge__')
|
||||
const hardwarePaths = scene.getObjectByName('__technical_line_capture_hardware_path__')
|
||||
|
||||
expect(activeEdges).toBeInstanceOf(THREE.LineSegments)
|
||||
expect((activeEdges as THREE.LineSegments).material).toMatchObject({ depthTest: true })
|
||||
expect(((activeEdges as THREE.LineSegments).material as THREE.LineBasicMaterial).color.getHex())
|
||||
.toBe(0xd3262e)
|
||||
expect(hardwarePaths).toBeInstanceOf(THREE.LineSegments)
|
||||
expect((hardwarePaths as THREE.LineSegments).geometry.getAttribute('position').count).toBe(2)
|
||||
expect((hardwarePaths as THREE.LineSegments).geometry.getAttribute('lineDistance').count).toBe(2)
|
||||
expect(((hardwarePaths as THREE.LineSegments).material as THREE.LineDashedMaterial).color.getHex())
|
||||
.toBe(0x1d63d8)
|
||||
}, {
|
||||
sourceCamera: createSourceCamera(),
|
||||
...viewport,
|
||||
annotation: {
|
||||
activeObjects: [mesh],
|
||||
hardwareObjects: [mesh],
|
||||
assembledTransforms: [
|
||||
{ object: mesh, position: [0, 3, 0], rotation: [0, 0, 0, 1] },
|
||||
{ object: receivingMesh, position: [0, 0, 0], rotation: [0, 0, 0, 1] },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(mesh.material).toBe(originalMaterial)
|
||||
expect(scene.getObjectByName('__technical_line_capture_active_edge__')).toBeUndefined()
|
||||
expect(scene.getObjectByName('__technical_line_capture_hardware_path__')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('rejects scenes without usable visible geometry', () => {
|
||||
|
||||
@ -2,8 +2,15 @@ import * as THREE from 'three'
|
||||
import { isHighlightHelperObjectName } from './engine-highlight-resources'
|
||||
import { createTechnicalLineOrthographicCamera } from './technical-line-camera'
|
||||
import { buildMergedTechnicalLineGeometry } from './technical-line-geometry'
|
||||
import {
|
||||
collectTechnicalLineActiveMeshes,
|
||||
resolveTechnicalLineHardwarePaths,
|
||||
type TechnicalLineCaptureAnnotation,
|
||||
} from './technical-line-annotations'
|
||||
|
||||
const TECHNICAL_LINE_EDGE_NAME = '__technical_line_capture_edge__'
|
||||
const TECHNICAL_LINE_ACTIVE_EDGE_NAME = '__technical_line_capture_active_edge__'
|
||||
const TECHNICAL_LINE_HARDWARE_PATH_NAME = '__technical_line_capture_hardware_path__'
|
||||
|
||||
export type TechnicalLineCaptureStyle = 'clean' | 'detail'
|
||||
|
||||
@ -12,6 +19,7 @@ export interface TechnicalLineCaptureSceneOptions {
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
style?: TechnicalLineCaptureStyle
|
||||
annotation?: TechnicalLineCaptureAnnotation
|
||||
}
|
||||
|
||||
interface MaterialSnapshot {
|
||||
@ -63,6 +71,24 @@ function createOcclusionMaterial() {
|
||||
})
|
||||
}
|
||||
|
||||
function createHardwarePathGeometry(
|
||||
paths: ReturnType<typeof resolveTechnicalLineHardwarePaths>,
|
||||
) {
|
||||
const positions = paths.flatMap(({ start, end }) => [
|
||||
start.x, start.y, start.z,
|
||||
end.x, end.y, end.z,
|
||||
])
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||
return geometry
|
||||
}
|
||||
|
||||
function getSceneDiagonal(meshes: THREE.Mesh[]) {
|
||||
const bounds = new THREE.Box3()
|
||||
meshes.forEach((mesh) => bounds.expandByObject(mesh))
|
||||
return bounds.isEmpty() ? 1 : Math.max(bounds.getSize(new THREE.Vector3()).length(), 1e-4)
|
||||
}
|
||||
|
||||
export function withTechnicalLineCaptureScene<T>(
|
||||
roots: THREE.Object3D[],
|
||||
capture: (camera: THREE.Camera) => T,
|
||||
@ -110,6 +136,65 @@ export function withTechnicalLineCaptureScene<T>(
|
||||
lines.frustumCulled = false
|
||||
lines.matrixAutoUpdate = false
|
||||
lines.matrix.copy(lineParent.matrixWorld).invert()
|
||||
const annotationObjects: THREE.Object3D[] = []
|
||||
const annotationResources: Array<THREE.BufferGeometry | THREE.Material> = []
|
||||
|
||||
if (options.annotation) {
|
||||
const activeMeshes = collectTechnicalLineActiveMeshes(
|
||||
meshes,
|
||||
options.annotation.activeObjects,
|
||||
)
|
||||
if (activeMeshes.length > 0) {
|
||||
const activeGeometry = buildMergedTechnicalLineGeometry({
|
||||
meshes: activeMeshes,
|
||||
camera,
|
||||
viewportWidth: options.viewportWidth,
|
||||
viewportHeight: options.viewportHeight,
|
||||
style: options.style ?? 'detail',
|
||||
})
|
||||
if ((activeGeometry.getAttribute('position')?.count ?? 0) > 0) {
|
||||
const activeMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0xd3262e,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
})
|
||||
const activeLines = new THREE.LineSegments(activeGeometry, activeMaterial)
|
||||
activeLines.name = TECHNICAL_LINE_ACTIVE_EDGE_NAME
|
||||
activeLines.renderOrder = 3
|
||||
activeLines.frustumCulled = false
|
||||
activeLines.matrixAutoUpdate = false
|
||||
activeLines.matrix.copy(lineParent.matrixWorld).invert()
|
||||
annotationObjects.push(activeLines)
|
||||
annotationResources.push(activeGeometry, activeMaterial)
|
||||
} else {
|
||||
activeGeometry.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const hardwarePaths = resolveTechnicalLineHardwarePaths(options.annotation, meshes, camera)
|
||||
if (hardwarePaths.length > 0) {
|
||||
const pathGeometry = createHardwarePathGeometry(hardwarePaths)
|
||||
const sceneDiagonal = getSceneDiagonal(meshes)
|
||||
const pathMaterial = new THREE.LineDashedMaterial({
|
||||
color: 0x1d63d8,
|
||||
dashSize: sceneDiagonal * 0.012,
|
||||
gapSize: sceneDiagonal * 0.008,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
})
|
||||
const pathLines = new THREE.LineSegments(pathGeometry, pathMaterial)
|
||||
pathLines.name = TECHNICAL_LINE_HARDWARE_PATH_NAME
|
||||
pathLines.renderOrder = 4
|
||||
pathLines.frustumCulled = false
|
||||
pathLines.matrixAutoUpdate = false
|
||||
pathLines.matrix.copy(lineParent.matrixWorld).invert()
|
||||
pathLines.computeLineDistances()
|
||||
annotationObjects.push(pathLines)
|
||||
annotationResources.push(pathGeometry, pathMaterial)
|
||||
}
|
||||
}
|
||||
const materialSnapshots: MaterialSnapshot[] = []
|
||||
|
||||
try {
|
||||
@ -118,12 +203,15 @@ export function withTechnicalLineCaptureScene<T>(
|
||||
mesh.material = createOcclusionMaterial()
|
||||
})
|
||||
lineParent.add(lines)
|
||||
annotationObjects.forEach((object) => lineParent.add(object))
|
||||
lineParent.updateMatrixWorld(true)
|
||||
return capture(camera)
|
||||
} finally {
|
||||
lineParent.remove(lines)
|
||||
annotationObjects.forEach((object) => lineParent.remove(object))
|
||||
geometry.dispose()
|
||||
lineMaterial.dispose()
|
||||
annotationResources.forEach((resource) => resource.dispose())
|
||||
materialSnapshots.forEach(({ mesh, material }) => {
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material.forEach((item) => item.dispose())
|
||||
|
||||
102
furniture-3d-system/src/lib/3d/technical-line-feature-loops.ts
Normal file
102
furniture-3d-system/src/lib/3d/technical-line-feature-loops.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
export interface TechnicalLineFeatureLoop {
|
||||
center: THREE.Vector3
|
||||
referenceCenter: THREE.Vector3
|
||||
diameter: number
|
||||
}
|
||||
|
||||
export function collectClosedTechnicalLineFeatureLoops(
|
||||
meshes: THREE.Mesh[],
|
||||
referenceWorldMatrixByMeshUuid: Map<string, THREE.Matrix4>,
|
||||
) {
|
||||
return meshes.flatMap((mesh): TechnicalLineFeatureLoop[] => {
|
||||
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
|
||||
if (!mesh.geometry.boundingBox) return []
|
||||
const localSize = mesh.geometry.boundingBox.getSize(new THREE.Vector3())
|
||||
const weldTolerance = Math.max(localSize.length() * 1e-5, 1e-6)
|
||||
const edgeGeometry = new THREE.EdgesGeometry(mesh.geometry, 18)
|
||||
|
||||
try {
|
||||
const position = edgeGeometry.getAttribute('position')
|
||||
if (!position || position.count < 6) return []
|
||||
const nodes = new Map<string, { point: THREE.Vector3; neighbors: Set<string> }>()
|
||||
const edges = new Set<string>()
|
||||
const getKey = (point: THREE.Vector3) => [
|
||||
Math.round(point.x / weldTolerance),
|
||||
Math.round(point.y / weldTolerance),
|
||||
Math.round(point.z / weldTolerance),
|
||||
].join(':')
|
||||
|
||||
for (let index = 0; index + 1 < position.count; index += 2) {
|
||||
const start = new THREE.Vector3().fromBufferAttribute(position, index)
|
||||
const end = new THREE.Vector3().fromBufferAttribute(position, index + 1)
|
||||
const startKey = getKey(start)
|
||||
const endKey = getKey(end)
|
||||
if (startKey === endKey) continue
|
||||
const edgeKey = startKey < endKey
|
||||
? `${startKey}|${endKey}`
|
||||
: `${endKey}|${startKey}`
|
||||
if (edges.has(edgeKey)) continue
|
||||
edges.add(edgeKey)
|
||||
const startNode = nodes.get(startKey) ?? { point: start, neighbors: new Set<string>() }
|
||||
const endNode = nodes.get(endKey) ?? { point: end, neighbors: new Set<string>() }
|
||||
startNode.neighbors.add(endKey)
|
||||
endNode.neighbors.add(startKey)
|
||||
nodes.set(startKey, startNode)
|
||||
nodes.set(endKey, endNode)
|
||||
}
|
||||
|
||||
const visited = new Set<string>()
|
||||
const loops: TechnicalLineFeatureLoop[] = []
|
||||
nodes.forEach((_node, initialKey) => {
|
||||
if (visited.has(initialKey)) return
|
||||
const stack = [initialKey]
|
||||
const componentKeys: string[] = []
|
||||
visited.add(initialKey)
|
||||
while (stack.length > 0) {
|
||||
const key = stack.pop()
|
||||
if (!key) continue
|
||||
componentKeys.push(key)
|
||||
nodes.get(key)?.neighbors.forEach((neighborKey) => {
|
||||
if (visited.has(neighborKey)) return
|
||||
visited.add(neighborKey)
|
||||
stack.push(neighborKey)
|
||||
})
|
||||
}
|
||||
if (
|
||||
componentKeys.length < 3
|
||||
|| componentKeys.some((key) => nodes.get(key)?.neighbors.size !== 2)
|
||||
) return
|
||||
|
||||
const localBounds = new THREE.Box3()
|
||||
componentKeys.forEach((key) => {
|
||||
const point = nodes.get(key)?.point
|
||||
if (point) localBounds.expandByPoint(point)
|
||||
})
|
||||
const sortedSpans = localBounds.getSize(new THREE.Vector3()).toArray()
|
||||
.sort((left, right) => right - left)
|
||||
if ((sortedSpans[1] ?? 0) <= weldTolerance * 4) return
|
||||
|
||||
const localCenter = localBounds.getCenter(new THREE.Vector3())
|
||||
const center = localCenter.clone().applyMatrix4(mesh.matrixWorld)
|
||||
const referenceCenter = localCenter.clone().applyMatrix4(
|
||||
referenceWorldMatrixByMeshUuid.get(mesh.uuid) ?? mesh.matrixWorld,
|
||||
)
|
||||
const worldBounds = new THREE.Box3()
|
||||
componentKeys.forEach((key) => {
|
||||
const point = nodes.get(key)?.point
|
||||
if (point) worldBounds.expandByPoint(point.clone().applyMatrix4(mesh.matrixWorld))
|
||||
})
|
||||
loops.push({
|
||||
center,
|
||||
referenceCenter,
|
||||
diameter: worldBounds.getSize(new THREE.Vector3()).length(),
|
||||
})
|
||||
})
|
||||
return loops
|
||||
} finally {
|
||||
edgeGeometry.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
InstallationDrawingImageMode,
|
||||
} from "@/app/installation-demo-workbench/lib/installationDrawingCaptureTypes";
|
||||
import type { InstallationDrawingTemplateId } from "@/app/installation-demo-workbench/lib/installationDrawingTemplates";
|
||||
import type { InstallationDrawingTextAnnotationsByTarget } from "@/app/installation-demo-workbench/lib/installationDrawingTextAnnotations";
|
||||
import type {
|
||||
InstallationDetailView,
|
||||
InstallationPartItem,
|
||||
@ -35,6 +36,7 @@ export interface InstallationDemoShareDrawingDocument {
|
||||
templateId: InstallationDrawingTemplateId;
|
||||
imageMode: InstallationDrawingImageMode;
|
||||
captures: InstallationDrawingCaptures;
|
||||
textAnnotationsByTargetKey: InstallationDrawingTextAnnotationsByTarget;
|
||||
snapshotLineArtUrls: Record<string, string>;
|
||||
toolSnapshotLineArtUrls: Record<string, string>;
|
||||
}
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
jest.mock("server-only", () => ({}));
|
||||
|
||||
jest.mock("../system-config.service", () => ({
|
||||
systemConfigService: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("./installation-demo-access.service", () => ({
|
||||
installationDemoAccessService: {
|
||||
getAccess: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { systemConfigService } from "../system-config.service";
|
||||
import { installationDemoAccessService } from "./installation-demo-access.service";
|
||||
import { InstallationDemoDrawingTextAnnotationService } from "./installation-demo-drawing-text-annotation.service";
|
||||
|
||||
const user = {
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
name: "Test user",
|
||||
role: "USER" as const,
|
||||
businessRoles: [],
|
||||
status: "ACTIVE" as const,
|
||||
};
|
||||
|
||||
describe("InstallationDemoDrawingTextAnnotationService", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("returns an empty document before the first annotation is added", async () => {
|
||||
const service = new InstallationDemoDrawingTextAnnotationService();
|
||||
const snapshot = await service.get(user, "drawing-1");
|
||||
|
||||
expect(snapshot.annotationsByTargetKey).toEqual({});
|
||||
});
|
||||
|
||||
it("updates one action without removing annotations from other actions", async () => {
|
||||
mockedConfigService.get.mockResolvedValue({
|
||||
version: 1,
|
||||
drawingId: "drawing-1",
|
||||
annotationsByTargetKey: {
|
||||
"action:step-1:fixed": [
|
||||
{ id: "existing", text: "先固定底板", x: 0.3, y: 0.7, fontSize: 12 },
|
||||
],
|
||||
},
|
||||
savedAt: "2026-08-17T00:00:00.000Z",
|
||||
});
|
||||
const service = new InstallationDemoDrawingTextAnnotationService();
|
||||
const snapshot = await service.updateTarget(user, {
|
||||
drawingId: "drawing-1",
|
||||
targetKey: "action:step-2:action-1",
|
||||
annotations: [
|
||||
{ id: "text-2", text: "使用螺丝刀锁紧", x: 0.55, y: 0.25, fontSize: 16 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.annotationsByTargetKey).toEqual({
|
||||
"action:step-1:fixed": [
|
||||
{ id: "existing", text: "先固定底板", x: 0.3, y: 0.7, fontSize: 12 },
|
||||
],
|
||||
"action:step-2:action-1": [
|
||||
{ id: "text-2", text: "使用螺丝刀锁紧", x: 0.55, y: 0.25, fontSize: 16 },
|
||||
],
|
||||
});
|
||||
expect(mockedConfigService.set).toHaveBeenCalledWith(
|
||||
"installation-demo-workbench:drawing-text-annotations:drawing-1",
|
||||
expect.objectContaining({
|
||||
annotationsByTargetKey: snapshot.annotationsByTargetKey,
|
||||
}),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,143 @@
|
||||
import "server-only";
|
||||
|
||||
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 {
|
||||
MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET,
|
||||
sanitizeInstallationDrawingTextAnnotations,
|
||||
type InstallationDrawingTextAnnotation,
|
||||
type InstallationDrawingTextAnnotationsByTarget,
|
||||
} from "@/app/installation-demo-workbench/lib/installationDrawingTextAnnotations";
|
||||
|
||||
const CONFIG_PREFIX = "installation-demo-workbench:drawing-text-annotations:";
|
||||
const VERSION = 1;
|
||||
const MAX_TARGET_COUNT = 400;
|
||||
|
||||
export interface InstallationDemoDrawingTextAnnotationsDTO {
|
||||
version: number;
|
||||
drawingId: string;
|
||||
annotationsByTargetKey: InstallationDrawingTextAnnotationsByTarget;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function buildConfigKey(drawingId: string) {
|
||||
return `${CONFIG_PREFIX}${drawingId}`;
|
||||
}
|
||||
|
||||
function normalizeDrawingId(value: unknown) {
|
||||
const drawingId = typeof value === "string" ? value.trim() : "";
|
||||
if (!drawingId) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "缺少图纸 ID");
|
||||
}
|
||||
if (drawingId.length > 240) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "图纸 ID 过长");
|
||||
}
|
||||
return drawingId;
|
||||
}
|
||||
|
||||
function normalizeTargetKey(value: unknown) {
|
||||
const targetKey = typeof value === "string" ? value.trim() : "";
|
||||
if (!targetKey) {
|
||||
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, "缺少文字标注目标");
|
||||
}
|
||||
if (targetKey.length > 300) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "文字标注目标过长");
|
||||
}
|
||||
return targetKey;
|
||||
}
|
||||
|
||||
function normalizeMap(value: unknown) {
|
||||
const map = sanitizeInstallationDrawingTextAnnotations(value);
|
||||
if (Object.keys(map).length > MAX_TARGET_COUNT) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "文字标注目标数量超出上限");
|
||||
}
|
||||
for (const [targetKey, annotations] of Object.entries(map)) {
|
||||
if (targetKey.length > 300 || annotations.length > MAX_INSTALLATION_DRAWING_TEXT_ANNOTATIONS_PER_TARGET) {
|
||||
throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, "文字标注数量或目标无效");
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function normalizeStored(value: unknown, drawingId: string): InstallationDemoDrawingTextAnnotationsDTO | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.version !== VERSION) return null;
|
||||
const storedAnnotations = sanitizeInstallationDrawingTextAnnotations(
|
||||
record.annotationsByTargetKey,
|
||||
);
|
||||
return {
|
||||
version: VERSION,
|
||||
drawingId,
|
||||
annotationsByTargetKey: Object.fromEntries(
|
||||
Object.entries(storedAnnotations).slice(0, MAX_TARGET_COUNT),
|
||||
),
|
||||
savedAt: typeof record.savedAt === "string" ? record.savedAt : new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
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 InstallationDemoDrawingTextAnnotationService {
|
||||
private async read(drawingId: string) {
|
||||
return normalizeStored(
|
||||
await systemConfigService.get<unknown>(buildConfigKey(drawingId)),
|
||||
drawingId,
|
||||
);
|
||||
}
|
||||
|
||||
async get(user: AuthUser, rawDrawingId: unknown) {
|
||||
await assertCanView(user);
|
||||
const drawingId = normalizeDrawingId(rawDrawingId);
|
||||
return (await this.read(drawingId)) ?? {
|
||||
version: VERSION,
|
||||
drawingId,
|
||||
annotationsByTargetKey: {},
|
||||
savedAt: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async updateTarget(
|
||||
user: AuthUser,
|
||||
input: { drawingId: unknown; targetKey: unknown; annotations: unknown },
|
||||
) {
|
||||
await assertCanEdit(user);
|
||||
const drawingId = normalizeDrawingId(input.drawingId);
|
||||
const targetKey = normalizeTargetKey(input.targetKey);
|
||||
const targetMap = normalizeMap({ [targetKey]: input.annotations });
|
||||
const current = await this.read(drawingId);
|
||||
const annotationsByTargetKey = { ...(current?.annotationsByTargetKey ?? {}) };
|
||||
const nextAnnotations = targetMap[targetKey] ?? [];
|
||||
if (nextAnnotations.length === 0) delete annotationsByTargetKey[targetKey];
|
||||
else annotationsByTargetKey[targetKey] = nextAnnotations;
|
||||
const next: InstallationDemoDrawingTextAnnotationsDTO = {
|
||||
version: VERSION,
|
||||
drawingId,
|
||||
annotationsByTargetKey: normalizeMap(annotationsByTargetKey),
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
await systemConfigService.set(
|
||||
buildConfigKey(drawingId),
|
||||
next,
|
||||
`Installation drawing text annotations for ${drawingId}`,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export const installationDemoDrawingTextAnnotationService =
|
||||
new InstallationDemoDrawingTextAnnotationService();
|
||||
|
||||
export type { InstallationDrawingTextAnnotation };
|
||||
@ -65,6 +65,17 @@ function createInput() {
|
||||
actions: [],
|
||||
inventoryTransforms: {},
|
||||
},
|
||||
textAnnotationsByTargetKey: {
|
||||
"action:step-1:fixed": [
|
||||
{
|
||||
id: "text-1",
|
||||
text: "锁紧四颗螺丝",
|
||||
x: 0.42,
|
||||
y: 0.76,
|
||||
fontSize: 14,
|
||||
},
|
||||
],
|
||||
},
|
||||
snapshotLineArtUrls: {
|
||||
part: "/api/storage/object?key=snapshots%2Fpart-line.png",
|
||||
},
|
||||
@ -118,6 +129,11 @@ describe("InstallationDemoShareService", () => {
|
||||
expect(snapshot.drawingDocument).toMatchObject({
|
||||
templateId: "set-1",
|
||||
imageMode: "original",
|
||||
textAnnotationsByTargetKey: {
|
||||
"action:step-1:fixed": [
|
||||
expect.objectContaining({ text: "锁紧四颗螺丝", fontSize: 14 }),
|
||||
],
|
||||
},
|
||||
snapshotLineArtUrls: {
|
||||
part: "/api/storage/object?key=snapshots%2Fpart-line.png",
|
||||
},
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
InstallationDemoShareSnapshot,
|
||||
} from "@/lib/installation-demo-share-types";
|
||||
import type { InstallationStepMaterialShareSnapshot } from "@/app/installation-demo-workbench/lib/installationStepMaterialShareSnapshots";
|
||||
import { sanitizeInstallationDrawingTextAnnotations } from "@/app/installation-demo-workbench/lib/installationDrawingTextAnnotations";
|
||||
import { systemConfigService } from "../system-config.service";
|
||||
import { installationDemoAccessService } from "./installation-demo-access.service";
|
||||
|
||||
@ -205,6 +206,10 @@ function normalizeDrawingDocument(
|
||||
imageMode: normalizeDrawingImageMode(value.imageMode),
|
||||
captures:
|
||||
value.captures as unknown as InstallationDemoShareDrawingDocument["captures"],
|
||||
textAnnotationsByTargetKey:
|
||||
sanitizeInstallationDrawingTextAnnotations(
|
||||
value.textAnnotationsByTargetKey,
|
||||
),
|
||||
snapshotLineArtUrls: normalizeStringRecord(
|
||||
value.snapshotLineArtUrls,
|
||||
"部件线稿快照",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user