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[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( values: T[], batchSize: number, worker: (value: T, index: number) => Promise ) { const results: Array> = [] 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, seen = new WeakSet(), 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, fallbackIndex: number ) { for (const [name, value] of Object.entries(variables)) { const ids = new Set() 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)[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([]) const [snapshot, setSnapshot] = useState() const [exploded, setExploded] = useState(false) const [overrides, setOverrides] = useState({}) const [brand, setBrand] = useState({ name: '由壹点AI', logoUrl: '/api/favicon?surface=hicad', }) const [notes, setNotes] = useState( '材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。' ) const explodeOffsetsRef = useRef([]) 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 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 => { 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 && ( )} {open && (

工程出图

真实实体爆炸、尺寸、BOM 与可打印生产工艺图

{bodies.length} 个独立实体 · {bomRows.length} 类零件 · 总尺寸{' '} {formatNumber(overall.dimensions.x)} ×{' '} {formatNumber(overall.dimensions.y)} ×{' '} {formatNumber(overall.dimensions.z)} mm

装配 / 爆炸预览

{snapshot ? ( {exploded ) : ( {busy ? '正在读取工程数据…' : '暂无预览'} )}

生产说明