925 lines
31 KiB
Plaintext
925 lines
31 KiB
Plaintext
import type { BoundingBox, ModelingCmd } from '@kittycad/lib'
|
||
import { getBodiesFromArtifactGraph } from '@src/lang/std/artifactGraph'
|
||
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,
|
||
type BodyMeasurement,
|
||
type BomRow,
|
||
type CircularEdgeSample,
|
||
type ExplodeOffset,
|
||
} 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 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)
|
||
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 readCircularEdges = async (bodyId: string) => {
|
||
try {
|
||
const childResponse = await sendModelingCommand({
|
||
type: 'entity_get_all_child_uuids',
|
||
entity_id: bodyId,
|
||
})
|
||
if (
|
||
childResponse.type !== 'entity_get_all_child_uuids' ||
|
||
typeof childResponse.data !== 'object' ||
|
||
childResponse.data === null ||
|
||
!('entity_ids' in childResponse.data) ||
|
||
!Array.isArray(childResponse.data.entity_ids)
|
||
) {
|
||
return []
|
||
}
|
||
const childIds = childResponse.data.entity_ids.filter(
|
||
(id): id is string => typeof id === 'string'
|
||
)
|
||
const typedChildren = await Promise.allSettled(
|
||
childIds.map(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 = typedChildren.flatMap((result) =>
|
||
result.status === 'fulfilled' &&
|
||
(result.value.entityType === 'edge' ||
|
||
result.value.entityType === 'curve')
|
||
? [result.value]
|
||
: []
|
||
)
|
||
const edgeResults = await Promise.allSettled(
|
||
curveCandidates.map(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] : []
|
||
)
|
||
} 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 Record<
|
||
string,
|
||
unknown
|
||
>
|
||
const measurementResults = await Promise.allSettled(
|
||
bodyIds.map(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 = filterManufacturingBodies(measured)
|
||
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 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,
|
||
snapshotDataUrl: snapshot,
|
||
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">
|
||
尺寸来自模型实体包围盒。材料、工艺、公差和连接方式必须由工程人员投产前复核。
|
||
</p>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|