fix: render HiCAD orthographic views and Chinese BOM names

This commit is contained in:
134 2026-07-27 21:47:45 +08:00
parent 81c3a64089
commit 5fadb1ac61
3 changed files with 151 additions and 19 deletions

View File

@ -107,7 +107,7 @@ test.describe('HiCAD manufacturing extension', () => {
test('groups matching parts and exports a UTF-8 BOM', () => {
const rows = groupBodiesIntoBom(bodies)
const legRow = rows.find((row) => row.name === 'leg')
const legRow = rows.find((row) => row.name === '支腿')
expect(rows).toHaveLength(2)
expect(legRow?.quantity).toBe(2)
@ -148,7 +148,8 @@ test.describe('HiCAD manufacturing extension', () => {
expect(filtered).toHaveLength(5)
expect(rows).toHaveLength(2)
expect(rows.find((row) => row.name === 'Leg')?.quantity).toBe(4)
expect(rows.find((row) => row.name === '支腿')?.quantity).toBe(4)
expect(rows.find((row) => row.name === '座板')?.quantity).toBe(1)
expect(rows.some((row) => row.name === 'unitBox')).toBe(false)
})
@ -167,6 +168,7 @@ test.describe('HiCAD manufacturing extension', () => {
projectName: '<工作台>',
generatedAt: '2026-07-27 20:00',
overallDimensions: { x: 120, y: 60, z: 92 },
bodies,
brandName: '由壹点AI',
brandLogoUrl: '/api/favicon?surface=hicad',
snapshotDataUrl: 'data:image/png;base64,abc',
@ -178,6 +180,9 @@ test.describe('HiCAD manufacturing extension', () => {
expect(html).toContain('正视图')
expect(html).toContain('俯视图')
expect(html).toContain('右视图')
expect(html.match(/class="projection-part"/g)).toHaveLength(9)
expect(html).toContain('data-part-name="支腿"')
expect(html).toContain('data-part-name="台面板"')
expect(html).toContain('120 mm')
expect(html).toContain('配件明细表(BOM)')
expect(html).toContain('由壹点AI')
@ -193,6 +198,7 @@ test.describe('HiCAD manufacturing extension', () => {
projectName: '工作台生产图',
generatedAt: '2026-07-27 20:00',
overallDimensions: { x: 120, y: 60, z: 92 },
bodies,
brandName: '由壹点AI',
brandLogoUrl:
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="80" height="80"%3E%3Crect width="80" height="80" fill="%23111827"/%3E%3Cpath d="M20 20h40v40H20z" fill="white"/%3E%3C/svg%3E',
@ -209,12 +215,15 @@ test.describe('HiCAD manufacturing extension', () => {
clientWidth: document.documentElement.clientWidth,
tableCount: document.querySelectorAll('table').length,
dimensionViewCount: document.querySelectorAll('.dimension-view').length,
projectionPartCount:
document.querySelectorAll('.projection-part').length,
brandCount: document.querySelectorAll('.brand-name').length,
}))
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
expect(layout.tableCount).toBe(1)
expect(layout.dimensionViewCount).toBe(3)
expect(layout.projectionPartCount).toBe(bodies.length * 3)
expect(layout.brandCount).toBe(1)
await page.screenshot({
path: testInfo.outputPath('engineering-sheet.png'),

View File

@ -27,6 +27,7 @@ type ModelingResponse = {
type BomOverrides = Record<
string,
{
name?: string
material?: string
process?: string
}
@ -536,7 +537,7 @@ export function HicadManufacturingPanel({
const updateRow = (
row: BomRow,
field: 'material' | 'process',
field: 'name' | 'material' | 'process',
value: string
) => {
setOverrides((current) => ({
@ -577,6 +578,7 @@ export function HicadManufacturingPanel({
projectName: resolvedProjectName,
generatedAt: new Date().toLocaleString('zh-CN'),
overallDimensions: overall.dimensions,
bodies,
brandName: brand.name,
brandLogoUrl: brand.logoUrl,
snapshotDataUrl: snapshot,
@ -731,7 +733,16 @@ export function HicadManufacturingPanel({
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 font-medium">{row.name}</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)} ×{' '}

View File

@ -35,6 +35,7 @@ export type EngineeringSheetInput = {
projectName: string
generatedAt: string
overallDimensions: Point3d
bodies?: BodyMeasurement[]
brandName?: string
brandLogoUrl?: string
snapshotDataUrl?: string
@ -209,6 +210,44 @@ function normalizePartName(name: string) {
return locationLegMatch?.[1] ?? withoutCopySuffix
}
const CHINESE_PART_NAMES: Record<string, string> = {
backpanel: '背板',
base: '底座',
baseboard: '底板',
bottompanel: '底板',
bracket: '支架',
dividerpanel: '隔板',
doorpanel: '门板',
dowel: '木榫',
drawer: '抽屉',
drawerfront: '抽屉面板',
foot: '支腿',
handle: '拉手',
hinge: '铰链',
leftsidepanel: '左侧板',
leg: '支腿',
rightsidepanel: '右侧板',
screw: '螺钉',
seat: '座板',
seatboard: '座板',
seatbody: '座板',
shelf: '层板',
shelfpanel: '层板',
sidepanel: '侧板',
support: '支撑件',
tablebody: '桌体',
tabletop: '台面板',
topboard: '顶板',
topbottompanel: '顶底板',
toppanel: '顶板',
}
export function localizePartName(name: string) {
const cleaned = name.trim() || '未命名零件'
if (/[\u3400-\u9fff]/.test(cleaned)) return cleaned
return CHINESE_PART_NAMES[compactPartName(cleaned)] ?? cleaned
}
function dimensionsKey(dimensions: Point3d) {
return [dimensions.x, dimensions.y, dimensions.z]
.map((value) => roundDimension(value).toFixed(2))
@ -235,8 +274,11 @@ export function groupBodiesIntoBom(bodies: BodyMeasurement[]): BomRow[] {
const groups = new Map<string, BomRow>()
for (const body of bodies) {
const name = normalizePartName(body.name)
const key = `${name.toLowerCase()}::${dimensionsKey(body.dimensions)}`
const normalizedName = normalizePartName(body.name)
const name = localizePartName(normalizedName)
const key = `${normalizedName.toLowerCase()}::${dimensionsKey(
body.dimensions
)}`
const existing = groups.get(key)
if (existing) {
@ -317,19 +359,79 @@ function formatDimension(value: number) {
return `${roundDimension(value)} mm`
}
function dimensionViewSvg(
function projectedBodyRect(
body: BodyMeasurement,
horizontalAxis: keyof Point3d,
verticalAxis: keyof Point3d,
originX: number,
originY: number,
scale: number,
minHorizontal: number,
minVertical: number,
projectedHeight: number
) {
const horizontalStart =
body.center[horizontalAxis] - body.dimensions[horizontalAxis] / 2
const verticalStart =
body.center[verticalAxis] - body.dimensions[verticalAxis] / 2
const rectX = originX + (horizontalStart - minHorizontal) * scale
const rectWidth = Math.max(body.dimensions[horizontalAxis] * scale, 1)
const rectHeight = Math.max(body.dimensions[verticalAxis] * scale, 1)
const rectY =
originY +
projectedHeight -
(verticalStart - minVertical) * scale -
rectHeight
return `<rect class="projection-part"
data-part-name="${escapeHtml(localizePartName(normalizePartName(body.name)))}"
x="${roundDimension(rectX)}" y="${roundDimension(rectY)}"
width="${roundDimension(rectWidth)}" height="${roundDimension(rectHeight)}"
fill="#e5e7eb" fill-opacity="0.64" stroke="#111827" stroke-width="1.4"/>`
}
function orthographicViewSvg(
label: string,
horizontal: number,
vertical: number,
bodies: BodyMeasurement[],
horizontalAxis: keyof Point3d,
verticalAxis: keyof Point3d,
width = 310,
height = 210
) {
const objectX = 72
const objectY = 40
const objectWidth = 180
const objectHeight = 112
const overall = getOverallBounds(bodies)
const horizontal = overall.dimensions[horizontalAxis]
const vertical = overall.dimensions[verticalAxis]
const maxObjectWidth = 180
const maxObjectHeight = 112
const scale = Math.min(
maxObjectWidth / Math.max(horizontal, EPSILON),
maxObjectHeight / Math.max(vertical, EPSILON)
)
const objectWidth = Math.max(horizontal * scale, 1)
const objectHeight = Math.max(vertical * scale, 1)
const objectX = 72 + (maxObjectWidth - objectWidth) / 2
const objectY = 40 + (maxObjectHeight - objectHeight) / 2
const minHorizontal =
overall.center[horizontalAxis] - overall.dimensions[horizontalAxis] / 2
const minVertical =
overall.center[verticalAxis] - overall.dimensions[verticalAxis] / 2
const horizontalText = escapeHtml(formatDimension(horizontal))
const verticalText = escapeHtml(formatDimension(vertical))
const projectedBodies = bodies
.map((body) =>
projectedBodyRect(
body,
horizontalAxis,
verticalAxis,
objectX,
objectY,
scale,
minHorizontal,
minVertical,
objectHeight
)
)
.join('')
return `
<figure class="dimension-view">
@ -340,8 +442,7 @@ function dimensionViewSvg(
<path d="M 0 0 L 10 5 L 0 10 z" fill="#111827"/>
</marker>
</defs>
<rect x="${objectX}" y="${objectY}" width="${objectWidth}" height="${objectHeight}"
fill="none" stroke="#111827" stroke-width="2"/>
${projectedBodies}
<line x1="${objectX}" y1="${objectY + objectHeight + 25}"
x2="${objectX + objectWidth}" y2="${objectY + objectHeight + 25}"
stroke="#111827" marker-start="url(#arrow-${escapeHtml(label)})"
@ -373,6 +474,17 @@ function dimensionViewSvg(
export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
const { overallDimensions: dimensions } = input
const projectionBodies =
input.bodies && input.bodies.length > 0
? input.bodies
: [
{
id: 'overall',
name: '总体',
center: { x: 0, y: 0, z: 0 },
dimensions,
},
]
const rows = input.bomRows
.map(
(row) => `
@ -454,11 +566,11 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
</div>
</section>
<section>
<h2>总体三视图与尺寸</h2>
<h2>实体正投影三视图与尺寸</h2>
<div class="views">
${dimensionViewSvg('正视图', dimensions.x, dimensions.z)}
${dimensionViewSvg('俯视图', dimensions.x, dimensions.y)}
${dimensionViewSvg('右视图', dimensions.y, dimensions.z)}
${orthographicViewSvg('正视图', projectionBodies, 'x', 'z')}
${orthographicViewSvg('俯视图', projectionBodies, 'x', 'y')}
${orthographicViewSvg('右视图', projectionBodies, 'y', 'z')}
</div>
</section>
<section class="bom">