feat: 支持教学图鉴

This commit is contained in:
richarjiang
2026-10-18 17:26:16 +08:00
parent 3635b2a906
commit 3dc56a113b
29 changed files with 3056 additions and 5 deletions

View File

@@ -0,0 +1,156 @@
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', '固定螺钉',
]