fix: render HiCAD orthographic views and Chinese BOM names
This commit is contained in:
parent
81c3a64089
commit
5fadb1ac61
@ -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'),
|
||||
|
||||
@ -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)} ×{' '}
|
||||
|
||||
142
vendor/zoo-modeling-app/manufacturing.ts
vendored
142
vendor/zoo-modeling-app/manufacturing.ts
vendored
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user