07. Basis 和 Mount 的游乐场
设备的 坐标系变换 以交互方式操作,configure.basis、configure.preset、configure.mount 展示了它们在钢丝上如何协同工作。为了将交互操作的重点放在 configure 命令上,我们固定了水平方向的地板。
本教程将教你:
- 底牌的排列的设置(
"XZY"→(Y 轴在上的应用程序框架) - 预设(
arm_front)的选择,以及对其设置内容的理解 - 使用旋转四元数在运行时覆盖挂载(键盘操作)
- 排他规则:
mount和preset是,一样 设备配置 无法在同一块内共存 - 单次拍摄的
configure工作原理:每次按下按键一次,仅发送一条 configure 消息 - (C++ Glaze)
std::optional使用……对排他性字段进行建模
工作流
- 在第一条消息中,会话配置文件、
configure.basis: "XZY"、configure.preset: arm_front将发送。由于地板是固定的,因此set_cursor_force开始发送。 - 在每个 tick 中,读取光标的 Y 坐标,
force_y = max(0, (floor_pos - y) * stiffness)计算后发送。 - 当用户按下马鞍旋转键时,
pending_configure = true设置该标志。 - 在下一个 tick 时,
configure.mount构建块。该变换的rotation是一个单位四元数(由当前俯仰角和偏航角依次合成 Z 轴方向,随后是 X 轴方向)。preset此处省略。因为二者在线路上是互斥的。 - 重置键(
R)来取消覆盖。在接下来的 configure 步骤中,再次preset会被使用。
参数
| 姓名 | 预设值 | 用途 |
|---|---|---|
BASIS | "XZY" | 轴的排列— Y 轴位于上方的应用程序框架 |
DEVICE_PRESET / DEVICE_CONFIG_PRESET | "arm_front" | 命名预设— 原点为设备底座 |
FLOOR_POS_Y | 0.0 m | 固定的地板平面(应用程序的 Y 轴) |
STIFFNESS | 1000 不适用 | 床的弹簧刚度系数 |
MOUNT_STEP_DEG | 10° | 每次按键的旋转量 |
PRINT_EVERY_MS | 200 | 遥测采样间隔 |
操作
| 键 | 动作 |
|---|---|
W / S | 将支架围绕设备的 +X 轴旋转 ±10°(俯仰角) |
A / D | 将支架围绕设备的 +Z 轴旋转 ±10°(偏航) |
R | 重置安装 — 恢复为预设状态 |
H | 显示操作方法 |
Q | 结束 |
mount 和 preset 不能同时指定该服务包含这两项 设备配置 拒绝该块。如果用户覆盖了挂载设置,在本教程中,此后所有 configure 操作都将 preset 省略。R 按下后,在接下来的 configure 中 preset 再次生效,mount 将被删除。
C++ 的变体在后台的 stdin 线程中按行读取输入(每输入一个字符就按一次回车键)。Python 则通过主异步循环实时获取键盘输入,因此,keyboard 使用该软件包。无需按 ENTER 键。键位和命令都一样。
读取状态字段
data.inverse3[i].state 从以下内容中读取:
cursor_position.y—vec3。用于计算向地基的贯入量current_cursor_force— 将作为遥测数据上报
发送/接收
所有变体的有效载荷格式都相同。值得注意的区别在于,各实现具有排他性的 mount / preset 如何构建该分支,以及输入线程如何通知 WebSocket 线程。
- Python
- C++ (nlohmann)
- C++ (Glaze)
keyboard 这是一个通过包来轮询实时键盘输入的单一异步循环。pending_configure 是一个由键处理程序设置的全局标志,configure 每次发送区块时都会被清空。
async with websockets.connect(URI) as websocket:
while True:
msg = await websocket.recv()
data = json.loads(msg)
if first_message:
first_message = False
device_id = data["inverse3"][0]["device_id"]
# Handshake: profile + basis + preset
request_msg = {
"session": {"configure": {"profile": {"name": SLUG}}},
"inverse3": [{
"device_id": device_id,
"configure": build_configure_block(first_handshake=True),
# -> {"basis": {"permutation": "XZY"},
# "preset": {"preset": "arm_front"}}
}],
}
else:
handle_key_inputs() # may set pending_configure = True (classic, not shown)
y = data["inverse3"][0]["state"]["cursor_position"]["y"]
force_y = 0.0 if y > FLOOR_POS_Y else (FLOOR_POS_Y - y) * STIFFNESS
entry = {
"device_id": device_id,
"commands": {"set_cursor_force":
{"vector": {"x": 0.0, "y": force_y, "z": 0.0}}},
}
if pending_configure:
entry["configure"] = build_configure_block(first_handshake=False)
# -> {"mount": {...}} OR {"preset": {...}} (never both)
pending_configure = False
request_msg = {"inverse3": [entry]}
await websocket.send(json.dumps(request_msg))
采用双线程架构。后台的 stdin 线程负责读取行,pending_configure(std::atomic<bool>)进行切换。libhv 的 I/O 线程会在每个时钟周期检查该设置,如果已设置,则 configure 将发送。
std::atomic<bool> pending_configure{false};
ws.onmessage = [&](const std::string &msg) {
const json data = json::parse(msg);
if (!data.contains("inverse3") || data["inverse3"].empty()) return;
const bool do_handshake = first_message;
if (first_message) first_message = false;
const bool do_configure = do_handshake || pending_configure.exchange(false);
json request = {};
if (do_handshake) {
request["session"] = {{"configure", {{"profile",
{{"name", "co.haply.inverse.tutorials:basis-and-mount"}}}}}};
}
request["inverse3"] = json::array();
for (auto &el : data["inverse3"].items()) {
json dev_cmd = {{"device_id", el.value()["device_id"]}};
if (do_configure) {
json cfg = {};
if (do_handshake) cfg["basis"] = {{"permutation", BASIS}};
if (mount_overridden) {
cfg["mount"] = {{"transform", {
{"position", {{"x", 0.0}, {"y", 0.0}, {"z", 0.0}}},
{"rotation", quat_from_xz_deg(mount_angle_x_deg, mount_angle_z_deg)},
{"scale", {{"x", 1.0}, {"y", 1.0}, {"z", 1.0}}},
}}};
} else {
cfg["preset"] = {{"preset", DEVICE_CONFIG_PRESET}};
}
dev_cmd["configure"] = cfg;
}
const float y = el.value()["state"]["cursor_position"]["y"].get<float>();
const float force_y = y > FLOOR_POS_Y ? 0.0f : (FLOOR_POS_Y - y) * STIFFNESS;
dev_cmd["commands"] = {{"set_cursor_force",
{{"vector", {{"x", 0.0}, {"y", force_y}, {"z", 0.0}}}}}};
request["inverse3"].push_back(dev_cmd);
}
ws.send(request.dump());
};
std::thread input_thr(input_thread_func); // stdin reader — flips pending_configure
ws.open("ws://localhost:10001");
while (running.load()) std::this_thread::sleep_for(50ms);
排他规则是,std::optional<preset_cfg> 和 std::optional<mount_cfg> 可以通过它自然地表达出来。序列化的 JSON 中只会显示已设置值的字段。预设名称为 enum class 被建模为,glz::meta 通过该特化,服务可与预期的字符串建立对应关系。
// The preset set modelled as an enum
enum class device_preset {
defaults, arm_front, arm_front_centered,
led_front, led_front_centered, custom,
};
// Glaze meta — serialize the enum as the JSON string the service expects
template <> struct glz::meta<device_preset> {
using enum device_preset;
static constexpr auto value =
enumerate(defaults, arm_front, arm_front_centered,
led_front, led_front_centered, custom);
};
// Transform + the configure block
struct vec3 { float x{}, y{}, z{}; };
struct quat { float w{1.0f}, x{}, y{}, z{}; };
struct transform_t { vec3 position{}; quat rotation{}; vec3 scale{1,1,1}; };
struct preset_cfg { device_preset preset; };
struct basis_cfg { std::string permutation; };
struct mount_cfg { transform_t transform; };
struct device_configure {
std::optional<preset_cfg> preset; // mutually exclusive with mount
std::optional<basis_cfg> basis;
std::optional<mount_cfg> mount; // mutually exclusive with preset
};
// Send / receive
ws.onmessage = [&](const std::string &msg) {
devices_message data{};
if (glz::read<glz_settings>(data, msg)) return;
if (data.inverse3.empty()) return;
const bool do_handshake = first_message.exchange(false);
const bool do_configure = do_handshake || pending_configure.exchange(false);
commands_message out_cmds{};
if (do_handshake) {
out_cmds.session = session_cmd{ /* profile = basis-and-mount */ };
}
for (const auto &dev : data.inverse3) {
device_commands dc{ .device_id = dev.device_id };
if (do_configure) {
device_configure cfg{};
if (do_handshake) cfg.basis = basis_cfg{BASIS};
if (mount_overridden) {
cfg.mount = mount_cfg{ .transform = transform_t{
.rotation = quat_from_xz_deg(mount_angle_x_deg, mount_angle_z_deg)}};
} else {
cfg.preset = preset_cfg{DEVICE_CONFIG_PRESET};
}
dc.configure = std::move(cfg);
}
const float y = dev.state.cursor_position.y;
const float force_y = y > FLOOR_POS_Y ? 0.0f : (FLOOR_POS_Y - y) * STIFFNESS;
dc.commands.set_cursor_force = set_cursor_force_cmd{{0.0f, force_y, 0.0f}};
out_cmds.inverse3.push_back(std::move(dc));
}
std::string out_json;
(void)glz::write_json(out_cmds, out_json);
ws.send(out_json);
};
std::thread input_thr(input_thread_func);
ws.open("ws://localhost:10001");
while (running.load()) std::this_thread::sleep_for(50ms);
教程 07 已随 SDK 一起安装在本地。位于服务安装目录下的 tutorials/07-haply-inverse-basis-and-mount/ 请确认。
相关条目: 底牌的排列 · 安装与工作区 · 设备设置 · 控制命令(set_cursor_force) · 型(transform) · 教程 04(Hello Floor)