ai-chatbot/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template

1046 lines
35 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { BoundingBox, ModelingCmd } from '@kittycad/lib'
import { getBodiesFromArtifactGraph } from '@src/lang/std/artifactGraph'
import { humanDisplayNumber, type VariableMap } from '@src/lang/wasm'
import { useSingletons } from '@src/lib/boot'
import { isModelingResponse } from '@src/lib/kcSdkGuards'
import { engineStreamZoomToFit, engineViewIsometric } from '@src/lib/utils'
import { uuidv4 } from '@src/lib/utils'
import { useEffect, useMemo, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import {
buildExplodeOffsets,
createSnapshotDataUrl,
createBomCsv,
createEngineeringSheetHtml,
detectPanelHoles,
filterManufacturingBodies,
getOverallBounds,
groupBodiesIntoBom,
parseManufacturingLengthInMillimeters,
reconcileManufacturingDimensions,
type BodyMeasurement,
type BomRow,
type CircularEdgeSample,
type ExplodeOffset,
type ManufacturingLengthParameter,
} from './manufacturing'
type ModelingResponse = {
type: string
data?: unknown
}
type BomOverrides = Record<
string,
{
name?: string
material?: string
process?: string
}
>
type BrandSettings = {
name: string
logoUrl: string
}
function collectManufacturingLengthParameters(
variables: VariableMap,
wasmInstance: Parameters<typeof humanDisplayNumber>[2]
): ManufacturingLengthParameter[] {
return Object.entries(variables).flatMap(([name, value]) => {
if (!value || value.type !== 'Number') return []
const displayValue = humanDisplayNumber(value.value, value.ty, wasmInstance)
if (displayValue instanceof Error) return []
const valueMm = parseManufacturingLengthInMillimeters(displayValue)
return valueMm === undefined ? [] : [{ name, valueMm }]
})
}
async function settleInBatches<T, Result>(
values: T[],
batchSize: number,
worker: (value: T, index: number) => Promise<Result>
) {
const results: Array<PromiseSettledResult<Result>> = []
for (let offset = 0; offset < values.length; offset += batchSize) {
const batch = values.slice(offset, offset + batchSize)
results.push(
...(await Promise.allSettled(
batch.map((value, index) => worker(value, offset + index))
))
)
}
return results
}
function isTransientEngineFailure(message: string) {
return /(?:not ready|not initialized|initializ|temporar|timeout|timed out|busy|pending|connection|disconnected|stream|try again|retry|尚未就绪|初始化|连接|繁忙|稍后重试)/i.test(
message
)
}
function downloadText(filename: string, text: string, type: string) {
const blob = new Blob([text], { type })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
function collectArtifactIds(
value: unknown,
ids: Set<string>,
seen = new WeakSet<object>(),
depth = 0
) {
if (depth > 8 || value === null || typeof value !== 'object') return
if (seen.has(value)) return
seen.add(value)
if ('artifactId' in value && typeof value.artifactId === 'string') {
ids.add(value.artifactId)
}
if ('artifact_id' in value && typeof value.artifact_id === 'string') {
ids.add(value.artifact_id)
}
if ('id' in value && typeof value.id === 'string') {
ids.add(value.id)
}
for (const nested of Object.values(value)) {
collectArtifactIds(nested, ids, seen, depth + 1)
}
}
function resolveBodyName(
bodyId: string,
variables: Record<string, unknown>,
fallbackIndex: number
) {
for (const [name, value] of Object.entries(variables)) {
const ids = new Set<string>()
collectArtifactIds(value, ids)
if (ids.has(bodyId)) return name
}
return `零件 ${String(fallbackIndex + 1).padStart(2, '0')}`
}
function modelingResponseData(response: unknown): ModelingResponse {
const normalized = Array.isArray(response) ? response[0] : response
if (!isModelingResponse(normalized)) {
throw new Error(engineFailureMessage(response))
}
return normalized.resp.data.modeling_response as ModelingResponse
}
function engineFailureMessage(response: unknown) {
const responses = Array.isArray(response) ? response : [response]
const messages = responses.flatMap((item) => {
if (item instanceof Error) return [item.message]
if (typeof item !== 'object' || item === null || !('errors' in item)) {
return []
}
const errors = (item as { errors?: unknown }).errors
if (!Array.isArray(errors)) return []
return errors.flatMap((error) => {
if (typeof error === 'string') return [error]
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
) {
return [error.message]
}
return []
})
})
return messages.find((message) => message.trim()) ?? 'HiCAD 引擎未返回有效结果'
}
function numberValue(data: unknown, key: string) {
if (typeof data !== 'object' || data === null) return undefined
const value = (data as Record<string, unknown>)[key]
return typeof value === 'number' ? value : undefined
}
function formatNumber(value: number | undefined) {
if (value === undefined) return '-'
return Math.round(value * 100) / 100
}
export function HicadManufacturingPanel({
hideTrigger = false,
initialOpen = false,
onRequestClose,
projectName,
}: {
hideTrigger?: boolean
initialOpen?: boolean
onRequestClose?: () => void
projectName: string
}) {
const { kclManager } = useSingletons()
const [open, setOpen] = useState(initialOpen)
const [busy, setBusy] = useState(false)
const [bodies, setBodies] = useState<BodyMeasurement[]>([])
const [snapshot, setSnapshot] = useState<string>()
const [exploded, setExploded] = useState(false)
const [overrides, setOverrides] = useState<BomOverrides>({})
const [brand, setBrand] = useState<BrandSettings>({
name: '由壹点AI',
logoUrl: '/api/favicon?surface=hicad',
})
const [notes, setNotes] = useState(
'材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。'
)
const explodeOffsetsRef = useRef<ExplodeOffset[]>([])
const resolvedProjectName = projectName || 'HiCAD 模型'
const loadBrandSettings = async () => {
try {
const response = await fetch('/api/site-settings?scope=chat', {
credentials: 'same-origin',
})
if (!response.ok) return
const settings = (await response.json()) as Record<string, unknown>
const name =
typeof settings.siteName === 'string' && settings.siteName.trim()
? settings.siteName.trim()
: '由壹点AI'
const configuredLogo =
typeof settings.siteLogo === 'string' && settings.siteLogo.trim()
? settings.siteLogo.trim()
: typeof settings.siteLogoDark === 'string' &&
settings.siteLogoDark.trim()
? settings.siteLogoDark.trim()
: '/api/favicon?surface=hicad'
setBrand({ name, logoUrl: configuredLogo })
} catch {
// Keep the built-in website branding when settings are unavailable.
}
}
const sendModelingCommand = async (cmd: ModelingCmd) => {
const maxAttempts = 15
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const response = await kclManager.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd,
})
const normalized = Array.isArray(response) ? response[0] : response
if (isModelingResponse(normalized)) {
return modelingResponseData(normalized)
}
if (response !== null && response !== undefined) {
const message = engineFailureMessage(response)
if (attempt < maxAttempts - 1 && isTransientEngineFailure(message)) {
await new Promise((resolve) =>
setTimeout(resolve, Math.min(150 * (attempt + 1), 900))
)
continue
}
console.error('[HiCAD manufacturing] Engine command failed', {
command: cmd.type,
message,
response,
})
throw new Error(message)
}
await new Promise((resolve) => setTimeout(resolve, 200))
}
throw new Error('HiCAD 引擎连接尚未就绪,请稍后重新分析')
}
const getSceneSolidIds = async () => {
const response = await sendModelingCommand({
type: 'scene_get_entity_ids',
filter: ['solid3d'],
skip: 0,
take: 1000,
})
if (
response.type !== 'scene_get_entity_ids' ||
typeof response.data !== 'object' ||
response.data === null ||
!('entity_ids' in response.data) ||
!Array.isArray(response.data.entity_ids)
) {
return []
}
return [
...new Set(
response.data.entity_ids.flatMap((ids) =>
Array.isArray(ids)
? ids.filter((id): id is string => typeof id === 'string')
: []
)
),
]
}
const readEntityChildIds = async (entityId: string) => {
const response = await sendModelingCommand({
type: 'entity_get_all_child_uuids',
entity_id: entityId,
})
if (
response.type !== 'entity_get_all_child_uuids' ||
typeof response.data !== 'object' ||
response.data === null ||
!('entity_ids' in response.data) ||
!Array.isArray(response.data.entity_ids)
) {
return []
}
return response.data.entity_ids.filter(
(id): id is string => typeof id === 'string'
)
}
const readCircularSamples = async (entityIds: string[]) => {
const typedEntities = await settleInBatches(
[...new Set(entityIds)],
6,
async (entityId) => {
const response = await sendModelingCommand({
type: 'get_entity_type',
entity_id: entityId,
})
const entityType =
response.type === 'get_entity_type' &&
typeof response.data === 'object' &&
response.data !== null &&
'entity_type' in response.data &&
typeof response.data.entity_type === 'string'
? response.data.entity_type
: undefined
return { entityId, entityType }
}
)
const curveCandidates = typedEntities.flatMap((result) =>
result.status === 'fulfilled' &&
(result.value.entityType === 'edge' ||
result.value.entityType === 'curve')
? [result.value]
: []
)
const edgeResults = await settleInBatches(
curveCandidates,
4,
async ({ entityId, entityType }) => {
const typeResponse = await sendModelingCommand({
type: 'curve_get_type',
curve_id: entityId,
})
const curveType =
typeResponse.type === 'curve_get_type' &&
typeof typeResponse.data === 'object' &&
typeResponse.data !== null &&
'curve_type' in typeResponse.data
? typeResponse.data.curve_type
: undefined
if (curveType !== 'arc' && curveType !== 'nurbs') return undefined
const [pointsResult, lengthResult] = await Promise.allSettled([
sendModelingCommand({
type: 'curve_get_control_points',
curve_id: entityId,
}),
entityType === 'edge'
? sendModelingCommand({
type: 'edge_get_length',
edge_id: entityId,
})
: Promise.resolve(undefined),
])
if (
pointsResult.status !== 'fulfilled' ||
pointsResult.value.type !== 'curve_get_control_points' ||
typeof pointsResult.value.data !== 'object' ||
pointsResult.value.data === null ||
!('control_points' in pointsResult.value.data) ||
!Array.isArray(pointsResult.value.data.control_points)
) {
return undefined
}
const controlPoints =
pointsResult.value.data.control_points.flatMap((point) =>
typeof point === 'object' &&
point !== null &&
'x' in point &&
'y' in point &&
'z' in point &&
typeof point.x === 'number' &&
typeof point.y === 'number' &&
typeof point.z === 'number'
? [{ x: point.x, y: point.y, z: point.z }]
: []
)
let length: number | undefined
if (
lengthResult.status === 'fulfilled' &&
lengthResult.value &&
lengthResult.value.type === 'edge_get_length' &&
typeof lengthResult.value.data === 'object' &&
lengthResult.value.data !== null &&
'length' in lengthResult.value.data &&
typeof lengthResult.value.data.length === 'number'
) {
length = lengthResult.value.data.length
}
return { controlPoints, length } satisfies CircularEdgeSample
}
)
return edgeResults.flatMap((result) =>
result.status === 'fulfilled' && result.value ? [result.value] : []
)
}
const readCircularEdges = async (bodyId: string) => {
try {
const childSamples = await readCircularSamples(
await readEntityChildIds(bodyId)
)
if (childSamples.length > 0) return childSamples
const sketchResponse = await sendModelingCommand({
type: 'entity_get_sketch_paths',
entity_id: bodyId,
})
if (
sketchResponse.type !== 'entity_get_sketch_paths' ||
typeof sketchResponse.data !== 'object' ||
sketchResponse.data === null ||
!('entity_ids' in sketchResponse.data) ||
!Array.isArray(sketchResponse.data.entity_ids)
) {
return []
}
const sketchIds = sketchResponse.data.entity_ids.filter(
(id): id is string => typeof id === 'string'
)
const sketchChildren = await settleInBatches(
sketchIds,
4,
async (sketchId) => [sketchId, ...(await readEntityChildIds(sketchId))]
)
const sketchEntityIds = sketchChildren.flatMap((result) =>
result.status === 'fulfilled' ? result.value : []
)
return readCircularSamples(sketchEntityIds)
} catch (error) {
console.warn(
'[HiCAD manufacturing] Unable to inspect circular body edges',
{ bodyId, error }
)
return []
}
}
const readBoundingBox = async (entityIds: string[]) => {
const response = await sendModelingCommand({
type: 'bounding_box',
entity_ids: entityIds,
output_unit: 'mm',
})
if (
response.type !== 'bounding_box' ||
typeof response.data !== 'object' ||
response.data === null
) {
throw new Error('HiCAD 引擎没有返回尺寸数据')
}
return response.data as BoundingBox
}
const captureSnapshot = async () => {
const response = await sendModelingCommand({
type: 'take_snapshot',
format: 'png',
})
if (
response.type !== 'take_snapshot' ||
typeof response.data !== 'object' ||
response.data === null ||
!('contents' in response.data) ||
typeof response.data.contents !== 'string'
) {
return undefined
}
const dataUrl = createSnapshotDataUrl(response.data.contents)
setSnapshot(dataUrl)
return dataUrl
}
const resetExplodeTransforms = async () => {
const offsets = explodeOffsetsRef.current
if (offsets.length === 0) return
await Promise.allSettled(
offsets.map(({ id, offset }) =>
sendModelingCommand({
type: 'set_object_transform',
object_id: id,
transforms: [
{
translate: {
property: {
x: -offset.x,
y: -offset.y,
z: -offset.z,
},
set: false,
origin: { type: 'global' },
},
},
],
})
)
)
explodeOffsetsRef.current = []
setExploded(false)
}
const analyzeModel = async (manageBusy = true) => {
if (manageBusy) setBusy(true)
try {
if (explodeOffsetsRef.current.length > 0) {
await resetExplodeTransforms()
}
const artifactBodies = [
...getBodiesFromArtifactGraph(
kclManager.execStateSignal.value.artifactGraph
),
]
let sceneBodyIds: string[] = []
try {
sceneBodyIds = await getSceneSolidIds()
} catch (error) {
console.warn(
'[HiCAD manufacturing] Unable to enumerate scene solids; using artifact graph',
error
)
}
const bodyIds =
sceneBodyIds.length > 0
? sceneBodyIds
: artifactBodies.map(([bodyId]) => bodyId)
if (bodyIds.length === 0) {
throw new Error('当前模型还没有可识别的实体')
}
const variables = kclManager.variablesSignal.value as VariableMap
const wasmInstance = await kclManager.wasmInstancePromise
const lengthParameters = collectManufacturingLengthParameters(
variables,
wasmInstance
)
const measurementResults = await settleInBatches(
bodyIds,
3,
async (bodyId, index): Promise<BodyMeasurement> => {
const boundingBox = await readBoundingBox([bodyId])
const [volumeResponse, areaResponse] = await Promise.allSettled([
sendModelingCommand({
type: 'volume',
entity_ids: [bodyId],
output_unit: 'mm3',
}),
sendModelingCommand({
type: 'surface_area',
entity_ids: [bodyId],
output_unit: 'mm2',
}),
])
const measurement: BodyMeasurement = {
id: bodyId,
name: resolveBodyName(bodyId, variables, index),
center: boundingBox.center,
dimensions: boundingBox.dimensions,
volume:
volumeResponse.status === 'fulfilled'
? numberValue(volumeResponse.value.data, 'volume')
: undefined,
surfaceArea:
areaResponse.status === 'fulfilled'
? numberValue(areaResponse.value.data, 'surface_area')
: undefined,
}
const circularEdges = await readCircularEdges(bodyId)
measurement.holes = detectPanelHoles(measurement, circularEdges)
return measurement
}
)
const measured = measurementResults.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : []
)
const measurementErrors = measurementResults.flatMap((result) =>
result.status === 'rejected'
? [
result.reason instanceof Error
? result.reason.message
: String(result.reason),
]
: []
)
if (measured.length === 0) {
try {
const overallBoundingBox = await readBoundingBox([])
measured.push({
id: 'hicad-scene-aggregate',
name: '模型整体',
center: overallBoundingBox.center,
dimensions: overallBoundingBox.dimensions,
})
toast(
'当前引擎只返回了整模尺寸,已生成整体工程记录;独立零件 BOM 需模型包含可分离实体。'
)
} catch (error) {
const details =
measurementErrors[0] ||
(error instanceof Error ? error.message : String(error))
throw new Error(`无法读取模型尺寸:${details}`)
}
} else if (measurementErrors.length > 0) {
toast(
`已识别 ${measured.length} 个实体,另有 ${measurementErrors.length} 个实体暂时无法测量。`
)
}
const manufacturingBodies = reconcileManufacturingDimensions(
filterManufacturingBodies(measured),
lengthParameters
)
const parametricCount = manufacturingBodies.filter(
(body) => body.dimensionSource === 'kcl-parameters'
).length
const correctedCount = manufacturingBodies.filter(
(body) => body.engineDimensions !== undefined
).length
if (parametricCount > 0) {
toast.success(
correctedCount > 0
? `已按 KCL 参数校准 ${parametricCount} 个零件,修正 ${correctedCount} 项场景缩放尺寸。`
: `已使用 KCL 参数核验 ${parametricCount} 个零件的制造尺寸。`
)
}
if (manufacturingBodies.length < measured.length) {
toast(
`已忽略 ${measured.length - manufacturingBodies.length} 个建模模板实体。`
)
}
setBodies(manufacturingBodies)
await Promise.allSettled([
engineViewIsometric({
engineCommandManager: kclManager.engineCommandManager,
padding: 0.22,
}),
engineStreamZoomToFit({
engineCommandManager: kclManager.engineCommandManager,
padding: 0.24,
}),
])
await new Promise((resolve) => setTimeout(resolve, 180))
try {
await captureSnapshot()
} catch (error) {
console.warn(
'[HiCAD manufacturing] Snapshot failed after successful analysis',
error
)
}
return manufacturingBodies
} catch (error) {
const message =
error instanceof Error ? error.message : '工程数据分析失败'
toast.error(message)
return []
} finally {
if (manageBusy) setBusy(false)
}
}
// This component is lazy-mounted by the launcher, so automatic analysis only
// starts after the user explicitly opens the engineering tools.
useEffect(() => {
if (!initialOpen) return
void loadBrandSettings()
void analyzeModel()
}, [initialOpen])
const handleOpen = () => {
setOpen(true)
void loadBrandSettings()
if (bodies.length === 0) {
void analyzeModel()
}
}
const toggleExplodedView = async () => {
if (busy) return
setBusy(true)
try {
if (exploded) {
await resetExplodeTransforms()
} else {
const measured =
bodies.length > 0 ? bodies : await analyzeModel(false)
if (measured.length < 2) {
throw new Error(
'爆炸图需要至少两个独立实体;当前模型可能已经布尔合并为一个整体'
)
}
const offsets = buildExplodeOffsets(measured)
const transformResults = await Promise.allSettled(
offsets.map(({ id, offset }) =>
sendModelingCommand({
type: 'set_object_transform',
object_id: id,
transforms: [
{
translate: {
property: offset,
set: false,
origin: { type: 'global' },
},
},
],
})
)
)
const successfulOffsets = offsets.filter(
(_, index) => transformResults[index]?.status === 'fulfilled'
)
explodeOffsetsRef.current = successfulOffsets
const failedCount = transformResults.length - successfulOffsets.length
if (failedCount > 0) {
await resetExplodeTransforms()
throw new Error(
`${failedCount} 个实体无法移动,已自动复原装配,请检查模型实体`
)
}
explodeOffsetsRef.current = offsets
setExploded(true)
}
await engineViewIsometric({
engineCommandManager: kclManager.engineCommandManager,
padding: 0.18,
})
await engineStreamZoomToFit({
engineCommandManager: kclManager.engineCommandManager,
padding: 0.18,
})
await new Promise((resolve) => setTimeout(resolve, 240))
await captureSnapshot()
} catch (error) {
toast.error(error instanceof Error ? error.message : '爆炸图生成失败')
} finally {
setBusy(false)
}
}
const bomRows = useMemo(
() =>
groupBodiesIntoBom(bodies).map((row) => ({
...row,
...overrides[row.key],
})),
[bodies, overrides]
)
const overall = useMemo(() => getOverallBounds(bodies), [bodies])
const parametricDimensionCount = useMemo(
() =>
bodies.filter((body) => body.dimensionSource === 'kcl-parameters').length,
[bodies]
)
const volumeCheckedDimensionCount = useMemo(
() =>
bodies.filter((body) => body.dimensionSource === 'engine-volume-check')
.length,
[bodies]
)
const updateRow = (
row: BomRow,
field: 'name' | 'material' | 'process',
value: string
) => {
setOverrides((current) => ({
...current,
[row.key]: {
...current[row.key],
[field]: value,
},
}))
}
const downloadBom = () => {
if (bomRows.length === 0) {
toast.error('请先分析模型')
return
}
downloadText(
`${resolvedProjectName}-BOM.csv`,
createBomCsv(resolvedProjectName, bomRows),
'text/csv;charset=utf-8'
)
}
const printEngineeringSheet = () => {
if (bomRows.length === 0) {
toast.error('请先分析模型')
return
}
const reportWindow = window.open('', '_blank')
if (!reportWindow) {
toast.error('浏览器阻止了工艺图窗口,请允许本站打开新窗口')
return
}
reportWindow.opener = null
reportWindow.document.open()
reportWindow.document.write(
createEngineeringSheetHtml({
projectName: resolvedProjectName,
generatedAt: new Date().toLocaleString('zh-CN'),
overallDimensions: overall.dimensions,
bodies,
brandName: brand.name,
brandLogoUrl: brand.logoUrl,
bomRows,
notes,
})
)
reportWindow.document.close()
}
const handleClose = async () => {
if (busy) return
setBusy(true)
try {
if (explodeOffsetsRef.current.length > 0) {
await resetExplodeTransforms()
await engineStreamZoomToFit({
engineCommandManager: kclManager.engineCommandManager,
padding: 0.24,
})
}
setOpen(false)
onRequestClose?.()
} finally {
setBusy(false)
}
}
return (
<>
{!hideTrigger && (
<button
type="button"
data-testid="hicad-manufacturing-trigger"
onClick={handleOpen}
className="absolute right-4 top-14 z-20 h-9 border border-chalkboard-30 bg-chalkboard-10 px-3 text-sm font-medium shadow-sm hover:bg-chalkboard-20 dark:border-chalkboard-70 dark:bg-chalkboard-90 dark:hover:bg-chalkboard-80"
>
工程出图
</button>
)}
{open && (
<div
className="fixed inset-0 z-[100] bg-black/50 p-4 md:p-8"
role="dialog"
aria-modal="true"
aria-label="工程出图"
>
<div className="mx-auto flex h-full max-w-[1500px] flex-col overflow-hidden bg-chalkboard-10 dark:bg-chalkboard-100 border border-chalkboard-30 dark:border-chalkboard-70 shadow-2xl">
<header className="flex shrink-0 items-center justify-between gap-4 border-b border-chalkboard-30 dark:border-chalkboard-70 px-5 py-4">
<div>
<h2 className="text-lg font-semibold">工程出图</h2>
<p className="text-xs text-chalkboard-60 dark:text-chalkboard-40">
真实实体爆炸、尺寸、BOM 与可打印生产工艺图
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={busy}
onClick={() => void analyzeModel()}
className="h-9 border border-chalkboard-30 dark:border-chalkboard-70 px-3 text-sm disabled:opacity-50"
>
重新分析
</button>
<button
type="button"
aria-label="关闭"
disabled={busy}
onClick={() => void handleClose()}
className="grid size-9 place-items-center border border-chalkboard-30 dark:border-chalkboard-70 text-xl disabled:opacity-50"
>
×
</button>
</div>
</header>
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-chalkboard-30 dark:border-chalkboard-70 px-5 py-3">
<button
type="button"
disabled={busy}
onClick={() => void toggleExplodedView()}
className="h-9 bg-chalkboard-100 px-4 text-sm font-medium text-chalkboard-10 dark:bg-chalkboard-10 dark:text-chalkboard-100 disabled:opacity-50"
>
{busy ? '处理中…' : exploded ? '复原装配' : '一键爆炸'}
</button>
<button
type="button"
onClick={downloadBom}
className="h-9 border border-chalkboard-30 dark:border-chalkboard-70 px-4 text-sm"
>
下载 BOM
</button>
<button
type="button"
onClick={printEngineeringSheet}
className="h-9 border border-chalkboard-30 dark:border-chalkboard-70 px-4 text-sm"
>
打印 / PDF 工艺图
</button>
<span className="ml-auto text-xs text-chalkboard-60 dark:text-chalkboard-40">
{bodies.length} 个独立实体 · {bomRows.length} 类零件 · 总尺寸{' '}
{formatNumber(overall.dimensions.x)} ×{' '}
{formatNumber(overall.dimensions.y)} ×{' '}
{formatNumber(overall.dimensions.z)} mm
</span>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-y-auto xl:grid-cols-[minmax(360px,0.8fr)_minmax(700px,1.4fr)]">
<section className="border-b border-chalkboard-30 dark:border-chalkboard-70 p-5 xl:border-b-0 xl:border-r">
<h3 className="mb-3 text-sm font-semibold">装配 / 爆炸预览</h3>
<div className="grid min-h-[320px] place-items-center border border-chalkboard-30 dark:border-chalkboard-70 bg-white dark:bg-black">
{snapshot ? (
<img
src={snapshot}
alt={exploded ? '模型爆炸图' : '模型装配图'}
className="max-h-[520px] w-full object-contain"
/>
) : (
<span className="text-sm text-chalkboard-60">
{busy ? '正在读取工程数据…' : '暂无预览'}
</span>
)}
</div>
<h3 className="mb-2 mt-5 text-sm font-semibold">生产说明</h3>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
className="min-h-28 w-full resize-y border border-chalkboard-30 bg-transparent p-3 text-sm dark:border-chalkboard-70"
/>
</section>
<section className="min-w-0 p-5">
<h3 className="mb-3 text-sm font-semibold">配件明细表</h3>
<div className="overflow-x-auto border border-chalkboard-30 dark:border-chalkboard-70">
<table className="w-full min-w-[920px] border-collapse text-sm">
<thead className="bg-chalkboard-20 dark:bg-chalkboard-90">
<tr>
<th className="border-b p-2 text-left">序号</th>
<th className="border-b p-2 text-left">零件名称</th>
<th className="border-b p-2 text-left">数量</th>
<th className="border-b p-2 text-left">长 × 宽 × 高 (mm)</th>
<th className="border-b p-2 text-left">材料</th>
<th className="border-b p-2 text-left">生产工艺</th>
<th className="border-b p-2 text-left">体积 / 表面积</th>
</tr>
</thead>
<tbody>
{bomRows.map((row) => (
<tr
key={row.key}
className="border-b border-chalkboard-30 last:border-b-0 dark:border-chalkboard-70"
>
<td className="p-2">{row.itemNumber}</td>
<td className="p-2">
<input
value={row.name}
onChange={(event) =>
updateRow(row, 'name', event.target.value)
}
aria-label={`零件名称 ${row.itemNumber}`}
className="h-8 w-36 border border-chalkboard-30 bg-transparent px-2 font-medium dark:border-chalkboard-70"
/>
</td>
<td className="p-2">{row.quantity}</td>
<td className="p-2 whitespace-nowrap">
{formatNumber(row.dimensions.x)} ×{' '}
{formatNumber(row.dimensions.y)} ×{' '}
{formatNumber(row.dimensions.z)}
</td>
<td className="p-2">
<input
value={row.material}
onChange={(event) =>
updateRow(row, 'material', event.target.value)
}
className="h-8 w-28 border border-chalkboard-30 bg-transparent px-2 dark:border-chalkboard-70"
/>
</td>
<td className="p-2">
<input
value={row.process}
onChange={(event) =>
updateRow(row, 'process', event.target.value)
}
className="h-8 w-64 border border-chalkboard-30 bg-transparent px-2 dark:border-chalkboard-70"
/>
</td>
<td className="p-2 text-xs whitespace-nowrap">
{formatNumber(row.volume)} mm³ /{' '}
{formatNumber(row.surfaceArea)} mm²
</td>
</tr>
))}
{bomRows.length === 0 && (
<tr>
<td
colSpan={7}
className="p-10 text-center text-chalkboard-60"
>
{busy ? '正在分析模型…' : '暂无可识别零件'}
</td>
</tr>
)}
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-chalkboard-60 dark:text-chalkboard-40">
{parametricDimensionCount > 0
? `制造尺寸优先采用 KCL 已求值参数(${parametricDimensionCount} 个实体)`
: volumeCheckedDimensionCount > 0
? `制造尺寸已通过实体体积交叉校验(${volumeCheckedDimensionCount} 个实体)`
: '当前制造尺寸来自模型实体包围盒'}
。材料、工艺、公差和连接方式必须由工程人员投产前复核。
</p>
</section>
</div>
</div>
</div>
)}
</>
)
}