282 lines
11 KiB
JavaScript
282 lines
11 KiB
JavaScript
const Z_AXIS = [0, 0, 1];
|
|
const FLAT_ON_GROUND_TRANSFORM = [
|
|
0, 0, -1, 0,
|
|
0, 1, 0, 0,
|
|
1, 0, 0, -322.461928,
|
|
0, 0, 0, 1
|
|
];
|
|
|
|
// CAD coordinates measured from the STEP occurrence bounds. The side-view
|
|
// mechanism is a toggle four-bar: the lower pink rocker drives the yellow/red
|
|
// panel coupler out to an over-center tangent pose, then the coupler returns on
|
|
// the opposite branch with the panel flipped.
|
|
const PIVOTS = {
|
|
upperFrame: [-15.002, 229.609, 0],
|
|
lowerFrame: [104.981, 29.599, 0],
|
|
lowerBoard: [45.03, 229.61, 0],
|
|
upperBoard: [44.984, 420.401, 0]
|
|
};
|
|
|
|
const PANEL_COUPLER_PARTS = ["panel", "redLink", "driveRod", "hingeRod", "panelHardware"];
|
|
const LOWER_ROCKER_PARTS = ["rocker2", "greenRod", "handleHardware"];
|
|
const FRAME_DETAIL_PARTS = ["stopper", "bearings"];
|
|
const FRAME_PARTS = ["frame", ...FRAME_DETAIL_PARTS, "frameHardware"];
|
|
const HARDWARE = ["frameHardware", "handleHardware", "panelHardware"];
|
|
const CROSS_RODS = ["hingeRod", "driveRod"];
|
|
const MOVING_PARTS = ["rockerBlue", ...PANEL_COUPLER_PARTS, ...LOWER_ROCKER_PARTS];
|
|
|
|
const INITIAL_PANEL_ANGLE_DEG = angleDeg2(PIVOTS.lowerBoard, PIVOTS.upperBoard);
|
|
const INITIAL_BLUE_ANGLE_DEG = angleDeg2(PIVOTS.upperFrame, PIVOTS.upperBoard);
|
|
const INITIAL_LOWER_ROCKER_ANGLE_DEG = angleDeg2(PIVOTS.lowerFrame, PIVOTS.lowerBoard);
|
|
const LOWER_LINK_LENGTH = distance2(PIVOTS.lowerFrame, PIVOTS.lowerBoard);
|
|
const UPPER_LINK_LENGTH = distance2(PIVOTS.upperFrame, PIVOTS.upperBoard);
|
|
const COUPLER_LINK_LENGTH = distance2(PIVOTS.lowerBoard, PIVOTS.upperBoard);
|
|
const OVER_CENTER_CRANK_ANGLE_DEG = -3.22068;
|
|
|
|
function finite(value, fallback = 0) {
|
|
const numericValue = Number(value);
|
|
return Number.isFinite(numericValue) ? numericValue : fallback;
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(Math.max(finite(value, min), min), max);
|
|
}
|
|
|
|
function clamp01(value) {
|
|
return clamp(value, 0, 1);
|
|
}
|
|
|
|
function smooth01(value) {
|
|
const t = clamp01(value);
|
|
return t * t * (3 - (2 * t));
|
|
}
|
|
|
|
function lerp(start, end, t) {
|
|
return start + ((end - start) * t);
|
|
}
|
|
|
|
function lerpPoint(start, end, t) {
|
|
return [
|
|
lerp(start[0], end[0], t),
|
|
lerp(start[1], end[1], t),
|
|
lerp(start[2] || 0, end[2] || 0, t)
|
|
];
|
|
}
|
|
|
|
function distance2(start, end) {
|
|
return Math.hypot(end[0] - start[0], end[1] - start[1]);
|
|
}
|
|
|
|
function angleDeg2(start, end) {
|
|
return Math.atan2(end[1] - start[1], end[0] - start[0]) * 180 / Math.PI;
|
|
}
|
|
|
|
function pointFromAngle(origin, length, angleDeg) {
|
|
const angleRad = angleDeg * Math.PI / 180;
|
|
return [
|
|
origin[0] + (Math.cos(angleRad) * length),
|
|
origin[1] + (Math.sin(angleRad) * length),
|
|
origin[2] || 0
|
|
];
|
|
}
|
|
|
|
function circleIntersections(centerA, radiusA, centerB, radiusB) {
|
|
const dx = centerB[0] - centerA[0];
|
|
const dy = centerB[1] - centerA[1];
|
|
const distance = Math.hypot(dx, dy);
|
|
if (distance <= 1e-6) {
|
|
return [centerA, centerA];
|
|
}
|
|
const maxDistance = radiusA + radiusB;
|
|
const minDistance = Math.abs(radiusA - radiusB);
|
|
const solvedDistance = clamp(distance, minDistance + 1e-6, maxDistance - 1e-6);
|
|
const along = ((radiusA * radiusA) - (radiusB * radiusB) + (solvedDistance * solvedDistance)) / (2 * solvedDistance);
|
|
const height = Math.sqrt(Math.max(0, (radiusA * radiusA) - (along * along)));
|
|
const unitX = dx / distance;
|
|
const unitY = dy / distance;
|
|
const midX = centerA[0] + (along * unitX);
|
|
const midY = centerA[1] + (along * unitY);
|
|
const offsetX = -unitY * height;
|
|
const offsetY = unitX * height;
|
|
return [
|
|
[midX + offsetX, midY + offsetY, centerA[2] || 0],
|
|
[midX - offsetX, midY - offsetY, centerA[2] || 0]
|
|
];
|
|
}
|
|
|
|
function boardBranches(lower) {
|
|
const branches = circleIntersections(PIVOTS.upperFrame, UPPER_LINK_LENGTH, lower, COUPLER_LINK_LENGTH);
|
|
return branches.sort((a, b) => b[1] - a[1]);
|
|
}
|
|
|
|
function translateVector(start, end) {
|
|
return [
|
|
end[0] - start[0],
|
|
end[1] - start[1],
|
|
(end[2] || 0) - (start[2] || 0)
|
|
];
|
|
}
|
|
|
|
function cycleFlip(cycle) {
|
|
const phase = ((finite(cycle, 0) % 1) + 1) % 1;
|
|
return 0.5 - (0.5 * Math.cos(phase * Math.PI * 2));
|
|
}
|
|
|
|
function sampleMotion(rawFlip, { extension = 1, turnover = 1 } = {}) {
|
|
const flip = clamp01(rawFlip);
|
|
const travel = clamp(extension, 0.2, 1);
|
|
const turnoverAmount = clamp(turnover, 0, 1);
|
|
const extendedAngle = lerp(INITIAL_LOWER_ROCKER_ANGLE_DEG, OVER_CENTER_CRANK_ANGLE_DEG, travel);
|
|
const firstHalf = flip <= 0.5;
|
|
const legT = firstHalf ? smooth01(flip / 0.5) : smooth01((flip - 0.5) / 0.5);
|
|
const lowerAngleDeg = firstHalf
|
|
? lerp(INITIAL_LOWER_ROCKER_ANGLE_DEG, extendedAngle, legT)
|
|
: lerp(extendedAngle, INITIAL_LOWER_ROCKER_ANGLE_DEG, legT);
|
|
const lower = pointFromAngle(PIVOTS.lowerFrame, LOWER_LINK_LENGTH, lowerAngleDeg);
|
|
const [closedBranch, openBranch] = boardBranches(lower);
|
|
const upper = !firstHalf && turnoverAmount > 0 ? openBranch : closedBranch;
|
|
const angleDeg = angleDeg2(lower, upper);
|
|
return { lower, upper, angleDeg, lowerAngleDeg };
|
|
}
|
|
|
|
function applyVisibility(effects, params) {
|
|
effects.visible("*", true);
|
|
effects.visible("frame", params.showFrame !== false);
|
|
effects.visible(FRAME_DETAIL_PARTS, params.showFrame !== false && params.showDetails !== false);
|
|
effects.visible("frameHardware", params.showFrame !== false && params.showHardware === true);
|
|
effects.visible(["handleHardware", "panelHardware"], params.showHardware === true);
|
|
effects.visible(CROSS_RODS, params.showCrossRods !== false);
|
|
}
|
|
|
|
export default {
|
|
manifest: {
|
|
schemaVersion: 1,
|
|
step: {
|
|
path: "models/thang010146/180_degree_flip_mechanism.step"
|
|
},
|
|
label: "180 degree flip mechanism",
|
|
description: "Planar four-bar viewer rig reconstructed from the reference video: blue upper link, pink lower rocker, red/board coupler, and an over-center turnover.",
|
|
units: {
|
|
length: "mm",
|
|
angle: "deg",
|
|
time: "s"
|
|
},
|
|
features: {
|
|
frame: { ref: "#o1.1.1", label: "Frame", description: "Fixed frame body." },
|
|
stopper: { ref: "#o1.1.2", label: "Stopper" },
|
|
bearings: { ref: "#o1.1.3,o1.1.4", label: "Bearing blocks" },
|
|
frameHardware: { ref: "#o1.1.5,o1.1.6,o1.1.7,o1.1.8,o1.1.9,o1.1.10,o1.1.11,o1.1.12,o1.1.13,o1.1.14,o1.1.15,o1.1.16,o1.1.17,o1.1.18,o1.1.19,o1.1.20,o1.1.21,o1.1.22,o1.1.23,o1.1.24,o1.1.25,o1.1.26,o1.1.27,o1.1.28", label: "Frame hardware" },
|
|
|
|
rockerBlue: { ref: "#o1.2", label: "Blue upper link", description: "Upper 200 mm link rotating about the upper fixed pivot." },
|
|
rocker2: { ref: "#o1.3.1", label: "Pink lower rocker", description: "Lower rocker arm rotating about the lower fixed pivot." },
|
|
greenRod: { ref: "#o1.3.2", label: "Handle rod" },
|
|
handleHardware: { ref: "#o1.3.3,o1.3.4", label: "Handle hardware" },
|
|
|
|
redLink: { ref: "#o1.4.1", label: "Red coupler", description: "Rigid coupler between the upper and lower board pivots." },
|
|
panel: { ref: "#o1.4.2", label: "Flip panel", description: "Panel fixed to the red coupler." },
|
|
driveRod: { ref: "#o1.4.3", label: "Upper board rod" },
|
|
hingeRod: { ref: "#o1.4.7", label: "Lower board rod" },
|
|
panelHardware: { ref: "#o1.4.4,o1.4.5,o1.4.6,o1.4.8", label: "Panel nuts" }
|
|
},
|
|
parameters: {
|
|
flip: { type: "number", label: "Flip", description: "Closed to open travel. The animation loops this 0 -> 1 -> 0.", default: 0, min: 0, max: 1, step: 0.01 },
|
|
extension: { type: "number", label: "Extension", description: "How close the lower rocker drives to the over-center tangent pose.", default: 1, min: 0.2, max: 1, step: 0.01 },
|
|
turnover: { type: "number", label: "Turnover", description: "Enables the returning coupler to use the open branch after the over-center pose.", default: 1, min: 0, max: 1, step: 1 },
|
|
explode: { type: "number", label: "Explode", description: "Separates moving groups for inspection after the linkage motion is applied.", default: 0, min: 0, max: 1, step: 0.01 },
|
|
showFrame: { type: "boolean", label: "Frame", default: true },
|
|
showDetails: { type: "boolean", label: "Frame details", default: true },
|
|
showCrossRods: { type: "boolean", label: "Cross rods", default: true },
|
|
showHardware: { type: "boolean", label: "Hardware", default: true },
|
|
highlightLinks: { type: "boolean", label: "Highlight moving parts", default: false }
|
|
},
|
|
animations: {
|
|
flipLoop: {
|
|
label: "Reference flip loop",
|
|
description: "Closed -> over-center -> open -> over-center -> closed.",
|
|
duration: 5,
|
|
loop: true,
|
|
update({ cycle, set }) {
|
|
set("flip", cycleFlip(cycle));
|
|
}
|
|
},
|
|
inspectExplode: {
|
|
label: "Inspect explode",
|
|
duration: 5,
|
|
loop: true,
|
|
update({ cycle, set }) {
|
|
set("flip", cycleFlip(cycle));
|
|
set("explode", Math.sin((((finite(cycle, 0) % 1) + 1) % 1) * Math.PI));
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
update({ params, effects }) {
|
|
const extension = clamp(params.extension, 0.2, 1);
|
|
const turnover = clamp(params.turnover, 0, 1);
|
|
const explode = clamp01(params.explode);
|
|
const pose = sampleMotion(params.flip, { extension, turnover });
|
|
|
|
const panelAngleDelta = pose.angleDeg - INITIAL_PANEL_ANGLE_DEG;
|
|
const panelTranslate = translateVector(PIVOTS.lowerBoard, pose.lower);
|
|
const blueAngleDelta = angleDeg2(PIVOTS.upperFrame, pose.upper) - INITIAL_BLUE_ANGLE_DEG;
|
|
const lowerRockerAngleDelta = pose.lowerAngleDeg - INITIAL_LOWER_ROCKER_ANGLE_DEG;
|
|
|
|
applyVisibility(effects, params);
|
|
|
|
effects.style(FRAME_PARTS, { opacity: 1, edgeOpacity: 0.9 });
|
|
effects.style(FRAME_DETAIL_PARTS, { opacity: 1, edgeOpacity: 0.8 });
|
|
effects.style(HARDWARE, { opacity: 1, edgeOpacity: 0.7 });
|
|
effects.style("panel", { opacity: 1, edgeOpacity: 1 });
|
|
effects.style(["rocker2", "greenRod"], { opacity: 1, edgeOpacity: 1 });
|
|
effects.style("rockerBlue", { opacity: 1, edgeOpacity: 1 });
|
|
effects.style("redLink", { opacity: 1, edgeOpacity: 1 });
|
|
effects.style(CROSS_RODS, { opacity: 1, edgeOpacity: 1 });
|
|
|
|
if (params.highlightLinks !== false) {
|
|
effects.highlight(MOVING_PARTS, true);
|
|
}
|
|
|
|
effects.transform(PANEL_COUPLER_PARTS, {
|
|
transforms: [
|
|
{
|
|
rotate: {
|
|
axis: Z_AXIS,
|
|
origin: PIVOTS.lowerBoard,
|
|
angleDeg: panelAngleDelta
|
|
}
|
|
},
|
|
{ translate: [panelTranslate[0] + (explode * 16), panelTranslate[1] - (explode * 8), panelTranslate[2] + (explode * 24)] }
|
|
]
|
|
});
|
|
|
|
effects.transform("rockerBlue", {
|
|
transforms: [
|
|
{
|
|
rotate: {
|
|
axis: Z_AXIS,
|
|
origin: PIVOTS.upperFrame,
|
|
angleDeg: blueAngleDelta
|
|
}
|
|
},
|
|
{ translate: [-explode * 10, -explode * 8, explode * 18] }
|
|
]
|
|
});
|
|
|
|
effects.transform(LOWER_ROCKER_PARTS, {
|
|
transforms: [
|
|
{
|
|
rotate: {
|
|
axis: Z_AXIS,
|
|
origin: PIVOTS.lowerFrame,
|
|
angleDeg: lowerRockerAngleDelta
|
|
}
|
|
},
|
|
{ translate: [0, explode * 10, explode * 14] }
|
|
]
|
|
});
|
|
|
|
effects.transform("*", { matrix: FLAT_ON_GROUND_TRANSFORM });
|
|
}
|
|
};
|