fix: stabilize HiCAD manufacturing dimensions
This commit is contained in:
parent
d2123de778
commit
ce1e101ffc
@ -11,9 +11,11 @@ import {
|
|||||||
getOverallBounds,
|
getOverallBounds,
|
||||||
groupBodiesIntoBom,
|
groupBodiesIntoBom,
|
||||||
localizePartName,
|
localizePartName,
|
||||||
|
parseManufacturingLengthInMillimeters,
|
||||||
reconcileManufacturingDimensions,
|
reconcileManufacturingDimensions,
|
||||||
type BodyMeasurement,
|
type BodyMeasurement,
|
||||||
type CircularEdgeSample,
|
type CircularEdgeSample,
|
||||||
|
type ManufacturingLengthParameter,
|
||||||
} from '../../vendor/zoo-modeling-app/manufacturing'
|
} from '../../vendor/zoo-modeling-app/manufacturing'
|
||||||
|
|
||||||
const bodies: BodyMeasurement[] = [
|
const bodies: BodyMeasurement[] = [
|
||||||
@ -202,6 +204,17 @@ test.describe('HiCAD manufacturing extension', () => {
|
|||||||
expect(buildExplodeOffsets([bodies[0]])).toEqual([])
|
expect(buildExplodeOffsets([bodies[0]])).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('converts evaluated KCL length units to millimeters', () => {
|
||||||
|
expect(parseManufacturingLengthInMillimeters('120 cm')).toBe(1200)
|
||||||
|
expect(parseManufacturingLengthInMillimeters('1.2m')).toBe(1200)
|
||||||
|
expect(parseManufacturingLengthInMillimeters('47.24409449 in')).toBeCloseTo(
|
||||||
|
1200,
|
||||||
|
5
|
||||||
|
)
|
||||||
|
expect(parseManufacturingLengthInMillimeters('1200')).toBeUndefined()
|
||||||
|
expect(parseManufacturingLengthInMillimeters('90 deg')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test('groups matching parts and exports a UTF-8 BOM', () => {
|
test('groups matching parts and exports a UTF-8 BOM', () => {
|
||||||
const rows = groupBodiesIntoBom(bodies)
|
const rows = groupBodiesIntoBom(bodies)
|
||||||
const legRow = rows.find((row) => row.name === '支腿')
|
const legRow = rows.find((row) => row.name === '支腿')
|
||||||
@ -339,6 +352,81 @@ test.describe('HiCAD manufacturing extension', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('uses evaluated KCL parameters when a cold browser has no physical measurements', () => {
|
||||||
|
const coldBrowserBodies: BodyMeasurement[] = [
|
||||||
|
...[
|
||||||
|
'rearRightStudSource',
|
||||||
|
'rearLeftStudSource',
|
||||||
|
'frontRightStudSource',
|
||||||
|
'frontLeftStud',
|
||||||
|
].map(
|
||||||
|
(name, index): BodyMeasurement => ({
|
||||||
|
id: `cold-stud-${index}`,
|
||||||
|
name,
|
||||||
|
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 760 },
|
||||||
|
dimensions: { x: 6, y: 6, z: 10 },
|
||||||
|
})
|
||||||
|
),
|
||||||
|
...[
|
||||||
|
'rearRightLegSource',
|
||||||
|
'rearLeftLegSource',
|
||||||
|
'frontRightLegSource',
|
||||||
|
'frontLeftLeg',
|
||||||
|
].map(
|
||||||
|
(name, index): BodyMeasurement => ({
|
||||||
|
id: `cold-leg-${index}`,
|
||||||
|
name,
|
||||||
|
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 375 },
|
||||||
|
dimensions: { x: 60, y: 60, z: 750 },
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{
|
||||||
|
id: 'cold-tabletop',
|
||||||
|
name: 'tableTop',
|
||||||
|
center: { x: 0, y: 0, z: 762.5 },
|
||||||
|
dimensions: { x: 1440, y: 720, z: 25.1 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const parameters: ManufacturingLengthParameter[] = [
|
||||||
|
{ name: 'tableLength', valueMm: 1200 },
|
||||||
|
{ name: 'tableWidth', valueMm: 600 },
|
||||||
|
{ name: 'tableTopThickness', valueMm: 25 },
|
||||||
|
{ name: 'legDiameter', valueMm: 50 },
|
||||||
|
{ name: 'legHeight', valueMm: 750 },
|
||||||
|
{ name: 'studDiameter', valueMm: 5 },
|
||||||
|
{ name: 'studHeight', valueMm: 10 },
|
||||||
|
{ name: 'unrelatedCabinetWidth', valueMm: 900 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const reconciled = reconcileManufacturingDimensions(
|
||||||
|
coldBrowserBodies,
|
||||||
|
parameters
|
||||||
|
)
|
||||||
|
const rows = groupBodiesIntoBom(reconciled)
|
||||||
|
|
||||||
|
expect(rows.find((row) => row.name === '连接螺柱')).toMatchObject({
|
||||||
|
quantity: 4,
|
||||||
|
dimensions: { x: 5, y: 5, z: 10 },
|
||||||
|
})
|
||||||
|
expect(rows.find((row) => row.name === '支腿')).toMatchObject({
|
||||||
|
quantity: 4,
|
||||||
|
dimensions: { x: 50, y: 50, z: 750 },
|
||||||
|
})
|
||||||
|
expect(rows.find((row) => row.name === '台面板')).toMatchObject({
|
||||||
|
quantity: 1,
|
||||||
|
dimensions: { x: 1200, y: 600, z: 25 },
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
reconciled.every(
|
||||||
|
(body) =>
|
||||||
|
body.dimensionSource === 'kcl-parameters' &&
|
||||||
|
body.engineDimensions !== undefined &&
|
||||||
|
body.dimensionReferences &&
|
||||||
|
body.dimensionReferences.length >= 2
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test('keeps a square leg end view rectangular', () => {
|
test('keeps a square leg end view rectangular', () => {
|
||||||
const squareLeg: BodyMeasurement = {
|
const squareLeg: BodyMeasurement = {
|
||||||
id: 'square-leg',
|
id: 'square-leg',
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { BoundingBox, ModelingCmd } from '@kittycad/lib'
|
import type { BoundingBox, ModelingCmd } from '@kittycad/lib'
|
||||||
import { getBodiesFromArtifactGraph } from '@src/lang/std/artifactGraph'
|
import { getBodiesFromArtifactGraph } from '@src/lang/std/artifactGraph'
|
||||||
|
import { humanDisplayNumber, type VariableMap } from '@src/lang/wasm'
|
||||||
import { useSingletons } from '@src/lib/boot'
|
import { useSingletons } from '@src/lib/boot'
|
||||||
import { isModelingResponse } from '@src/lib/kcSdkGuards'
|
import { isModelingResponse } from '@src/lib/kcSdkGuards'
|
||||||
import { engineStreamZoomToFit, engineViewIsometric } from '@src/lib/utils'
|
import { engineStreamZoomToFit, engineViewIsometric } from '@src/lib/utils'
|
||||||
@ -15,11 +16,13 @@ import {
|
|||||||
filterManufacturingBodies,
|
filterManufacturingBodies,
|
||||||
getOverallBounds,
|
getOverallBounds,
|
||||||
groupBodiesIntoBom,
|
groupBodiesIntoBom,
|
||||||
|
parseManufacturingLengthInMillimeters,
|
||||||
reconcileManufacturingDimensions,
|
reconcileManufacturingDimensions,
|
||||||
type BodyMeasurement,
|
type BodyMeasurement,
|
||||||
type BomRow,
|
type BomRow,
|
||||||
type CircularEdgeSample,
|
type CircularEdgeSample,
|
||||||
type ExplodeOffset,
|
type ExplodeOffset,
|
||||||
|
type ManufacturingLengthParameter,
|
||||||
} from './manufacturing'
|
} from './manufacturing'
|
||||||
|
|
||||||
type ModelingResponse = {
|
type ModelingResponse = {
|
||||||
@ -41,6 +44,42 @@ type BrandSettings = {
|
|||||||
logoUrl: string
|
logoUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectManufacturingLengthParameters(
|
||||||
|
variables: VariableMap,
|
||||||
|
wasmInstance: Parameters<typeof humanDisplayNumber>[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<T, Result>(
|
||||||
|
values: T[],
|
||||||
|
batchSize: number,
|
||||||
|
worker: (value: T, index: number) => Promise<Result>
|
||||||
|
) {
|
||||||
|
const results: Array<PromiseSettledResult<Result>> = []
|
||||||
|
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) {
|
function downloadText(filename: string, text: string, type: string) {
|
||||||
const blob = new Blob([text], { type })
|
const blob = new Blob([text], { type })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@ -202,6 +241,12 @@ export function HicadManufacturingPanel({
|
|||||||
}
|
}
|
||||||
if (response !== null && response !== undefined) {
|
if (response !== null && response !== undefined) {
|
||||||
const message = engineFailureMessage(response)
|
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', {
|
console.error('[HiCAD manufacturing] Engine command failed', {
|
||||||
command: cmd.type,
|
command: cmd.type,
|
||||||
message,
|
message,
|
||||||
@ -262,8 +307,10 @@ export function HicadManufacturingPanel({
|
|||||||
const childIds = childResponse.data.entity_ids.filter(
|
const childIds = childResponse.data.entity_ids.filter(
|
||||||
(id): id is string => typeof id === 'string'
|
(id): id is string => typeof id === 'string'
|
||||||
)
|
)
|
||||||
const typedChildren = await Promise.allSettled(
|
const typedChildren = await settleInBatches(
|
||||||
childIds.map(async (entityId) => {
|
childIds,
|
||||||
|
6,
|
||||||
|
async (entityId) => {
|
||||||
const response = await sendModelingCommand({
|
const response = await sendModelingCommand({
|
||||||
type: 'get_entity_type',
|
type: 'get_entity_type',
|
||||||
entity_id: entityId,
|
entity_id: entityId,
|
||||||
@ -277,7 +324,7 @@ export function HicadManufacturingPanel({
|
|||||||
? response.data.entity_type
|
? response.data.entity_type
|
||||||
: undefined
|
: undefined
|
||||||
return { entityId, entityType }
|
return { entityId, entityType }
|
||||||
})
|
}
|
||||||
)
|
)
|
||||||
const curveCandidates = typedChildren.flatMap((result) =>
|
const curveCandidates = typedChildren.flatMap((result) =>
|
||||||
result.status === 'fulfilled' &&
|
result.status === 'fulfilled' &&
|
||||||
@ -286,8 +333,10 @@ export function HicadManufacturingPanel({
|
|||||||
? [result.value]
|
? [result.value]
|
||||||
: []
|
: []
|
||||||
)
|
)
|
||||||
const edgeResults = await Promise.allSettled(
|
const edgeResults = await settleInBatches(
|
||||||
curveCandidates.map(async ({ entityId, entityType }) => {
|
curveCandidates,
|
||||||
|
4,
|
||||||
|
async ({ entityId, entityType }) => {
|
||||||
const typeResponse = await sendModelingCommand({
|
const typeResponse = await sendModelingCommand({
|
||||||
type: 'curve_get_type',
|
type: 'curve_get_type',
|
||||||
curve_id: entityId,
|
curve_id: entityId,
|
||||||
@ -349,7 +398,7 @@ export function HicadManufacturingPanel({
|
|||||||
length = lengthResult.value.data.length
|
length = lengthResult.value.data.length
|
||||||
}
|
}
|
||||||
return { controlPoints, length } satisfies CircularEdgeSample
|
return { controlPoints, length } satisfies CircularEdgeSample
|
||||||
})
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return edgeResults.flatMap((result) =>
|
return edgeResults.flatMap((result) =>
|
||||||
@ -458,12 +507,16 @@ export function HicadManufacturingPanel({
|
|||||||
throw new Error('当前模型还没有可识别的实体')
|
throw new Error('当前模型还没有可识别的实体')
|
||||||
}
|
}
|
||||||
|
|
||||||
const variables = kclManager.variablesSignal.value as Record<
|
const variables = kclManager.variablesSignal.value as VariableMap
|
||||||
string,
|
const wasmInstance = await kclManager.wasmInstancePromise
|
||||||
unknown
|
const lengthParameters = collectManufacturingLengthParameters(
|
||||||
>
|
variables,
|
||||||
const measurementResults = await Promise.allSettled(
|
wasmInstance
|
||||||
bodyIds.map(async (bodyId, index): Promise<BodyMeasurement> => {
|
)
|
||||||
|
const measurementResults = await settleInBatches(
|
||||||
|
bodyIds,
|
||||||
|
3,
|
||||||
|
async (bodyId, index): Promise<BodyMeasurement> => {
|
||||||
const boundingBox = await readBoundingBox([bodyId])
|
const boundingBox = await readBoundingBox([bodyId])
|
||||||
const [volumeResponse, areaResponse] = await Promise.allSettled([
|
const [volumeResponse, areaResponse] = await Promise.allSettled([
|
||||||
sendModelingCommand({
|
sendModelingCommand({
|
||||||
@ -495,7 +548,7 @@ export function HicadManufacturingPanel({
|
|||||||
const circularEdges = await readCircularEdges(bodyId)
|
const circularEdges = await readCircularEdges(bodyId)
|
||||||
measurement.holes = detectPanelHoles(measurement, circularEdges)
|
measurement.holes = detectPanelHoles(measurement, circularEdges)
|
||||||
return measurement
|
return measurement
|
||||||
})
|
}
|
||||||
)
|
)
|
||||||
const measured = measurementResults.flatMap((result) =>
|
const measured = measurementResults.flatMap((result) =>
|
||||||
result.status === 'fulfilled' ? [result.value] : []
|
result.status === 'fulfilled' ? [result.value] : []
|
||||||
@ -535,8 +588,22 @@ export function HicadManufacturingPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const manufacturingBodies = reconcileManufacturingDimensions(
|
const manufacturingBodies = reconcileManufacturingDimensions(
|
||||||
filterManufacturingBodies(measured)
|
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) {
|
if (manufacturingBodies.length < measured.length) {
|
||||||
toast(
|
toast(
|
||||||
`已忽略 ${measured.length - manufacturingBodies.length} 个建模模板实体。`
|
`已忽略 ${measured.length - manufacturingBodies.length} 个建模模板实体。`
|
||||||
@ -662,6 +729,17 @@ export function HicadManufacturingPanel({
|
|||||||
[bodies, overrides]
|
[bodies, overrides]
|
||||||
)
|
)
|
||||||
const overall = useMemo(() => getOverallBounds(bodies), [bodies])
|
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 = (
|
const updateRow = (
|
||||||
row: BomRow,
|
row: BomRow,
|
||||||
@ -915,7 +993,12 @@ export function HicadManufacturingPanel({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-xs text-chalkboard-60 dark:text-chalkboard-40">
|
<p className="mt-3 text-xs text-chalkboard-60 dark:text-chalkboard-40">
|
||||||
尺寸来自模型实体包围盒。材料、工艺、公差和连接方式必须由工程人员投产前复核。
|
{parametricDimensionCount > 0
|
||||||
|
? `制造尺寸优先采用 KCL 已求值参数(${parametricDimensionCount} 个实体)`
|
||||||
|
: volumeCheckedDimensionCount > 0
|
||||||
|
? `制造尺寸已通过实体体积交叉校验(${volumeCheckedDimensionCount} 个实体)`
|
||||||
|
: '当前制造尺寸来自模型实体包围盒'}
|
||||||
|
。材料、工艺、公差和连接方式必须由工程人员投产前复核。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
347
vendor/zoo-modeling-app/manufacturing.ts
vendored
347
vendor/zoo-modeling-app/manufacturing.ts
vendored
@ -22,11 +22,19 @@ export type BodyMeasurement = {
|
|||||||
name: string
|
name: string
|
||||||
center: Point3d
|
center: Point3d
|
||||||
dimensions: Point3d
|
dimensions: Point3d
|
||||||
|
engineDimensions?: Point3d
|
||||||
|
dimensionSource?: 'engine' | 'kcl-parameters' | 'engine-volume-check'
|
||||||
|
dimensionReferences?: string[]
|
||||||
volume?: number
|
volume?: number
|
||||||
surfaceArea?: number
|
surfaceArea?: number
|
||||||
holes?: HoleFeature[]
|
holes?: HoleFeature[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ManufacturingLengthParameter = {
|
||||||
|
name: string
|
||||||
|
valueMm: number
|
||||||
|
}
|
||||||
|
|
||||||
export type ExplodeOffset = {
|
export type ExplodeOffset = {
|
||||||
id: string
|
id: string
|
||||||
offset: Point3d
|
offset: Point3d
|
||||||
@ -60,11 +68,32 @@ export type EngineeringSheetInput = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EPSILON = 1e-6
|
const EPSILON = 1e-6
|
||||||
|
const LENGTH_UNIT_TO_MM: Record<string, number> = {
|
||||||
|
mm: 1,
|
||||||
|
cm: 10,
|
||||||
|
m: 1000,
|
||||||
|
in: 25.4,
|
||||||
|
ft: 304.8,
|
||||||
|
yd: 914.4,
|
||||||
|
}
|
||||||
|
|
||||||
function roundDimension(value: number) {
|
function roundDimension(value: number) {
|
||||||
return Math.round(value * 100) / 100
|
return Math.round(value * 100) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseManufacturingLengthInMillimeters(value: string) {
|
||||||
|
const match = value
|
||||||
|
.trim()
|
||||||
|
.match(/^(-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)\s*(mm|cm|m|in|ft|yd)\b/i)
|
||||||
|
if (!match) return undefined
|
||||||
|
const numericValue = Number(match[1])
|
||||||
|
const multiplier = LENGTH_UNIT_TO_MM[match[2].toLowerCase()]
|
||||||
|
if (!Number.isFinite(numericValue) || multiplier === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return Math.abs(numericValue * multiplier)
|
||||||
|
}
|
||||||
|
|
||||||
function maximumDimension(body: BodyMeasurement) {
|
function maximumDimension(body: BodyMeasurement) {
|
||||||
return Math.max(
|
return Math.max(
|
||||||
Math.abs(body.dimensions.x),
|
Math.abs(body.dimensions.x),
|
||||||
@ -77,6 +106,249 @@ function compactPartName(name: string) {
|
|||||||
return name.trim().replaceAll(/[\s_-]+/g, '').toLowerCase()
|
return name.trim().replaceAll(/[\s_-]+/g, '').toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DimensionRole =
|
||||||
|
| 'length'
|
||||||
|
| 'width'
|
||||||
|
| 'height'
|
||||||
|
| 'thickness'
|
||||||
|
| 'diameter'
|
||||||
|
| 'radius'
|
||||||
|
|
||||||
|
type ClassifiedLengthParameter = ManufacturingLengthParameter & {
|
||||||
|
baseName: string
|
||||||
|
role: DimensionRole
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIMENSION_ROLE_PATTERNS: Array<{
|
||||||
|
role: DimensionRole
|
||||||
|
patterns: RegExp[]
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
role: 'thickness',
|
||||||
|
patterns: [/(?:thickness|thick)$/i, /(?:厚度|板厚|厚)$/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'diameter',
|
||||||
|
patterns: [/(?:diameter|dia)$/i, /(?:直径|外径)$/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'radius',
|
||||||
|
patterns: [/(?:radius|rad)$/i, /(?:半径)$/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'length',
|
||||||
|
patterns: [/(?:length|long|len)$/i, /(?:长度|长)$/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'width',
|
||||||
|
patterns: [/(?:width|wide|depth)$/i, /(?:宽度|深度|宽)$/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'height',
|
||||||
|
patterns: [/(?:height|high)$/i, /(?:高度|高)$/],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function classifyLengthParameter(
|
||||||
|
parameter: ManufacturingLengthParameter
|
||||||
|
): ClassifiedLengthParameter | undefined {
|
||||||
|
const compactName = compactPartName(parameter.name)
|
||||||
|
for (const { role, patterns } of DIMENSION_ROLE_PATTERNS) {
|
||||||
|
const pattern = patterns.find((candidate) => candidate.test(compactName))
|
||||||
|
if (!pattern) continue
|
||||||
|
return {
|
||||||
|
...parameter,
|
||||||
|
baseName: compactName.replace(pattern, ''),
|
||||||
|
role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function partParameterAliases(bodyName: string) {
|
||||||
|
const normalized = compactPartName(normalizePartName(bodyName))
|
||||||
|
const aliases = new Set([normalized])
|
||||||
|
|
||||||
|
if (/(?:tabletop|desktop|worktop|countertop|tablebody|台面|桌面)/.test(normalized)) {
|
||||||
|
;[
|
||||||
|
'tabletop',
|
||||||
|
'table',
|
||||||
|
'desktop',
|
||||||
|
'desk',
|
||||||
|
'worktop',
|
||||||
|
'countertop',
|
||||||
|
'top',
|
||||||
|
'台面',
|
||||||
|
'桌面',
|
||||||
|
'桌',
|
||||||
|
].forEach((alias) => aliases.add(alias))
|
||||||
|
}
|
||||||
|
if (/(?:leg|foot|支腿|桌腿)/.test(normalized)) {
|
||||||
|
;['leg', 'foot', 'tableleg', 'deskleg', '支腿', '桌腿'].forEach((alias) =>
|
||||||
|
aliases.add(alias)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (/(?:stud|bolt|螺柱|螺栓)/.test(normalized)) {
|
||||||
|
;['stud', 'connectorstud', 'bolt', '螺柱', '螺栓'].forEach((alias) =>
|
||||||
|
aliases.add(alias)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (/(?:panel|board|板)/.test(normalized)) {
|
||||||
|
;['panel', 'board', '板'].forEach((alias) => aliases.add(alias))
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...aliases].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parameterAffinity(baseName: string, aliases: string[]) {
|
||||||
|
if (!baseName) return 0
|
||||||
|
let score = 0
|
||||||
|
for (const alias of aliases) {
|
||||||
|
if (baseName === alias) score = Math.max(score, 8)
|
||||||
|
else if (baseName.startsWith(alias) || alias.startsWith(baseName)) {
|
||||||
|
score = Math.max(score, Math.min(baseName.length, alias.length) >= 3 ? 6 : 2)
|
||||||
|
} else if (baseName.includes(alias) || alias.includes(baseName)) {
|
||||||
|
score = Math.max(score, Math.min(baseName.length, alias.length) >= 3 ? 4 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestParameterForRole(
|
||||||
|
parameters: ClassifiedLengthParameter[],
|
||||||
|
role: DimensionRole,
|
||||||
|
aliases: string[]
|
||||||
|
) {
|
||||||
|
const candidates = parameters
|
||||||
|
.filter((parameter) => parameter.role === role)
|
||||||
|
.map((parameter) => ({
|
||||||
|
parameter,
|
||||||
|
score: parameterAffinity(parameter.baseName, aliases),
|
||||||
|
}))
|
||||||
|
.filter(({ score }) => score >= 4)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.score - left.score ||
|
||||||
|
left.parameter.name.localeCompare(right.parameter.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (candidates.length === 0) return undefined
|
||||||
|
if (
|
||||||
|
candidates.length > 1 &&
|
||||||
|
candidates[0].score === candidates[1].score &&
|
||||||
|
candidates[0].parameter.valueMm !== candidates[1].parameter.valueMm
|
||||||
|
) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return candidates[0].parameter
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSortedDimensions(
|
||||||
|
engineDimensions: Point3d,
|
||||||
|
desiredAscending: [number, number, number]
|
||||||
|
) {
|
||||||
|
const axes = (['x', 'y', 'z'] as Array<keyof Point3d>).sort(
|
||||||
|
(left, right) =>
|
||||||
|
Math.abs(engineDimensions[left]) - Math.abs(engineDimensions[right])
|
||||||
|
)
|
||||||
|
const next = { ...engineDimensions }
|
||||||
|
axes.forEach((axis, index) => {
|
||||||
|
next[axis] = desiredAscending[index]
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferParametricDimensions(
|
||||||
|
body: BodyMeasurement,
|
||||||
|
parameters: ClassifiedLengthParameter[]
|
||||||
|
) {
|
||||||
|
const aliases = partParameterAliases(body.name)
|
||||||
|
const length = bestParameterForRole(parameters, 'length', aliases)
|
||||||
|
const width = bestParameterForRole(parameters, 'width', aliases)
|
||||||
|
const height = bestParameterForRole(parameters, 'height', aliases)
|
||||||
|
const thickness = bestParameterForRole(parameters, 'thickness', aliases)
|
||||||
|
const diameter = bestParameterForRole(parameters, 'diameter', aliases)
|
||||||
|
const radius = bestParameterForRole(parameters, 'radius', aliases)
|
||||||
|
const resolvedDiameter =
|
||||||
|
diameter ??
|
||||||
|
(radius
|
||||||
|
? {
|
||||||
|
...radius,
|
||||||
|
name: `${radius.name}×2`,
|
||||||
|
valueMm: radius.valueMm * 2,
|
||||||
|
}
|
||||||
|
: undefined)
|
||||||
|
const values = (['x', 'y', 'z'] as const)
|
||||||
|
.map((axis) => Math.abs(body.dimensions[axis]))
|
||||||
|
.sort((left, right) => left - right)
|
||||||
|
const normalizedName = compactPartName(normalizePartName(body.name))
|
||||||
|
const looksLikePanel =
|
||||||
|
/(panel|board|tabletop|desktop|worktop|countertop|top|seat|shelf|板)/.test(
|
||||||
|
normalizedName
|
||||||
|
) || (values[0] / Math.max(values[1], EPSILON) < 0.35 &&
|
||||||
|
values[1] / Math.max(values[2], EPSILON) > 0.25)
|
||||||
|
const looksLikeRod =
|
||||||
|
/(leg|foot|stud|dowel|pin|bolt|支腿|桌腿|螺柱|螺栓)/.test(normalizedName) ||
|
||||||
|
values[2] / Math.max(values[1], EPSILON) > 2
|
||||||
|
|
||||||
|
if (looksLikePanel && length && width && thickness) {
|
||||||
|
const desired = [
|
||||||
|
Math.abs(thickness.valueMm),
|
||||||
|
Math.min(Math.abs(length.valueMm), Math.abs(width.valueMm)),
|
||||||
|
Math.max(Math.abs(length.valueMm), Math.abs(width.valueMm)),
|
||||||
|
] as [number, number, number]
|
||||||
|
return {
|
||||||
|
dimensions: mapSortedDimensions(body.dimensions, desired),
|
||||||
|
references: [length.name, width.name, thickness.name],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (looksLikeRod && height && resolvedDiameter) {
|
||||||
|
const desired = [
|
||||||
|
Math.abs(resolvedDiameter.valueMm),
|
||||||
|
Math.abs(resolvedDiameter.valueMm),
|
||||||
|
Math.abs(height.valueMm),
|
||||||
|
].sort((left, right) => left - right) as [number, number, number]
|
||||||
|
return {
|
||||||
|
dimensions: mapSortedDimensions(body.dimensions, desired),
|
||||||
|
references: [height.name, resolvedDiameter.name],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (looksLikeRod && height && thickness) {
|
||||||
|
const desired = [
|
||||||
|
Math.abs(thickness.valueMm),
|
||||||
|
Math.abs(thickness.valueMm),
|
||||||
|
Math.abs(height.valueMm),
|
||||||
|
].sort((left, right) => left - right) as [number, number, number]
|
||||||
|
return {
|
||||||
|
dimensions: mapSortedDimensions(body.dimensions, desired),
|
||||||
|
references: [height.name, thickness.name],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length && width && height) {
|
||||||
|
const desired = [
|
||||||
|
Math.abs(length.valueMm),
|
||||||
|
Math.abs(width.valueMm),
|
||||||
|
Math.abs(height.valueMm),
|
||||||
|
].sort((left, right) => left - right) as [number, number, number]
|
||||||
|
return {
|
||||||
|
dimensions: mapSortedDimensions(body.dimensions, desired),
|
||||||
|
references: [length.name, width.name, height.name],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimensionsDiffer(left: Point3d, right: Point3d, tolerance = 0.02) {
|
||||||
|
return (['x', 'y', 'z'] as const).some((axis) => {
|
||||||
|
const baseline = Math.max(Math.abs(right[axis]), EPSILON)
|
||||||
|
return Math.abs(left[axis] - right[axis]) / baseline > tolerance
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function isKnownTemplateBody(body: BodyMeasurement) {
|
function isKnownTemplateBody(body: BodyMeasurement) {
|
||||||
return /^unit(?:box|cube|cylinder[xyz]?|righttriangle[xyz]?)$/.test(
|
return /^unit(?:box|cube|cylinder[xyz]?|righttriangle[xyz]?)$/.test(
|
||||||
compactPartName(body.name)
|
compactPartName(body.name)
|
||||||
@ -545,8 +817,21 @@ function snapCorrectedDimension(value: number) {
|
|||||||
* hollow or filleted part.
|
* hollow or filleted part.
|
||||||
*/
|
*/
|
||||||
export function reconcileManufacturingDimensions(
|
export function reconcileManufacturingDimensions(
|
||||||
bodies: BodyMeasurement[]
|
bodies: BodyMeasurement[],
|
||||||
|
lengthParameters: ManufacturingLengthParameter[] = []
|
||||||
): BodyMeasurement[] {
|
): BodyMeasurement[] {
|
||||||
|
const classifiedParameters = lengthParameters
|
||||||
|
.filter(
|
||||||
|
(parameter) =>
|
||||||
|
parameter.name.trim() &&
|
||||||
|
Number.isFinite(parameter.valueMm) &&
|
||||||
|
Math.abs(parameter.valueMm) > EPSILON
|
||||||
|
)
|
||||||
|
.map(classifyLengthParameter)
|
||||||
|
.filter(
|
||||||
|
(parameter): parameter is ClassifiedLengthParameter =>
|
||||||
|
parameter !== undefined
|
||||||
|
)
|
||||||
const groups = new Map<string, BodyMeasurement[]>()
|
const groups = new Map<string, BodyMeasurement[]>()
|
||||||
for (const body of bodies) {
|
for (const body of bodies) {
|
||||||
const key = correctionGroupKey(body)
|
const key = correctionGroupKey(body)
|
||||||
@ -566,36 +851,66 @@ export function reconcileManufacturingDimensions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.size < 2) return bodies
|
let consensusScale: number | undefined
|
||||||
|
if (candidates.size >= 2) {
|
||||||
const consensusScale = median(
|
const proposedScale = median(
|
||||||
[...candidates.values()].map((candidate) => candidate.scale)
|
[...candidates.values()].map((candidate) => candidate.scale)
|
||||||
)
|
)
|
||||||
const supported = [...candidates.values()].filter(
|
const supported = [...candidates.values()].filter(
|
||||||
(candidate) => Math.abs(candidate.scale - consensusScale) <= 0.055
|
(candidate) => Math.abs(candidate.scale - proposedScale) <= 0.055
|
||||||
)
|
)
|
||||||
if (supported.length < 2) return bodies
|
if (supported.length >= 2) {
|
||||||
|
consensusScale = proposedScale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return bodies.map((body) => {
|
return bodies.map((body) => {
|
||||||
|
const inferred = inferParametricDimensions(body, classifiedParameters)
|
||||||
|
if (inferred) {
|
||||||
|
const corrected = {
|
||||||
|
x: snapCorrectedDimension(inferred.dimensions.x),
|
||||||
|
y: snapCorrectedDimension(inferred.dimensions.y),
|
||||||
|
z: snapCorrectedDimension(inferred.dimensions.z),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
dimensions: corrected,
|
||||||
|
engineDimensions: dimensionsDiffer(corrected, body.dimensions)
|
||||||
|
? body.dimensions
|
||||||
|
: body.engineDimensions,
|
||||||
|
dimensionSource: 'kcl-parameters',
|
||||||
|
dimensionReferences: inferred.references,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const candidate = candidates.get(correctionGroupKey(body))
|
const candidate = candidates.get(correctionGroupKey(body))
|
||||||
if (
|
if (
|
||||||
|
consensusScale === undefined ||
|
||||||
!candidate ||
|
!candidate ||
|
||||||
Math.abs(candidate.scale - consensusScale) > 0.055
|
Math.abs(candidate.scale - consensusScale) > 0.055
|
||||||
) {
|
) {
|
||||||
return body
|
return {
|
||||||
|
...body,
|
||||||
|
dimensionSource: body.dimensionSource ?? 'engine',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const corrected = { ...body.dimensions }
|
const corrected = { ...body.dimensions }
|
||||||
for (const axis of candidate.axes) {
|
for (const axis of candidate.axes) {
|
||||||
corrected[axis] *= consensusScale
|
corrected[axis] *= consensusScale
|
||||||
}
|
}
|
||||||
|
const normalized = {
|
||||||
|
x: snapCorrectedDimension(corrected.x),
|
||||||
|
y: snapCorrectedDimension(corrected.y),
|
||||||
|
z: snapCorrectedDimension(corrected.z),
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...body,
|
...body,
|
||||||
dimensions: {
|
dimensions: normalized,
|
||||||
x: snapCorrectedDimension(corrected.x),
|
engineDimensions: dimensionsDiffer(normalized, body.dimensions)
|
||||||
y: snapCorrectedDimension(corrected.y),
|
? body.dimensions
|
||||||
z: snapCorrectedDimension(corrected.z),
|
: body.engineDimensions,
|
||||||
},
|
dimensionSource: 'engine-volume-check',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user