Files
mp-pilates/education/reformer/src/geometry.ts
2026-10-18 17:26:16 +08:00

157 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as k from '../vendor/anatomy/iso-kit.mjs'
import * as g from '../vendor/anatomy/lathe.mjs'
import { reformerLesson, type ReformerPartId } from '../../../packages/app/src/content/reformer'
// Drawing units only. These dimensions are NOT equipment specifications.
export const drawing = Object.freeze({ travel: 125, anchorX: -223, carriageAnchorX: -140, springZ: 82, springYs: [-36, -18, 0, 18, 36] })
export const projection = { origin: [380, 261], scale: 0.91, azimuth: 66, elevation: 30 }
export const explodeMoves: Record<ReformerPartId, number[]> = {
frame: [0, 0, 0], rails: [0, 0, 22], carriage: [0, 0, 90], springs: [0, 0, 47],
hooks: [0, 0, 47], footbar: [-22, 0, 26], ropes: [0, 0, 0], pulleys: [18, 0, 22], headrest: [0, 0, 117],
}
const S = (shape: ReturnType<typeof k.slabOf>, material = 'steel') => `<g class="material-${material}">${k.solidSvg(shape)}</g>`
const slab = (x: number, y: number, w: number, d: number, z: number, h: number, material: string, r = 2) => S(k.slabOf({ x, y, w, d, r }, z, h, projection, 4, 0.6), material)
const cylinder = (x: number, y: number, radius: number, z: number, h: number, material: string) => S(k.cylinder(x, y, radius, z, h, projection, 20, 0.4), material)
const rod = (a: [number, number, number], b: [number, number, number], r: number, material = 'steel') => {
const vector: [number, number, number] = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], length = Math.hypot(...vector)
return S(g.disc(0, length, r, g.frameAlong(a, vector), projection, { steps: 20 }), material)
}
const seam = (plan: { x: number; y: number; w: number; d: number; r: number }, z: number) => k.lineSvg(k.planOutline(plan, z, projection), { tone: 'faint' })
const screws = (points: number[][]) => k.dotsSvg(points.map(p => k.iso(p as [number, number, number], projection)), { size: 1, tone: 'hi' })
const wrap = (id: ReformerPartId, content: string, extra = '') => `<g data-component="${id}" ${extra} class="component">${content}</g>`
export function springEndpoints(position: number, connected: boolean) {
const end = drawing.carriageAnchorX + position * drawing.travel
const start = connected ? drawing.anchorX : end - 70
return { start, end }
}
export function springPaths(position: number, index: number, connected: boolean, explode = 0) {
const { start, end } = springEndpoints(position, connected)
return k.coil(start + 7, end - 7, [drawing.springYs[index], drawing.springZ + explode * 47], 3.1, 17, 'x', projection)
}
export function hookPath(position: number, index: number, connected: boolean, explode = 0) {
const { start, end } = springEndpoints(position, connected)
const y = drawing.springYs[index], z = drawing.springZ + explode * 47
// Closed loops and straight necks meet the spring ends; no free-floating leaders.
return k.ring(start, y, 3.1, z, projection, 16) + k.segment([start + 3, y, z], [start + 7, y, z], projection)
+ k.segment([end - 7, y, z], [end - 3, y, z], projection) + k.ring(end, y, 3.1, z, projection, 16)
}
export function ropeRoutes(position: number, explode = 0) {
const paths: number[][][] = []
for (const side of [-1, 1]) {
const x = 66 + position * drawing.travel, y = side * 51
const anchor = [x + 16, y, 110 + explode * 90]
const pulley = [249 + explode * 18, side * 63, 151 + explode * 22]
const lower = [pulley[0], pulley[1], pulley[2] - 9], upper = [pulley[0], pulley[1], pulley[2] + 9]
const arc = Array.from({ length: 25 }, (_, i) => {
const angle = i / 24 * Math.PI
return [pulley[0] + Math.sin(angle) * 9, pulley[1], pulley[2] - Math.cos(angle) * 9]
})
const loop = [47 + position * drawing.travel + 7, side * 35, 130 + explode * 117]
// Parked loops: the two legs of each rope slacken as the carriage approaches the risers.
// Numerical bisection keeps their total centreline length constant within this drawing model.
const sample = (a: number[], b: number[], sag: number) => Array.from({ length: 25 }, (_, i) => {
const t = i / 24
// Slack bows out beside the frame, rather than cutting down through the bed.
return a.map((v, j) => v + (b[j] - v) * t + (j === 1 ? side * 4 * sag * t * (1 - t) : 0))
})
const length = (points: number[][]) => points.slice(1).reduce((sum, point, i) => sum + Math.hypot(...point.map((v, j) => v - points[i][j])), 0)
// Exploded pose separates assemblies conceptually; only the assembled rope conserves length.
const target = 430
let lo = 0, hi = 180
for (let j = 0; j < 12; j++) {
const mid = (lo + hi) / 2
if (length(sample(anchor, lower, mid)) + length(arc) + length(sample(upper, loop, mid)) < target) lo = mid
else hi = mid
}
const sag = (lo + hi) / 2 * (1 - explode * 0.75)
paths.push([...sample(anchor, lower, sag), ...arc.slice(1), ...sample(upper, loop, sag).slice(1)])
}
return paths
}
export const ropePaths = (position: number, explode = 0) => ropeRoutes(position, explode).map(points => k.pathOf(points.map(p => k.iso(p as [number, number, number], projection))))
export function createFigure() {
const back: string[] = [], front: string[] = [], carriage: string[] = [], head: string[] = []
// Four legs, pads, corner plates and fasteners.
for (const x of [-249, 230]) for (const y of [-77, 59]) {
const parts = slab(x, y, 20, 20, 6, 44, 'wood') + slab(x - 1, y - 1, 22, 22, 1, 5, 'rubber')
+ slab(x - 1, y - 1, 22, 22, 45, 5, 'wood')
;(y < 0 ? back : front).push(parts)
}
back.push(slab(-261, -83, 522, 15, 48, 26, 'wood', 4))
back.push(slab(246, -68, 15, 136, 48, 26, 'wood', 3))
front.push(slab(-261, 68, 522, 15, 48, 26, 'wood', 4))
front.push(slab(-261, -68, 15, 136, 48, 26, 'wood', 3))
const frameScrews = [-249, 249].flatMap(x => [-76, 76].map(y => [x, y, 74]))
front.push(screws(frameScrews))
const rails = [-66, 59].map(y => slab(-244, y, 488, 7, 66, 5, 'steel', 1)
+ screws([-228, 224].map(x => [x, y + 3.5, 71]))).join('')
+ [-240, 232].map(x => slab(x, -63, 8, 126, 71, 5, 'rubber')).join('')
// Four weight-bearing rollers plus four side guide wheels, attached to the bed plate.
for (const x of [-122, 75]) for (const y of [-59, 59]) {
carriage.push(slab(x - 7, y - 6, 14, 12, 84, 4, 'steel'))
carriage.push(rod([x, y - 3, 79], [x, y + 3, 79], 8, 'rubber'))
carriage.push(cylinder(x, y > 0 ? y + 5 : y - 5, 4, 77, 5, 'rubber'))
}
carriage.push(slab(-144, -43, 8, 86, 79, 8, 'steel'))
carriage.push(slab(-146, -57, 244, 114, 87, 6, 'steel', 5))
carriage.push(slab(-147, -56, 244, 112, 93, 14, 'upholstery', 8))
carriage.push(seam({ x: -143, y: -52, w: 236, d: 104, r: 7 }, 107))
for (const side of [-1, 1]) {
carriage.push(slab(78, side * 51 - 3, 8, 6, 107, 3, 'steel', 1))
carriage.push(cylinder(82, side * 51, 1.5, 110, 2, 'steel'))
}
// Carriage spring anchor crossmember is under the upholstery, not on top of it.
head.push(slab(44, -23, 52, 46, 107, 7, 'upholstery', 5))
for (const y of [-43, 26]) {
head.push(slab(20, y, 23, 17, 107, 4, 'steel', 3))
head.push(slab(21, y + 1, 21, 15, 111, 24, 'upholstery', 5))
head.push(cylinder(47, y < 0 ? -35 : 35, 2.2, 107, 23, 'steel'))
head.push(k.lineSvg(k.ring(47, y < 0 ? -35 : 35, 7, 130, projection, 24), { tone: 'mid' }))
}
const springBar = slab(-226, -48, 7, 96, 77, 8, 'steel')
+ drawing.springYs.map(y => cylinder(-223, y, 1.8, 85, 3, 'steel')).join('')
const springs = reformerLesson.springs.map((spring, i) => {
const connected = i === 0 || i === 3, path = springPaths(0, i, connected)
return `<g data-spring="${spring.id}" style="--spring-color:${spring.color};opacity:${connected ? 1 : .38}"><path class="spring-shadow" data-coil-back="${i}" d="${path.back}"/><path class="spring-wire" data-coil="${i}" d="${path.front}"/></g>`
}).join('')
const hooks = drawing.springYs.map((_, i) => `<path data-hook="${i}" class="hardware-line" d="${hookPath(0, i, i === 0 || i === 3)}"/>`).join('')
let footbar = ''
for (const y of [-75, 75]) {
footbar += slab(-238, y - 4, 30, 8, 74, 7, 'steel')
+ rod([-226, y, 80], [-194, y, 147], 3.4)
+ rod([-214, y, 82], [-202, y, 130], 2.5)
+ rod([-226, y - 2, 80], [-226, y + 2, 80], 6)
}
footbar += rod([-194, -75, 147], [-194, 75, 147], 6, 'rubber')
let pulleys = ''
for (const y of [-63, 63]) {
pulleys += slab(241, y - 8, 16, 16, 74, 8, 'steel')
+ cylinder(249, y, 3, 82, 67, 'steel')
+ rod([249, y - 3, 151], [249, y + 3, 151], 9, 'rubber')
+ rod([249, y - 4, 151], [249, y + 4, 151], 3, 'steel')
}
const ropes = ropePaths(0).map((d, i) => `<path class="rope" data-rope="${i}" d="${d}"/>`).join('')
const body = `<ellipse class="ground-shadow" cx="375" cy="345" rx="268" ry="46"/>
${wrap('frame', back.join(''), 'data-layer="frame-back"')}
${wrap('rails', rails)}${wrap('pulleys', pulleys)}
<g data-spring-assembly="">${wrap('hooks', springBar, 'data-spring-bar=""')}${wrap('springs', springs)}${wrap('hooks', hooks)}</g>
${wrap('ropes', ropes)}${wrap('carriage', carriage.join(''), 'data-carriage=""')}
${wrap('headrest', head.join(''), 'data-headrest=""')}
${wrap('frame', front.join(''), 'data-layer="frame-front"')}${wrap('footbar', footbar)}`
return `<svg xmlns="http://www.w3.org/2000/svg" class="reformer-svg" viewBox="0 0 760 475" role="img" aria-labelledby="machine-title machine-description"><title id="machine-title">核心床的等轴测结构图</title><desc id="machine-description">脚踏端在左侧,头端在右侧。床板沿两条滑轨移动,弹簧连接床板下方与脚踏端弹簧杆。图形尺寸、行程和绳索外观均为教学示意。</desc>${body}</svg>`
}
export const partsInventory = [
'木质长边框 ×2', '端部横梁 ×2', '支脚 ×4', '防滑脚垫 ×4', '角部承托 ×4',
'滑轨 ×2', '端部限位 ×2', '承重滚轮 ×4', '侧向导轮 ×4', '轮轴支架 ×4',
'床板底托', '软垫', '床板弹簧横梁', '头枕', '肩靠软垫 ×2', '肩靠底座 ×2', '拉环挂柱 ×2', '拉环 ×2',
'弹簧杆', '连接柱 ×5', '弹簧 ×5', '弹簧端环 ×10', '脚踏杆支臂 ×2', '支撑臂 ×2', '铰接轴 ×2', '脚踏软包',
'滑轮立柱底座 ×2', '立柱 ×2', '滑轮 ×2', '滑轮轴 ×2', '绳索 ×2', '固定螺钉',
]