fix: stabilize HiCAD manufacturing dimensions
This commit is contained in:
parent
d2123de778
commit
ce1e101ffc
@ -11,9 +11,11 @@ import {
|
||||
getOverallBounds,
|
||||
groupBodiesIntoBom,
|
||||
localizePartName,
|
||||
parseManufacturingLengthInMillimeters,
|
||||
reconcileManufacturingDimensions,
|
||||
type BodyMeasurement,
|
||||
type CircularEdgeSample,
|
||||
type ManufacturingLengthParameter,
|
||||
} from '../../vendor/zoo-modeling-app/manufacturing'
|
||||
|
||||
const bodies: BodyMeasurement[] = [
|
||||
@ -202,6 +204,17 @@ test.describe('HiCAD manufacturing extension', () => {
|
||||
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', () => {
|
||||
const rows = groupBodiesIntoBom(bodies)
|
||||
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', () => {
|
||||
const squareLeg: BodyMeasurement = {
|
||||
id: 'square-leg',
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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'
|
||||
@ -15,11 +16,13 @@ import {
|
||||
filterManufacturingBodies,
|
||||
getOverallBounds,
|
||||
groupBodiesIntoBom,
|
||||
parseManufacturingLengthInMillimeters,
|
||||
reconcileManufacturingDimensions,
|
||||
type BodyMeasurement,
|
||||
type BomRow,
|
||||
type CircularEdgeSample,
|
||||
type ExplodeOffset,
|
||||
type ManufacturingLengthParameter,
|
||||
} from './manufacturing'
|
||||
|
||||
type ModelingResponse = {
|
||||
@ -41,6 +44,42 @@ type BrandSettings = {
|
||||
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) {
|
||||
const blob = new Blob([text], { type })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@ -202,6 +241,12 @@ export function HicadManufacturingPanel({
|
||||
}
|
||||
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,
|
||||
@ -262,8 +307,10 @@ export function HicadManufacturingPanel({
|
||||
const childIds = childResponse.data.entity_ids.filter(
|
||||
(id): id is string => typeof id === 'string'
|
||||
)
|
||||
const typedChildren = await Promise.allSettled(
|
||||
childIds.map(async (entityId) => {
|
||||
const typedChildren = await settleInBatches(
|
||||
childIds,
|
||||
6,
|
||||
async (entityId) => {
|
||||
const response = await sendModelingCommand({
|
||||
type: 'get_entity_type',
|
||||
entity_id: entityId,
|
||||
@ -277,7 +324,7 @@ export function HicadManufacturingPanel({
|
||||
? response.data.entity_type
|
||||
: undefined
|
||||
return { entityId, entityType }
|
||||
})
|
||||
}
|
||||
)
|
||||
const curveCandidates = typedChildren.flatMap((result) =>
|
||||
result.status === 'fulfilled' &&
|
||||
@ -286,8 +333,10 @@ export function HicadManufacturingPanel({
|
||||
? [result.value]
|
||||
: []
|
||||
)
|
||||
const edgeResults = await Promise.allSettled(
|
||||
curveCandidates.map(async ({ entityId, entityType }) => {
|
||||
const edgeResults = await settleInBatches(
|
||||
curveCandidates,
|
||||
4,
|
||||
async ({ entityId, entityType }) => {
|
||||
const typeResponse = await sendModelingCommand({
|
||||
type: 'curve_get_type',
|
||||
curve_id: entityId,
|
||||
@ -349,7 +398,7 @@ export function HicadManufacturingPanel({
|
||||
length = lengthResult.value.data.length
|
||||
}
|
||||
return { controlPoints, length } satisfies CircularEdgeSample
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return edgeResults.flatMap((result) =>
|
||||
@ -458,12 +507,16 @@ export function HicadManufacturingPanel({
|
||||
throw new Error('当前模型还没有可识别的实体')
|
||||
}
|
||||
|
||||
const variables = kclManager.variablesSignal.value as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const measurementResults = await Promise.allSettled(
|
||||
bodyIds.map(async (bodyId, index): Promise<BodyMeasurement> => {
|
||||
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<BodyMeasurement> => {
|
||||
const boundingBox = await readBoundingBox([bodyId])
|
||||
const [volumeResponse, areaResponse] = await Promise.allSettled([
|
||||
sendModelingCommand({
|
||||
@ -495,7 +548,7 @@ export function HicadManufacturingPanel({
|
||||
const circularEdges = await readCircularEdges(bodyId)
|
||||
measurement.holes = detectPanelHoles(measurement, circularEdges)
|
||||
return measurement
|
||||
})
|
||||
}
|
||||
)
|
||||
const measured = measurementResults.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? [result.value] : []
|
||||
@ -535,8 +588,22 @@ export function HicadManufacturingPanel({
|
||||
}
|
||||
|
||||
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) {
|
||||
toast(
|
||||
`已忽略 ${measured.length - manufacturingBodies.length} 个建模模板实体。`
|
||||
@ -662,6 +729,17 @@ export function HicadManufacturingPanel({
|
||||
[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,
|
||||
@ -915,7 +993,12 @@ export function HicadManufacturingPanel({
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-chalkboard-60 dark:text-chalkboard-40">
|
||||
尺寸来自模型实体包围盒。材料、工艺、公差和连接方式必须由工程人员投产前复核。
|
||||
{parametricDimensionCount > 0
|
||||
? `制造尺寸优先采用 KCL 已求值参数(${parametricDimensionCount} 个实体)`
|
||||
: volumeCheckedDimensionCount > 0
|
||||
? `制造尺寸已通过实体体积交叉校验(${volumeCheckedDimensionCount} 个实体)`
|
||||
: '当前制造尺寸来自模型实体包围盒'}
|
||||
。材料、工艺、公差和连接方式必须由工程人员投产前复核。
|
||||
</p>
|
||||
</section>
|
||||
</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
|
||||
center: Point3d
|
||||
dimensions: Point3d
|
||||
engineDimensions?: Point3d
|
||||
dimensionSource?: 'engine' | 'kcl-parameters' | 'engine-volume-check'
|
||||
dimensionReferences?: string[]
|
||||
volume?: number
|
||||
surfaceArea?: number
|
||||
holes?: HoleFeature[]
|
||||
}
|
||||
|
||||
export type ManufacturingLengthParameter = {
|
||||
name: string
|
||||
valueMm: number
|
||||
}
|
||||
|
||||
export type ExplodeOffset = {
|
||||
id: string
|
||||
offset: Point3d
|
||||
@ -60,11 +68,32 @@ export type EngineeringSheetInput = {
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
return Math.max(
|
||||
Math.abs(body.dimensions.x),
|
||||
@ -77,6 +106,249 @@ function compactPartName(name: string) {
|
||||
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) {
|
||||
return /^unit(?:box|cube|cylinder[xyz]?|righttriangle[xyz]?)$/.test(
|
||||
compactPartName(body.name)
|
||||
@ -545,8 +817,21 @@ function snapCorrectedDimension(value: number) {
|
||||
* hollow or filleted part.
|
||||
*/
|
||||
export function reconcileManufacturingDimensions(
|
||||
bodies: BodyMeasurement[]
|
||||
bodies: BodyMeasurement[],
|
||||
lengthParameters: ManufacturingLengthParameter[] = []
|
||||
): 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[]>()
|
||||
for (const body of bodies) {
|
||||
const key = correctionGroupKey(body)
|
||||
@ -566,36 +851,66 @@ export function reconcileManufacturingDimensions(
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.size < 2) return bodies
|
||||
|
||||
const consensusScale = median(
|
||||
[...candidates.values()].map((candidate) => candidate.scale)
|
||||
)
|
||||
const supported = [...candidates.values()].filter(
|
||||
(candidate) => Math.abs(candidate.scale - consensusScale) <= 0.055
|
||||
)
|
||||
if (supported.length < 2) return bodies
|
||||
let consensusScale: number | undefined
|
||||
if (candidates.size >= 2) {
|
||||
const proposedScale = median(
|
||||
[...candidates.values()].map((candidate) => candidate.scale)
|
||||
)
|
||||
const supported = [...candidates.values()].filter(
|
||||
(candidate) => Math.abs(candidate.scale - proposedScale) <= 0.055
|
||||
)
|
||||
if (supported.length >= 2) {
|
||||
consensusScale = proposedScale
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
if (
|
||||
consensusScale === undefined ||
|
||||
!candidate ||
|
||||
Math.abs(candidate.scale - consensusScale) > 0.055
|
||||
) {
|
||||
return body
|
||||
return {
|
||||
...body,
|
||||
dimensionSource: body.dimensionSource ?? 'engine',
|
||||
}
|
||||
}
|
||||
|
||||
const corrected = { ...body.dimensions }
|
||||
for (const axis of candidate.axes) {
|
||||
corrected[axis] *= consensusScale
|
||||
}
|
||||
const normalized = {
|
||||
x: snapCorrectedDimension(corrected.x),
|
||||
y: snapCorrectedDimension(corrected.y),
|
||||
z: snapCorrectedDimension(corrected.z),
|
||||
}
|
||||
return {
|
||||
...body,
|
||||
dimensions: {
|
||||
x: snapCorrectedDimension(corrected.x),
|
||||
y: snapCorrectedDimension(corrected.y),
|
||||
z: snapCorrectedDimension(corrected.z),
|
||||
},
|
||||
dimensions: normalized,
|
||||
engineDimensions: dimensionsDiffer(normalized, body.dimensions)
|
||||
? body.dimensions
|
||||
: body.engineDimensions,
|
||||
dimensionSource: 'engine-volume-check',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user