Um gêmeo digital de esteira transportadora mostra de relance se a linha está andando, em que velocidade e onde está travada. Neste tutorial, você vai construir um no navegador com Three.js: os rolos giram e as caixas avançam na velocidade real da esteira informada via MQTT, um sinal de travamento para as caixas e deixa a esteira vermelha, e os operadores podem enviar comandos de partida, parada e reset de volta para a linha. Um simulador em Python publica dados realistas da esteira no servidor MQTT público em broker.connectmq.com, para que você possa construir e testar tudo antes de conectar um CLP.
Por que uma esteira é uma boa candidata a gêmeo digital
Esteiras estão por toda parte na manufatura, na armazenagem e na logística, e falham de formas visíveis e caras: uma caixa travada em um ponto de junção pode parar tudo o que está a montante. Os sinais normalmente já estão disponíveis no CLP e se traduzem naturalmente em movimento:
| Sinal | Origem típica | Unidade | Comportamento no gêmeo |
|---|---|---|---|
| Velocidade da esteira | Realimentação do inversor ou encoder | m/s | Rolos giram, caixas se movem |
| Em operação | Contator do motor ou status do inversor | booleano | Cor da esteira, movimento ligado/desligado |
| Travamento | Fotocélula bloqueada por mais tempo que um limite | booleano mais zona | Esteira fica vermelha, caixas param na zona do travamento |
| Contagem de caixas | Fotocélula na descarga | contagem | Contador de produção |
| Corrente do motor | Inversor | A | Opcional, alerta antecipado de sobrecarga |
Como o comportamento visual decorre diretamente da física (um rolo de raio r gira a v / r radianos por segundo), um gêmeo de esteira também é uma boa forma de aprender a conduzir a animação com unidades reais em vez de números arbitrários. Para uma visão mais ampla, consulte nosso guia de tecnologia de gêmeo digital.
Estrutura de topics
Escolha um prefixo único; o exemplo usa connectmq/demo/conv-7f3a:
| Topic | Direção | Payload |
|---|---|---|
.../line1/conv1/telemetry |
Da linha para o gêmeo | {"speed":0.8,"running":true,"jam":false,"jamZone":null,"count":1532,"current":4.1} |
.../line1/conv1/cmd |
Do gêmeo para a linha | START, STOP, RESET |
.../line1/conv1/status |
Linha, retained, last will | online / offline |
Enviar um único objeto de telemetria combinado, em vez de um topic por sinal, garante que o gêmeo nunca veja uma mistura inconsistente (por exemplo, running: true de uma mensagem e speed: 0 de outra mais antiga). As vantagens e desvantagens de topics combinados e por sinal são discutidas em MQTT para gêmeos digitais.
Passo 1: o simulador da esteira
O simulador modela uma esteira de três zonas rodando a 0,8 m/s, conta as caixas, trava ocasionalmente em uma zona aleatória e obedece a comandos. Ele usa paho-mqtt 2.x (pip install "paho-mqtt>=2.0").
import json
import random
import time
import paho.mqtt.client as mqtt
P = "connectmq/demo/conv-7f3a/line1/conv1"
NOMINAL_SPEED = 0.8 # m/s
state = {"running": True, "jam": False, "jamZone": None, "speed": 0.0, "count": 0}
def on_connect(client, userdata, flags, reason_code, properties):
print("connected:", reason_code)
client.publish(f"{P}/status", "online", qos=1, retain=True)
client.subscribe(f"{P}/cmd", qos=1)
def on_message(client, userdata, msg):
cmd = msg.payload.decode().strip().upper()
print("command:", cmd)
if cmd == "START" and not state["jam"]:
state["running"] = True
elif cmd == "STOP":
state["running"] = False
elif cmd == "RESET":
state["jam"], state["jamZone"] = False, None
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="conv-sim-7f3a")
client.will_set(f"{P}/status", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_start()
while True:
if state["running"] and not state["jam"] and random.random() < 0.01:
state["jam"], state["jamZone"] = True, random.choice([1, 2, 3])
state["running"] = False # o CLP para a esteira em caso de travamento
target = NOMINAL_SPEED if state["running"] else 0.0
state["speed"] += (target - state["speed"]) * 0.5 # rampa como um inversor faria
if state["speed"] > 0.05 and random.random() < state["speed"] / 1.6:
state["count"] += 1
payload = {
"speed": round(state["speed"], 3),
"running": state["running"],
"jam": state["jam"],
"jamZone": state["jamZone"],
"count": state["count"],
"current": round(1.0 + state["speed"] * 4 + random.uniform(-0.1, 0.1), 2),
}
client.publish(f"{P}/telemetry", json.dumps(payload), qos=0, retain=True)
time.sleep(0.5)
Duas mensagens por segundo é um ritmo tranquilo para o servidor MQTT público, que permite até 10 mensagens por segundo por cliente.
Passo 2: monte a cena do gêmeo digital da esteira
Salve como index.html e sirva com npx serve .. Os rolos são renderizados com um InstancedMesh, o que mantém barato o desenho de uma esteira longa.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Conveyor Digital Twin</title>
<style>
body { margin: 0; overflow: hidden; background: #0f172a; font-family: system-ui, sans-serif; }
#ui { position: absolute; top: 10px; left: 10px; color: #fff; background: rgba(0,0,0,.6);
padding: 10px 12px; border-radius: 6px; font-size: 14px; }
#ui button { margin-right: 6px; margin-top: 8px; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"mqtt": "https://unpkg.com/mqtt@5.10.1/dist/mqtt.esm.js"
} }
</script>
</head>
<body>
<div id="ui">
<div id="txt">Connecting...</div>
<button data-cmd="START">Start</button><button data-cmd="STOP">Stop</button><button data-cmd="RESET">Reset jam</button>
</div>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import mqtt from "mqtt";
const P = "connectmq/demo/conv-7f3a/line1/conv1";
const LENGTH = 12, WIDTH = 1.2, ROLLER_R = 0.06, ROLLER_PITCH = 0.25, ZONES = 3;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f172a);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(4, 5, 9);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
new OrbitControls(camera, renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 1.3));
const sun = new THREE.DirectionalLight(0xffffff, 1.2); sun.position.set(3, 10, 4); scene.add(sun);
// Estrutura
const frameMat = new THREE.MeshStandardMaterial({ color: 0x64748b });
for (const z of [-WIDTH / 2 - 0.05, WIDTH / 2 + 0.05]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(LENGTH, 0.15, 0.08), frameMat);
rail.position.set(0, 0.8, z); scene.add(rail);
}
// Rolos como instâncias
const count = Math.floor(LENGTH / ROLLER_PITCH);
const rollerGeo = new THREE.CylinderGeometry(ROLLER_R, ROLLER_R, WIDTH, 16);
rollerGeo.rotateX(Math.PI / 2); // eixo ao longo de Z
const rollerMat = new THREE.MeshStandardMaterial({ color: 0x94a3b8, metalness: 0.6, roughness: 0.4 });
const rollers = new THREE.InstancedMesh(rollerGeo, rollerMat, count);
const dummy = new THREE.Object3D();
const rollerX = (i) => -LENGTH / 2 + ROLLER_PITCH / 2 + i * ROLLER_PITCH;
scene.add(rollers);
// Marcadores de zona (acendem em vermelho no travamento)
const zoneMats = [];
for (let z = 0; z < ZONES; z++) {
const m = new THREE.MeshStandardMaterial({ color: 0x1e293b, emissive: 0x000000 });
const strip = new THREE.Mesh(new THREE.BoxGeometry(LENGTH / ZONES - 0.1, 0.02, 0.1), m);
strip.position.set(-LENGTH / 2 + (z + 0.5) * (LENGTH / ZONES), 0.89, WIDTH / 2 + 0.05);
scene.add(strip); zoneMats.push(m);
}
// Caixas
const parcelMat = new THREE.MeshStandardMaterial({ color: 0xc08a4a });
const parcels = [];
for (let i = 0; i < 8; i++) {
const p = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.4, 0.6), parcelMat);
p.position.set(-LENGTH / 2 + i * 1.5, 0.8 + ROLLER_R + 0.2, 0);
scene.add(p); parcels.push(p);
}
// ---------- Estado ----------
const s = { speed: 0, running: false, jam: false, jamZone: null, count: 0, online: false, at: 0 };
let shownSpeed = 0, rollerAngle = 0;
// ---------- MQTT ----------
const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
clientId: "conv-twin-" + Math.random().toString(16).slice(2, 10),
protocolVersion: 5,
});
const dec = new TextDecoder();
client.on("connect", () => client.subscribe(`${P}/#`, { qos: 1 }));
client.on("message", (topic, payload) => {
const text = dec.decode(payload);
if (topic.endsWith("/status")) { s.online = text === "online"; return; }
if (!topic.endsWith("/telemetry")) return;
try { Object.assign(s, JSON.parse(text), { at: performance.now() }); } catch {}
});
document.querySelectorAll("button").forEach((b) =>
b.addEventListener("click", () => client.publish(`${P}/cmd`, b.dataset.cmd, { qos: 1 })));
// ---------- Animação ----------
const clock = new THREE.Clock();
const txt = document.getElementById("txt");
renderer.setAnimationLoop(() => {
const dt = Math.min(clock.getDelta(), 0.1);
const stale = performance.now() - s.at > 3000;
const v = stale ? 0 : s.speed;
shownSpeed += (v - shownSpeed) * (1 - Math.exp(-dt * 6));
// Rolos: velocidade angular = v / r
rollerAngle -= (shownSpeed / ROLLER_R) * dt;
for (let i = 0; i < count; i++) {
dummy.position.set(rollerX(i), 0.8, 0);
dummy.rotation.set(0, 0, rollerAngle);
dummy.updateMatrix();
rollers.setMatrixAt(i, dummy.matrix);
}
rollers.instanceMatrix.needsUpdate = true;
// Caixas se movem na velocidade da esteira e voltam ao início no fim
for (const p of parcels) {
p.position.x += shownSpeed * dt;
if (p.position.x > LENGTH / 2) p.position.x -= LENGTH;
}
// Visualização do travamento
zoneMats.forEach((m, i) => {
const jammed = s.jam && s.jamZone === i + 1;
m.emissive.setHex(jammed ? 0xff2222 : 0x000000);
m.emissiveIntensity = jammed ? 0.6 + 0.4 * Math.sin(performance.now() / 150) : 0;
});
rollerMat.color.setHex(!s.online || stale ? 0x475569 : s.jam ? 0xdc2626 : 0x94a3b8);
txt.textContent = `${s.online ? "Online" : "Offline"} | ${shownSpeed.toFixed(2)} m/s | ` +
`${s.jam ? `JAM in zone ${s.jamZone}` : s.running ? "Running" : "Stopped"} | ` +
`count ${s.count} | ${s.current ?? "-"} A${stale ? " | no data" : ""}`;
renderer.render(scene, camera);
});
addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>
Inicie o simulador, abra a página e veja as caixas avançarem a 0,8 m/s. Em um ou dois minutos ocorre um travamento: a esteira desacelera, os rolos ficam vermelhos e a faixa da zona afetada pulsa. Clique em Reset jam e depois em Start, e a linha volta a acelerar.
Movimento conduzido por unidades reais
A linha mais importante do gêmeo é rollerAngle -= (shownSpeed / ROLLER_R) * dt. Como a cena usa metros e o raio do rolo corresponde ao modelo, a velocidade superficial dos rolos é igual à velocidade das caixas, que por sua vez é igual à velocidade medida da esteira. Quem assiste percebe inconscientemente quando os rolos giram "rápido demais" para as caixas; um movimento fisicamente correto é o que faz um gêmeo transmitir confiança.
A velocidade ainda é suavizada, porque inversores reais aceleram e desaceleram em rampa e a telemetria chega apenas duas vezes por segundo. O fator de suavização é curto (cerca de um sexto de segundo), então uma parada repentina continua parecendo uma parada. Observe que o simulador também para a esteira em caso de travamento, exatamente como um CLP faria: o gêmeo não decide sozinho parar as caixas, ele mostra o que a linha informa.
Checklist do gêmeo digital de esteira para linhas reais
Ao substituir o simulador por dados reais do CLP, verifique estes pontos:
- Origem da velocidade. Prefira a realimentação do inversor ou um encoder ao setpoint. Uma esteira patinando em um tambor gasto anda mais devagar do que o comandado, e o gêmeo deve mostrar isso.
- Definição de travamento. Um travamento normalmente é uma fotocélula bloqueada por mais tempo do que uma caixa leva para passar. Implemente isso no CLP ou no gateway, não no navegador, para que todos os consumidores concordem.
- Numeração das zonas. Combine a numeração das zonas com o engenheiro de automação e identifique-as fisicamente.
- Segurança dos comandos. A partida remota de uma esteira é uma questão de segurança. Muitas plantas permitem apenas parada e reset a partir de um gêmeo, ou exigem confirmação local. Nunca contorne o sistema de segurança da máquina.
- Gateway. Um gateway de CLP ou um fluxo do Node-RED normalmente lê as tags e publica o JSON mostrado acima; Node-RED com Modbus é um caminho comum.
- Taxa de atualização. De duas a cinco mensagens por segundo por esteira é mais que suficiente para visualização. Taxas maiores adicionam carga sem benefício visível.
Solução de problemas
As caixas se movem, mas os rolos parecem parados ou piscam. Em velocidades altas, a rotação do rolo por quadro pode se aproximar de um segmento inteiro do cilindro, o que cria o efeito de roda de carroça. Adicione uma textura com faixa visível ou reduza o número de segmentos para que o movimento seja lido corretamente.
O gêmeo continua se movendo depois que a linha para. Provavelmente seu gateway está publicando o setpoint de velocidade em vez da realimentação, ou parou de publicar por completo. A verificação de dados desatualizados zera a velocidade após três segundos sem mensagens, e é por isso que a linha de status mostra "no data".
Os botões não fazem nada. Verifique se o simulador ou gateway assina exatamente o topic cmd e acompanhe-o com mosquitto_sub para confirmar que o comando chega.
A página conecta e cai a cada poucos segundos. Duas abas ou dois visualizadores compartilham o mesmo client ID. O sufixo aleatório do exemplo evita isso no servidor MQTT público; em um broker privado, onde os client IDs estão vinculados às credenciais emitidas, dê a cada visualizador suas próprias credenciais.
Ampliando o gêmeo
- Monte uma linha inteira repetindo a esteira com prefixos diferentes e adicionando junções e desviadores; vínculos orientados por configuração, como em vincular dados IoT a um modelo 3D, mantêm isso gerenciável.
- Adicione cards de KPI para produção por hora, número de travamentos por turno e disponibilidade ao lado da visualização 3D, como mostrado no tutorial de dashboard de gêmeo digital.
- Substitua as caixas geométricas por um modelo glTF da sua esteira real, usando a estrutura de como construir um gêmeo digital 3D baseado na web.
- Combine-a com um robô ou paletizador para montar uma célula, semelhante à cena da demo web de gêmeo digital 3D da ConnectMQ, que inclui uma esteira ao lado de uma máquina CNC, um braço robótico, um forno, um tanque e uma bomba.
Executando em produção
Dados da linha, seja de um centro de distribuição, de uma mineradora ou de um frigorífico, não devem trafegar pelo servidor MQTT público, que serve apenas para testes e protótipos. Crie um broker privado com credenciais e TLS, depois adicione username, password e o clientId emitido às opções de mqtt.connect (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID) e use a porta 8883 com TLS no gateway. Um broker MQTT gerenciado como a ConnectMQ cuida do lado do broker, incluindo WebSocket seguro na porta 8084 para gêmeos no navegador, para que sua equipe se concentre na integração com o CLP e na visualização.
Comece a usar a ConnectMQ
Construa hoje seu gêmeo digital de esteira transportadora no servidor MQTT público e depois migre-o para o seu próprio broker privado na ConnectMQ. O plano Serverless gratuito, sem necessidade de cartão de crédito, suporta até 25 conexões e inclui regras de alerta com um registro de alertas, para que um travamento também possa disparar um alerta por limite no portal. Crie seu broker gratuito em app.connectmq.com. Se preferir continuar experimentando antes, use o servidor MQTT público (gratuito).
Perguntas frequentes
Com que frequência uma esteira deve publicar telemetria para um gêmeo digital?
De duas a cinco mensagens por segundo geralmente é suficiente. O gêmeo interpola entre as mensagens, então taxas maiores adicionam carga de rede com pouco benefício visual.
A detecção de travamento deve acontecer no gêmeo ou no CLP?
No CLP ou no gateway. O gêmeo deve exibir o estado de travamento que recebe, para que todos os sistemas, dos dashboards aos alertas, vejam o mesmo evento.
Posso controlar a esteira a partir do gêmeo digital?
Tecnicamente sim, publicando comandos que o gateway encaminha ao CLP. Se você deve fazer isso depende das regras de segurança da sua planta; parada e reset são comuns, enquanto a partida remota muitas vezes exige confirmação local.
Como exibir muitas esteiras sem deixar o navegador lento?
Use instanced meshes para rolos e caixas, compartilhe materiais e mantenha uma única conexão MQTT com uma assinatura curinga para a linha inteira.



