diff --git a/tests/routes/hicad-manufacturing-extension.test.ts b/tests/routes/hicad-manufacturing-extension.test.ts index 60bcaa673..66907310e 100644 --- a/tests/routes/hicad-manufacturing-extension.test.ts +++ b/tests/routes/hicad-manufacturing-extension.test.ts @@ -6,9 +6,11 @@ import { createBomCsv, createEngineeringSheetHtml, createSnapshotDataUrl, + detectPanelHoles, filterManufacturingBodies, groupBodiesIntoBom, type BodyMeasurement, + type CircularEdgeSample, } from '../../vendor/zoo-modeling-app/manufacturing' const bodies: BodyMeasurement[] = [ @@ -38,6 +40,23 @@ const bodies: BodyMeasurement[] = [ }, ] +function circularEdge( + centerX: number, + centerY: number, + z: number, + radius: number +): CircularEdgeSample { + return { + controlPoints: [ + { x: centerX + radius, y: centerY, z }, + { x: centerX, y: centerY + radius, z }, + { x: centerX - radius, y: centerY, z }, + { x: centerX, y: centerY - radius, z }, + ], + length: Math.PI * radius * 2, + } +} + test.describe('HiCAD manufacturing extension', () => { test('keeps the extension isolated behind one official registration point', () => { const projectRoot = process.cwd() @@ -162,6 +181,47 @@ test.describe('HiCAD manufacturing extension', () => { ).toBe('data:image/png;base64,iVBORw0KGgo/8A==') }) + test('detects through holes on a panel and deduplicates both face edges', () => { + const panel: BodyMeasurement = { + id: 'side-panel', + name: 'sidePanel', + center: { x: 0, y: 0, z: 9 }, + dimensions: { x: 400, y: 300, z: 18 }, + } + const holeCenters = [ + [-160, -100], + [160, -100], + [-160, 100], + [160, 100], + ] + const edgeSamples = holeCenters.flatMap(([x, y]) => [ + circularEdge(x, y, 0, 4), + circularEdge(x, y, 18, 4), + ]) + const holes = detectPanelHoles(panel, edgeSamples) + + expect(holes).toHaveLength(4) + expect(holes.every((hole) => hole.axis === 'z')).toBe(true) + expect(holes.every((hole) => hole.diameter === 8)).toBe(true) + expect(holes.every((hole) => hole.depth === 18)).toBe(true) + + const rows = groupBodiesIntoBom([{ ...panel, holes }]) + const html = createEngineeringSheetHtml({ + projectName: '五金连接侧板', + generatedAt: '2026-07-27 22:00', + overallDimensions: panel.dimensions, + bodies: [{ ...panel, holes }], + bomRows: rows, + }) + expect(html).toContain('零件加工详图、孔径与孔位尺寸') + expect(html).toContain('4 × Ø8') + expect(html).toContain('H1:Ø8 通孔') + expect(html).toContain('距左 40') + expect(html).toContain('距下 50') + expect(html).toContain('横向中心孔距 320 mm') + expect(html).toContain('纵向中心孔距 200 mm') + }) + test('builds a printable dimensioned engineering sheet safely', () => { const rows = groupBodiesIntoBom(bodies) const html = createEngineeringSheetHtml({ @@ -185,6 +245,8 @@ test.describe('HiCAD manufacturing extension', () => { expect(html).toContain('data-part-name="台面板"') expect(html).toContain('120 mm') expect(html).toContain('配件明细表(BOM)') + expect(html).toContain('零件加工详图、孔径与孔位尺寸') + expect(html.match(/class="part-detail"/g)).toHaveLength(2) expect(html).toContain('由壹点AI') expect(html).toContain('/api/favicon?surface=hicad') expect(html).toContain('@page { size: A3 landscape;') @@ -215,6 +277,7 @@ test.describe('HiCAD manufacturing extension', () => { clientWidth: document.documentElement.clientWidth, tableCount: document.querySelectorAll('table').length, dimensionViewCount: document.querySelectorAll('.dimension-view').length, + partDetailCount: document.querySelectorAll('.part-detail').length, projectionPartCount: document.querySelectorAll('.projection-part').length, brandCount: document.querySelectorAll('.brand-name').length, @@ -223,6 +286,7 @@ test.describe('HiCAD manufacturing extension', () => { expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth) expect(layout.tableCount).toBe(1) expect(layout.dimensionViewCount).toBe(3) + expect(layout.partDetailCount).toBe(rows.length) expect(layout.projectionPartCount).toBe(bodies.length * 3) expect(layout.brandCount).toBe(1) await page.screenshot({ diff --git a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template index 86da8e8e5..fa5743127 100644 --- a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template +++ b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template @@ -11,11 +11,13 @@ import { createSnapshotDataUrl, createBomCsv, createEngineeringSheetHtml, + detectPanelHoles, filterManufacturingBodies, getOverallBounds, groupBodiesIntoBom, type BodyMeasurement, type BomRow, + type CircularEdgeSample, type ExplodeOffset, } from './manufacturing' @@ -241,6 +243,126 @@ export function HicadManufacturingPanel({ ] } + 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', @@ -355,7 +477,7 @@ export function HicadManufacturingPanel({ }), ]) - return { + const measurement: BodyMeasurement = { id: bodyId, name: resolveBodyName(bodyId, variables, index), center: boundingBox.center, @@ -369,6 +491,9 @@ export function HicadManufacturingPanel({ ? numberValue(areaResponse.value.data, 'surface_area') : undefined, } + const circularEdges = await readCircularEdges(bodyId) + measurement.holes = detectPanelHoles(measurement, circularEdges) + return measurement }) ) const measured = measurementResults.flatMap((result) => diff --git a/vendor/zoo-modeling-app/manufacturing.ts b/vendor/zoo-modeling-app/manufacturing.ts index 002d466b3..988ae9312 100644 --- a/vendor/zoo-modeling-app/manufacturing.ts +++ b/vendor/zoo-modeling-app/manufacturing.ts @@ -4,6 +4,19 @@ export type Point3d = { z: number } +export type HoleFeature = { + type: 'through-hole' | 'blind-hole' + center: Point3d + diameter: number + axis: keyof Point3d + depth?: number +} + +export type CircularEdgeSample = { + controlPoints: Point3d[] + length?: number +} + export type BodyMeasurement = { id: string name: string @@ -11,6 +24,7 @@ export type BodyMeasurement = { dimensions: Point3d volume?: number surfaceArea?: number + holes?: HoleFeature[] } export type ExplodeOffset = { @@ -29,6 +43,8 @@ export type BomRow = { material: string process: string bodyIds: string[] + center: Point3d + holes: HoleFeature[] } export type EngineeringSheetInput = { @@ -108,6 +124,198 @@ function vectorLength(point: Point3d) { return Math.hypot(point.x, point.y, point.z) } +function panelAxes(dimensions: Point3d) { + const axes = (['x', 'y', 'z'] as Array).sort( + (left, right) => + Math.abs(dimensions[left]) - Math.abs(dimensions[right]) + ) + return { + depth: axes[0], + vertical: axes[1], + horizontal: axes[2], + } +} + +function cross(left: Point3d, right: Point3d): Point3d { + return { + x: left.y * right.z - left.z * right.y, + y: left.z * right.x - left.x * right.z, + z: left.x * right.y - left.y * right.x, + } +} + +function subtract(left: Point3d, right: Point3d): Point3d { + return { + x: left.x - right.x, + y: left.y - right.y, + z: left.z - right.z, + } +} + +function projectedCircumcircle( + points: Point3d[], + horizontal: keyof Point3d, + vertical: keyof Point3d +) { + let best: + | { + first: Point3d + second: Point3d + third: Point3d + area: number + } + | undefined + const candidates = points.slice(0, 16) + + for (let firstIndex = 0; firstIndex < candidates.length - 2; firstIndex += 1) { + for ( + let secondIndex = firstIndex + 1; + secondIndex < candidates.length - 1; + secondIndex += 1 + ) { + for ( + let thirdIndex = secondIndex + 1; + thirdIndex < candidates.length; + thirdIndex += 1 + ) { + const first = candidates[firstIndex] + const second = candidates[secondIndex] + const third = candidates[thirdIndex] + const area = Math.abs( + (second[horizontal] - first[horizontal]) * + (third[vertical] - first[vertical]) - + (third[horizontal] - first[horizontal]) * + (second[vertical] - first[vertical]) + ) + if (!best || area > best.area) { + best = { first, second, third, area } + } + } + } + } + + if (!best || best.area < EPSILON) return undefined + + const firstHorizontal = best.first[horizontal] + const firstVertical = best.first[vertical] + const secondHorizontal = best.second[horizontal] + const secondVertical = best.second[vertical] + const thirdHorizontal = best.third[horizontal] + const thirdVertical = best.third[vertical] + const denominator = + 2 * + (firstHorizontal * (secondVertical - thirdVertical) + + secondHorizontal * (thirdVertical - firstVertical) + + thirdHorizontal * (firstVertical - secondVertical)) + if (Math.abs(denominator) < EPSILON) return undefined + + const firstSquared = + firstHorizontal * firstHorizontal + firstVertical * firstVertical + const secondSquared = + secondHorizontal * secondHorizontal + secondVertical * secondVertical + const thirdSquared = + thirdHorizontal * thirdHorizontal + thirdVertical * thirdVertical + const centerHorizontal = + (firstSquared * (secondVertical - thirdVertical) + + secondSquared * (thirdVertical - firstVertical) + + thirdSquared * (firstVertical - secondVertical)) / + denominator + const centerVertical = + (firstSquared * (thirdHorizontal - secondHorizontal) + + secondSquared * (firstHorizontal - thirdHorizontal) + + thirdSquared * (secondHorizontal - firstHorizontal)) / + denominator + const radius = Math.hypot( + centerHorizontal - firstHorizontal, + centerVertical - firstVertical + ) + const normal = normalize( + cross( + subtract(best.second, best.first), + subtract(best.third, best.first) + ) + ) + + return { + centerHorizontal, + centerVertical, + radius, + normal, + } +} + +export function detectPanelHoles( + body: BodyMeasurement, + circularEdges: CircularEdgeSample[] +) { + const axes = panelAxes(body.dimensions) + const thickness = Math.abs(body.dimensions[axes.depth]) + const faceSize = Math.min( + Math.abs(body.dimensions[axes.horizontal]), + Math.abs(body.dimensions[axes.vertical]) + ) + if ( + thickness <= EPSILON || + faceSize <= EPSILON || + thickness / faceSize > 0.35 + ) { + return [] + } + + const candidates = circularEdges.flatMap((edge): HoleFeature[] => { + if (edge.controlPoints.length < 3) return [] + const circle = projectedCircumcircle( + edge.controlPoints, + axes.horizontal, + axes.vertical + ) + if (!circle || circle.radius < 0.5) return [] + if (Math.abs(circle.normal[axes.depth]) < 0.82) return [] + + const diameter = circle.radius * 2 + if (diameter > faceSize * 0.45) return [] + const circumference = Math.PI * diameter + if ( + edge.length !== undefined && + edge.length < circumference * 0.82 + ) { + return [] + } + + const center = { ...body.center } + center[axes.horizontal] = circle.centerHorizontal + center[axes.vertical] = circle.centerVertical + center[axes.depth] = body.center[axes.depth] + return [ + { + type: 'through-hole', + center, + diameter: roundDimension(diameter), + axis: axes.depth, + depth: roundDimension(thickness), + }, + ] + }) + + const deduplicated: HoleFeature[] = [] + for (const candidate of candidates) { + const duplicate = deduplicated.some( + (existing) => + existing.axis === candidate.axis && + Math.abs(existing.diameter - candidate.diameter) < 0.2 && + vectorLength(subtract(existing.center, candidate.center)) < 0.35 + ) + if (!duplicate) deduplicated.push(candidate) + } + + return deduplicated.sort((left, right) => { + const horizontalDifference = + left.center[axes.horizontal] - right.center[axes.horizontal] + if (Math.abs(horizontalDifference) > EPSILON) return horizontalDifference + return left.center[axes.vertical] - right.center[axes.vertical] + }) +} + function normalize(point: Point3d): Point3d { const length = vectorLength(point) if (length < EPSILON) { @@ -216,6 +424,10 @@ const CHINESE_PART_NAMES: Record = { baseboard: '底板', bottompanel: '底板', bracket: '支架', + camconnector: '三合一偏心连接件', + camlock: '三合一偏心轮', + connectorbolt: '连接螺栓', + connectorplate: '连接片', dividerpanel: '隔板', doorpanel: '门板', dowel: '木榫', @@ -224,10 +436,12 @@ const CHINESE_PART_NAMES: Record = { foot: '支腿', handle: '拉手', hinge: '铰链', + metalbracket: '金属连接角码', leftsidepanel: '左侧板', leg: '支腿', rightsidepanel: '右侧板', screw: '螺钉', + shelfpin: '层板托', seat: '座板', seatboard: '座板', seatbody: '座板', @@ -240,6 +454,7 @@ const CHINESE_PART_NAMES: Record = { topboard: '顶板', topbottompanel: '顶底板', toppanel: '顶板', + washer: '垫圈', } export function localizePartName(name: string) { @@ -254,6 +469,26 @@ function dimensionsKey(dimensions: Point3d) { .join('x') } +function holePatternKey(body: BodyMeasurement) { + return (body.holes ?? []) + .map((hole) => { + const local = { + x: hole.center.x - body.center.x, + y: hole.center.y - body.center.y, + z: hole.center.z - body.center.z, + } + return `${hole.axis}:${roundDimension(hole.diameter).toFixed(2)}:${[ + local.x, + local.y, + local.z, + ] + .map((value) => roundDimension(value).toFixed(2)) + .join(',')}` + }) + .sort() + .join('|') +} + export function inferProcess(dimensions: Point3d) { const values = [dimensions.x, dimensions.y, dimensions.z] .map((value) => Math.abs(value)) @@ -278,7 +513,7 @@ export function groupBodiesIntoBom(bodies: BodyMeasurement[]): BomRow[] { const name = localizePartName(normalizedName) const key = `${normalizedName.toLowerCase()}::${dimensionsKey( body.dimensions - )}` + )}::${holePatternKey(body)}` const existing = groups.get(key) if (existing) { @@ -302,6 +537,8 @@ export function groupBodiesIntoBom(bodies: BodyMeasurement[]): BomRow[] { material: '待确认', process: inferProcess(body.dimensions), bodyIds: [body.id], + center: body.center, + holes: body.holes ?? [], }) } @@ -472,6 +709,182 @@ function orthographicViewSvg( ` } +function uniqueRounded(values: number[]) { + return [ + ...new Set( + values.map((value) => roundDimension(value).toFixed(2)) + ), + ] + .map(Number) + .sort((left, right) => left - right) +} + +function machiningDetailSvg(row: BomRow) { + const axes = panelAxes(row.dimensions) + const horizontal = Math.abs(row.dimensions[axes.horizontal]) + const vertical = Math.abs(row.dimensions[axes.vertical]) + const depth = Math.abs(row.dimensions[axes.depth]) + const objectMaxWidth = 230 + const objectMaxHeight = 145 + const scale = Math.min( + objectMaxWidth / Math.max(horizontal, EPSILON), + objectMaxHeight / Math.max(vertical, EPSILON) + ) + const objectWidth = Math.max(horizontal * scale, 1) + const objectHeight = Math.max(vertical * scale, 1) + const objectX = 75 + (objectMaxWidth - objectWidth) / 2 + const objectY = 42 + (objectMaxHeight - objectHeight) / 2 + const holes = row.holes.filter((hole) => hole.axis === axes.depth) + const holePositions = holes.map((hole, index) => { + const fromLeft = + hole.center[axes.horizontal] - + row.center[axes.horizontal] + + horizontal / 2 + const fromBottom = + hole.center[axes.vertical] - + row.center[axes.vertical] + + vertical / 2 + return { + ...hole, + index: index + 1, + fromLeft: roundDimension(fromLeft), + fromBottom: roundDimension(fromBottom), + x: objectX + fromLeft * scale, + y: objectY + objectHeight - fromBottom * scale, + radius: Math.max((hole.diameter / 2) * scale, 2.5), + } + }) + const horizontalHolePositions = uniqueRounded( + holePositions.map((hole) => hole.fromLeft) + ) + const verticalHolePositions = uniqueRounded( + holePositions.map((hole) => hole.fromBottom) + ) + const horizontalPitch = + horizontalHolePositions.length > 1 + ? roundDimension( + horizontalHolePositions.at(-1)! - horizontalHolePositions[0] + ) + : undefined + const verticalPitch = + verticalHolePositions.length > 1 + ? roundDimension( + verticalHolePositions.at(-1)! - verticalHolePositions[0] + ) + : undefined + const holeMarkup = holePositions + .map( + (hole) => ` + + + + H${hole.index}` + ) + .join('') + const holeNotes = + holePositions.length > 0 + ? holePositions + .map( + (hole) => + `
  • H${hole.index}:Ø${roundDimension( + hole.diameter + )} ${hole.type === 'through-hole' ? '通孔' : `深 ${roundDimension( + hole.depth ?? 0 + )}`},距左 ${hole.fromLeft},距下 ${hole.fromBottom}
  • ` + ) + .join('') + : '
  • 该零件未识别到圆孔特征
  • ' + const pitchNotes = [ + horizontalPitch === undefined ? '' : `横向中心孔距 ${horizontalPitch} mm`, + verticalPitch === undefined ? '' : `纵向中心孔距 ${verticalPitch} mm`, + ] + .filter(Boolean) + .join(';') + + return ` +
    +
    + ${row.itemNumber}. ${escapeHtml(row.name)} + 数量 ${row.quantity} · 厚度 ${formatDimension(depth)} +
    +
    + + + + + + + + ${holeMarkup} + + + + ${formatDimension(horizontal)} + + + + ${formatDimension( + vertical + )} + +
    + 孔位与加工基准 +
      ${holeNotes}
    + ${ + pitchNotes + ? `

    孔距:${escapeHtml(pitchNotes)}

    ` + : '' + } +

    基准:左下角为孔位坐标基准,尺寸单位 mm。

    +
    +
    +
    ` +} + export function createEngineeringSheetHtml(input: EngineeringSheetInput) { const { overallDimensions: dimensions } = input const projectionBodies = @@ -495,9 +908,13 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) { ${formatDimension(row.dimensions.x)} × ${formatDimension(row.dimensions.y)} × ${formatDimension(row.dimensions.z)} ${escapeHtml(row.material)} ${escapeHtml(row.process)} + ${row.holes.length > 0 ? `${row.holes.length} × Ø${escapeHtml( + uniqueRounded(row.holes.map((hole) => hole.diameter)).join(' / Ø') + )}` : '-'} ` ) .join('') + const partDetails = input.bomRows.map(machiningDetailSvg).join('') const snapshot = input.snapshotDataUrl ? `爆炸图` @@ -539,6 +956,17 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) { th, td { border: 1px solid #6b7280; padding: 5px 6px; text-align: left; vertical-align: top; } th { background: #f3f4f6; } .bom { grid-column: 1 / -1; } + .part-details { grid-column: 1 / -1; break-before: page; } + .part-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + .part-detail { border: 1px solid #6b7280; break-inside: avoid; } + .part-detail-heading { display: flex; justify-content: space-between; gap: 12px; padding: 7px 9px; border-bottom: 1px solid #9ca3af; background: #f3f4f6; font-size: 12px; } + .part-detail-layout { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(190px, .8fr); gap: 8px; align-items: start; padding: 6px; } + .part-detail svg { width: 100%; display: block; font-size: 10px; } + .detail-hole { fill: white; stroke: #111827; stroke-width: 1.5; } + .center-line { stroke: #6b7280; stroke-width: .7; stroke-dasharray: 4 3; } + .hole-schedule { padding: 8px 6px 6px 0; font-size: 10px; line-height: 1.5; } + .hole-schedule ul { margin: 5px 0 8px; padding-left: 18px; } + .hole-schedule p { margin: 5px 0; } .notes { grid-column: 1 / -1; border: 1px solid #9ca3af; padding: 10px; min-height: 68px; white-space: pre-wrap; } footer { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr 1fr; border: 1px solid #6b7280; font-size: 11px; } footer div { min-height: 34px; padding: 8px; border-right: 1px solid #6b7280; } @@ -577,11 +1005,15 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {

    配件明细表(BOM)

    - + ${rows}
    序号零件名称数量外形尺寸材料生产工艺
    序号零件名称数量外形尺寸材料生产工艺孔位
    +
    +

    零件加工详图、孔径与孔位尺寸

    +
    ${partDetails}
    +
    生产说明:
    ${escapeHtml(input.notes || '材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。')}