feat(installation-demo): refine printable drawing workflow

This commit is contained in:
WuYongJie 2026-08-17 14:55:22 +08:00
parent 330cf30ac9
commit 5cfbcaa5fe
31 changed files with 2289 additions and 71 deletions

View File

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

View File

@ -65,6 +65,9 @@ function InstallationDemoShareDrawingPanel({
imageMode={imageMode}
snapshotUrls={snapshot.numberingSnapshotUrls}
snapshotLineArtUrls={snapshotLineArtUrls}
textAnnotationsByTargetKey={
snapshot.drawingDocument?.textAnnotationsByTargetKey ?? {}
}
allowManualCapture={false}
readOnly
/>

View File

@ -41,6 +41,7 @@ function createSnapshot(): InstallationDemoShareSnapshot {
actions: [],
inventoryTransforms: {},
},
textAnnotationsByTargetKey: {},
snapshotLineArtUrls: {},
toolSnapshotLineArtUrls: {},
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("自动抓取草稿批次创建失败");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 }] : []
})
}

View File

@ -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', () => {

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
"部件线稿快照",