Compare commits

...

3 Commits

Author SHA1 Message Date
Diego Freitas 608c9b5e85 Merge branch 'produto' of http://zendioninc.com.br:3000/Zendion/agrobot_base into produto 2026-09-11 22:19:46 -03:00
Diego Freitas cd3125da98 editor de mapa no operation control 2026-09-11 22:17:44 -03:00
Diego Freitas f1e37c1dae ajustado capture e hardware sync 2026-09-10 15:54:28 -03:00
93 changed files with 27991 additions and 4574 deletions

View File

@ -4425,14 +4425,48 @@ namespace AgroBase.Models
private void IniciarRetornoBaseCore(GPSModel baseGps, List<GPSModel> waypointsManual)
{
bool valido = false;
List<GPSModel> pontosRetorno = null;
List<GPSModel> waypointsManualPersistir =
null;
// =========================
// MODO MANUAL (operador)
// MODO MANUAL
// =========================
if (waypointsManual != null && waypointsManual.Count > 0)
if (
waypointsManual != null &&
waypointsManual.Count > 0
)
{
pontosRetorno = ValidarEPrepararWaypointsRetorno(waypointsManual, "manual");
pontosRetorno =
ValidarEPrepararWaypointsRetorno(
waypointsManual,
"manual"
);
/*
* Esta é a rota que representa a intenção
* do operador e deve voltar para a base.
*/
waypointsManualPersistir =
pontosRetorno
.Select(
x =>
new GPSModel
{
Latitude =
x.Latitude,
Longitude =
x.Longitude
}
)
.ToList();
valido = true;
}
else
@ -4441,19 +4475,49 @@ namespace AgroBase.Models
// MODO AUTOMÁTICO
// =========================
if (PontoGpsValido(baseGps))
(valido, pontosRetorno) = GerarTrajetoriaRetornoAutomatico(baseGps);
{
(
valido,
pontosRetorno
) =
GerarTrajetoriaRetornoAutomatico(
baseGps
);
}
}
if (!valido || pontosRetorno == null || pontosRetorno.Count < 1)
if (
!valido ||
pontosRetorno == null ||
pontosRetorno.Count < 1
)
{
throw new InvalidOperationException("Trajetoria de retorno invalida ou rota segura indisponivel.");
}
else
{
pontosRetorno = ValidarEPrepararWaypointsRetorno(pontosRetorno, "calculado");
AplicarTrajetoriaFixaRetorno(pontosRetorno, "RetornoBase", waypointsManual?.Any() ?? false);
AtualizarDadosOperacaoParaRetorno();
throw new InvalidOperationException(
"Trajetoria de retorno invalida " +
"ou rota segura indisponivel."
);
}
pontosRetorno =
ValidarEPrepararWaypointsRetorno(
pontosRetorno,
"calculado"
);
AplicarTrajetoriaFixaRetorno(
pontosRetorno,
"RetornoBase",
waypointsManualPersistir?.Any()
?? false
);
AtualizarDadosOperacaoParaRetorno(
waypointsManualPersistir
);
}
private List<GPSModel> ValidarEPrepararWaypointsRetorno(List<GPSModel> pontos, string origem)
@ -4623,7 +4687,7 @@ namespace AgroBase.Models
return new List<GPSModel>(pontos);
}
private void AtualizarDadosOperacaoParaRetorno()
private void AtualizarDadosOperacaoParaRetorno(List<GPSModel> waypointsManual)
{
var op = Variaveis.OperacaoEmAndamento;
@ -4640,6 +4704,26 @@ namespace AgroBase.Models
op.Parametros = new_op.Parametros;
/*
* Mantém somente a rota MANUAL recebida do operador.
*
* Em retorno automático, fica null.
*
* Contrato:
* [latitude, longitude]
*/
op.Parametros.PontosRetorno =
waypointsManual?
.Select(
x =>
new double[]
{
x.Latitude,
x.Longitude
}
)
.ToList();
if (!op.Sensoriamento.Operacao.OperacaoIniciada)
{
op.Sensoriamento.Operacao.OperacaoIniciada = true;

View File

@ -11,9 +11,6 @@ from .radiometric_controller import RadiometricController
PHYSICAL_CHANNEL_NAMES = ("R", "G", "B", "RE", "NIR")
PHYSICAL_CHANNEL_COUNT = len(PHYSICAL_CHANNEL_NAMES)
OAK_FCC3_CLIENT_VERSION = "production_v1_2026_08_24"
PRODUCT_SCHEMA = "multispec_module_params_v3"
ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1"
class OakFcc3Client:
@ -44,110 +41,83 @@ class OakFcc3Client:
capture_mode="AUTO",
raw_policy="allow_single",
module_calibration_json=None,
sync_mode="best",
sync_tolerance_ms=25.0,
mx_id=None,
imu_modo="rotation_vector",
imu_freq_hz=200,
evaluate_quality=True,
require_product_contract=False,
hardware_sync_enabled=None,
frame_sync_master=None,
sync_mode=None,
sync_tolerance_ms=None,
buffer_size=None,
**kwargs,
):
# width/height/bayer recebidos do caller ficam apenas como fallback legado.
# Em module_params de produção, hardware real + MP são a autoridade.
self.legacy_width = int(width)
self.legacy_height = int(height)
self.legacy_bayer = str(bayer or "BGGR").upper()
self.fps = float(fps)
self.frame_type = str(frame_type).upper()
self.output_dtype = str(output_dtype).lower()
self.capture_mode = str(capture_mode).upper()
self.raw_policy = str(raw_policy).lower()
self.width = width
self.height = height
self.bayer = bayer
self.fps = fps
self.frame_type = frame_type
self.output_dtype = output_dtype
self.capture_mode = capture_mode
self.raw_policy = raw_policy
self.module_calibration_json = module_calibration_json
self.require_product_contract = bool(require_product_contract)
self.module_params = self._load_module_params(
module_calibration_json,
required=self.require_product_contract,
)
self.product_contract = self._is_product_module_params(self.module_params)
if self.require_product_contract and not self.product_contract:
raise RuntimeError(
"Contrato de produção obrigatório, mas module_params não foi "
"gerado pelo assembler oficial."
)
self.module_params = self._load_module_params(module_calibration_json)
self.fusion_config = self.module_params.get("fusion_config", {}) or {}
self.camera_hardware = self._resolve_camera_hardware()
self.sensor_size_by_role = self._resolve_sensor_size_by_role()
rgb_size = self.sensor_size_by_role.get(
"rgb",
[self.legacy_width, self.legacy_height],
)
self.rgb_native_width = int(rgb_size[0])
self.rgb_native_height = int(rgb_size[1])
# Mantidos por compatibilidade, mas agora significam RGB de referência.
self.width = self.rgb_native_width
self.height = self.rgb_native_height
self.bayer = self._resolve_bayer_pattern()
self.default_target_size = self._resolve_default_target_size()
self.imu_modo = str(imu_modo).strip().lower()
self.imu_freq_hz = int(imu_freq_hz)
# Auditoria radiométrica completa do Raw5.
#
# True mantém o comportamento histórico e é útil para captura científica,
# normalize/auditoria e ferramentas offline.
#
# No runtime em tempo real deve ficar False: evaluate_frame_quality()
# calcula estatísticas/percentis pesados e não faz parte da montagem
# necessária para a inferência.
self.evaluate_quality = bool(evaluate_quality)
self.mx_id = str(mx_id) if mx_id else None
# Validação antecipada do modo produto. O Manager também repete estes
# checks antes de abrir o hardware, de propósito.
self._validate_static_product_contract()
self.svc = OakFcc3Service(
timeout=10,
fps=self.fps,
width=self.width,
height=self.height,
frame_type=self.frame_type,
output_dtype=self.output_dtype,
capture_mode=self.capture_mode,
raw_policy=self.raw_policy,
sync_mode=sync_mode,
sync_tolerance_ms=sync_tolerance_ms,
fps=fps,
width=width,
height=height,
frame_type=frame_type,
output_dtype=output_dtype,
capture_mode=capture_mode,
raw_policy=raw_policy,
mx_id=self.mx_id,
module_calibration_json=module_calibration_json,
module_params=self.module_params,
require_product_contract=self.require_product_contract,
imu_modo=self.imu_modo,
imu_freq_hz=self.imu_freq_hz,
hardware_sync_enabled=hardware_sync_enabled,
frame_sync_master=frame_sync_master,
sync_mode=sync_mode,
sync_tolerance_ms=sync_tolerance_ms,
buffer_size=buffer_size,
**kwargs,
)
self.applied_camera_controls = {}
self.radiometric_controller = None
self.radiometric_controller_enabled = False
self.core = RawProcessorCore(
sensor_width=self.rgb_native_width,
sensor_height=self.rgb_native_height,
bayer_pattern=self.bayer,
sensor_width=width,
sensor_height=height,
bayer_pattern=bayer,
calibration_json_path=module_calibration_json,
)
# Preview é apenas visual, porém também precisa usar o Bayer/raster
# reais do RGB para não mentir sobre a AR0234.
self.preview = RawProcessorPreview(
sensor_width=self.rgb_native_width,
sensor_height=self.rgb_native_height,
bayer_pattern=self.bayer,
sensor_width=width,
sensor_height=height,
bayer_pattern=bayer,
)
self._preview_cache = {
(self.rgb_native_width, self.rgb_native_height, self.bayer): self.preview,
}
def __enter__(self):
self.start()
@ -156,247 +126,19 @@ class OakFcc3Client:
def __exit__(self, exc_type, exc, tb):
self.stop()
def _load_module_params(self, path, required=False):
if not path:
if required:
raise FileNotFoundError(
"module_params obrigatório no contrato de produção."
)
return {}
if not os.path.isfile(path):
if required:
raise FileNotFoundError(
f"module_params não encontrado: {path}"
)
def _load_module_params(self, path):
if not path or not os.path.isfile(path):
return {}
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise RuntimeError(
f"module_params root deve ser dict/object: {path}"
)
return data
@staticmethod
def _is_product_module_params(module_params):
mp = module_params or {}
assembly = mp.get("assembly_metadata", {}) or {}
return bool(
mp.get("schema") == PRODUCT_SCHEMA
and assembly.get("schema") == ASSEMBLY_SCHEMA
and isinstance(mp.get("camera_hardware"), dict)
and isinstance(mp.get("sensor_size_by_role"), dict)
and isinstance(mp.get("calibration_provenance"), dict)
)
@staticmethod
def _normalize_size(value, label):
if not (isinstance(value, (list, tuple)) and len(value) == 2):
raise RuntimeError(f"{label} deve ser [W,H], recebido={value!r}")
w = int(value[0])
h = int(value[1])
if w <= 0 or h <= 0:
raise RuntimeError(f"{label} inválido: {value!r}")
return [w, h]
def _resolve_camera_hardware(self):
raw = self.module_params.get("camera_hardware", {}) or {}
if not self.product_contract:
return {}
out = {}
expected = {
"rgb": ("CAM_A", ("OV9782", "AR0234")),
"re": ("CAM_B", ("OV9282",)),
"nir": ("CAM_C", ("OV9282",)),
}
for role, (expected_socket, allowed_sensors) in expected.items():
item = raw.get(role)
if not isinstance(item, dict):
raise RuntimeError(f"camera_hardware sem role={role}")
socket = str(item.get("socket") or item.get("socket_name") or "").upper()
sensor = str(item.get("sensor") or item.get("sensor_name") or "").upper()
size = self._normalize_size(item.get("size"), f"camera_hardware.{role}.size")
if socket != expected_socket:
raise RuntimeError(
f"camera_hardware.{role}.socket={socket!r}, esperado={expected_socket!r}"
)
if sensor not in allowed_sensors:
raise RuntimeError(
f"camera_hardware.{role}.sensor={sensor!r}, permitidos={allowed_sensors}"
)
out[role] = {
"role": role,
"socket": socket,
"sensor": sensor,
"size": size,
}
return out
def _resolve_sensor_size_by_role(self):
if not self.product_contract:
return {
"rgb": [self.legacy_width, self.legacy_height],
"re": [self.legacy_width, self.legacy_height],
"nir": [self.legacy_width, self.legacy_height],
}
raw = self.module_params.get("sensor_size_by_role", {}) or {}
out = {}
for role in ("rgb", "re", "nir"):
size = self._normalize_size(
raw.get(role),
f"sensor_size_by_role.{role}",
)
expected = self.camera_hardware[role]["size"]
if size != expected:
raise RuntimeError(
f"sensor_size_by_role.{role}={size} != camera_hardware={expected}"
)
out[role] = size
return out
def _resolve_bayer_pattern(self):
value = self.module_params.get("bayer_pattern") if self.product_contract else None
bayer = str(value or self.legacy_bayer).upper()
if bayer not in ("RGGB", "BGGR", "GRBG", "GBRG"):
raise RuntimeError(f"bayer_pattern inválido: {bayer!r}")
return bayer
def _resolve_default_target_size(self):
value = (self.fusion_config or {}).get("target_size")
if value is None:
return None
return self._normalize_size(value, "fusion_config.target_size")
def _resolve_target_size(self, target_size):
value = target_size if target_size is not None else self.default_target_size
if value is None:
return None
size = self._normalize_size(value, "target_size")
if self.product_contract and self.default_target_size is not None:
if size != self.default_target_size:
raise RuntimeError(
"target_size solicitado diverge do module_params homologado: "
f"requested={size}, calibrated_runtime={self.default_target_size}"
)
return size
def _validate_static_product_contract(self):
if not self.product_contract:
return
if self.frame_type != "RAW_BRUTO":
raise RuntimeError(
"OakFcc3Client de produção aceita somente frame_type='RAW_BRUTO'."
)
if self.raw_policy != "require_triple":
raise RuntimeError(
"OakFcc3Client de produção exige raw_policy='require_triple'."
)
if self.capture_mode not in ("TRIPLE", "AUTO"):
raise RuntimeError(
"OakFcc3Client de produção exige capture_mode TRIPLE/AUTO."
)
expected_mx = (
(self.module_params.get("calibration_provenance", {}) or {})
.get("device_mx_id")
)
if expected_mx and self.mx_id and str(expected_mx) != self.mx_id:
raise RuntimeError(
"MX ID solicitado diverge da calibração homologada: "
f"requested={self.mx_id}, calibrated={expected_mx}"
)
def _sync_contract_from_manager_status(self, status):
if not isinstance(status, dict):
return
if self.product_contract and not bool(status.get("product_contract", False)):
raise RuntimeError(
"Client carregou module_params produto, mas Manager não reconheceu o contrato."
)
sizes = status.get("sensor_size_by_role")
if isinstance(sizes, dict):
normalized = {
role: self._normalize_size(sizes.get(role), f"manager.sensor_size_by_role.{role}")
for role in ("rgb", "re", "nir")
}
if self.product_contract and normalized != self.sensor_size_by_role:
raise RuntimeError(
"Manager e Client discordam sobre sensor_size_by_role: "
f"manager={normalized}, client={self.sensor_size_by_role}"
)
self.sensor_size_by_role = normalized
bayer = status.get("bayer_pattern")
if bayer:
bayer = str(bayer).upper()
if self.product_contract and bayer != self.bayer:
raise RuntimeError(
f"Manager Bayer={bayer} != Client/module_params={self.bayer}"
)
def get_contract(self):
return {
"client_version": OAK_FCC3_CLIENT_VERSION,
"product_contract": bool(self.product_contract),
"require_product_contract": bool(self.require_product_contract),
"sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"camera_hardware": json.loads(json.dumps(self.camera_hardware)),
"bayer_pattern": self.bayer,
"default_target_size": (
None if self.default_target_size is None
else list(self.default_target_size)
),
"evaluate_quality": bool(self.evaluate_quality),
"radiometric_controller_enabled": bool(self.radiometric_controller_enabled),
}
return json.load(f)
def apply_module_camera_settings(self):
camera_settings = self.module_params.get("camera_settings", {}) or {}
if self.product_contract:
missing = [
role
for role in ("rgb", "re", "nir")
if not isinstance(camera_settings.get(role), dict)
]
if missing:
raise RuntimeError(
f"module_params produto sem camera_settings para: {missing}"
)
applied = {}
roles = ("rgb", "re", "nir") if self.product_contract else tuple(camera_settings.keys())
for role in roles:
settings = camera_settings.get(role)
for role, settings in camera_settings.items():
if not isinstance(settings, dict):
continue
@ -415,43 +157,17 @@ class OakFcc3Client:
}
self.applied_camera_controls = applied
if self.product_contract:
failed = {
role: value
for role, value in applied.items()
if not bool((value or {}).get("ok", False))
}
if failed:
raise RuntimeError(
f"Falha reaplicando camera_settings homologado: {failed}"
)
return applied
def enable_radiometric_controller(self):
cfg = self.module_params.get("radiometric_config", {}) or {}
enabled = bool(cfg.get("enabled", False))
# No produto atual este controller de exposição em campo é OFF.
# Não instanciamos um segundo piloto para ficar parado dentro do loop.
if not enabled:
self.radiometric_controller = None
self.radiometric_controller_enabled = False
return None
self.radiometric_controller = RadiometricController(
client=self,
config_json_path=self.module_calibration_json,
)
self.radiometric_controller.sync_from_camera_controls(
self.applied_camera_controls
)
self.radiometric_controller.sync_from_camera_controls(self.applied_camera_controls)
self.radiometric_controller.sync_from_actual_camera_controls()
self.radiometric_controller_enabled = bool(
getattr(self.radiometric_controller, "enabled", True)
)
return self.radiometric_controller
def update_radiometry(self, decoded, meta=None):
@ -491,45 +207,17 @@ class OakFcc3Client:
)
try:
status = self.svc.get_status()
self.mx_id = str(status.get("mx_id") or self.mx_id or "") or None
self._sync_contract_from_manager_status(status)
self.mx_id = self.svc.manager.mx_id
except Exception:
# Se a validação de contrato falhar, fecha hardware antes de propagar.
try:
self.svc.disconnect()
except Exception:
pass
raise
pass
# O Manager de produção já aplicou estes controles via initialControl.
# Reaplicamos após start como confirmação operacional e para manter
# compatibilidade com Managers legados durante a migração.
applied = self.apply_module_camera_settings()
self.enable_radiometric_controller()
if print_debug:
print("[OAK CLIENT] START:", resp)
print("[OAK CLIENT] CONTRACT:", self.get_contract())
print("[OAK CLIENT] APPLIED CAMERA SETTINGS:", applied)
if isinstance(resp, dict):
resp = dict(resp)
resp["client_version"] = OAK_FCC3_CLIENT_VERSION
resp["product_contract"] = bool(self.product_contract)
resp["sensor_size_by_role"] = {
role: list(size)
for role, size in self.sensor_size_by_role.items()
}
resp["bayer_pattern"] = self.bayer
resp["default_target_size"] = (
None if self.default_target_size is None
else list(self.default_target_size)
)
resp["radiometric_controller_enabled"] = bool(
self.radiometric_controller_enabled
)
self.enable_radiometric_controller()
return resp
@ -541,29 +229,7 @@ class OakFcc3Client:
return self.svc.get_device_metrics()
def get_status(self):
status = self.svc.get_status()
if not isinstance(status, dict):
status = {}
else:
status = dict(status)
status.update({
"client_version": OAK_FCC3_CLIENT_VERSION,
"client_product_contract": bool(self.product_contract),
"client_require_product_contract": bool(self.require_product_contract),
"client_sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"client_bayer_pattern": self.bayer,
"client_default_target_size": (
None if self.default_target_size is None
else list(self.default_target_size)
),
"evaluate_quality": bool(self.evaluate_quality),
"radiometric_controller_enabled": bool(self.radiometric_controller_enabled),
})
return status
return self.svc.get_status()
def get_next_raw_frame(self, timeout=1.0):
return self.svc.capture_frame(timeout=timeout)
@ -578,12 +244,6 @@ class OakFcc3Client:
frame_type = str(raw_meta.get("frame_type", self.frame_type)).upper()
meta = dict(raw_meta)
if self.product_contract and frame_type != "RAW_BRUTO":
raise RuntimeError(
"OakFcc3Client produto recebeu frame_type não canônico: "
f"{frame_type!r}. Esperado='RAW_BRUTO'."
)
if frame_type == "RAW_BRUTO":
decoded = self.decode_stream_cameras(raw_frame, raw_meta)
@ -680,25 +340,6 @@ class OakFcc3Client:
def build_infer_tensor(self, frame, meta, channels_expected, target_size=None):
channels_expected = self._validate_physical_channel_count(channels_expected)
target_size = self._resolve_target_size(target_size)
frame_type = str(
(meta or {}).get("frame_type", self.frame_type)
if isinstance(meta, dict)
else self.frame_type
).upper()
# No runtime quente evitamos o quality audit pesado. Decodificamos e
# usamos exatamente o mesmo caminho do CameraMultispectral.
if not self.evaluate_quality and frame_type == "RAW_BRUTO":
decoded = self.core.decode_stream_cameras(frame, meta)
return self.build_infer_tensor_from_decoded(
decoded=decoded,
meta=meta,
channels_expected=channels_expected,
target_size=target_size,
)
return self.core.build_infer_tensor_from_stream(
frame,
meta,
@ -714,49 +355,41 @@ class OakFcc3Client:
target_size=None,
evaluate_quality=None,
):
"""
Monta o Raw5 físico a partir das câmeras já decodificadas.
evaluate_quality:
- None -> usa self.evaluate_quality
- True -> executa evaluate_frame_quality() e atualiza
core.last_frame_quality_result
- False -> não executa a auditoria pesada e limpa
core.last_frame_quality_result
A flag altera somente a auditoria de qualidade. Não altera decode,
radiometria, flat-field, homografia, crop/resize ou patch normalization.
"""
channels_expected = self._validate_physical_channel_count(channels_expected)
target_size = self._resolve_target_size(target_size)
if evaluate_quality is None:
evaluate_quality = self.evaluate_quality
evaluate_quality = bool(evaluate_quality)
# Core novo faz a geometria source->target em uma única etapa.
# NÃO redimensionar novamente depois da fusão.
tensor = self.core.fuse_multispec_cameras(
decoded,
meta,
channels_expected,
target_size=target_size,
)
patch_cfg = getattr(
self.core,
"patch_normalization_config",
{},
) or {}
tensor = self.core.fuse_multispec_cameras(decoded, meta, channels_expected)
tensor = self.core.resize_tensor_chw(tensor, target_size=target_size)
# Mantém paridade com build_infer_tensor_from_stream: se a calibração
# habilitar patch normalization, ela também vale no caminho decoded.
patch_cfg = getattr(self.core, "patch_normalization_config", {}) or {}
if bool(patch_cfg.get("enabled", False)):
if self.product_contract:
raise RuntimeError(
"patch_normalization não é permitido no contrato produto."
)
tensor = self.core.apply_patch_normalization_to_tensor(tensor)
if evaluate_quality:
self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor)
else:
# Evita deixar resultado antigo no objeto e evita percentis no hot path.
self.core.last_frame_quality_result = {
"status": "skipped",
"usable_for_training": None,
"reason": "disabled_by_oak_fcc3_client",
"client_version": OAK_FCC3_CLIENT_VERSION,
}
# Evita deixar um resultado antigo parecer referente ao frame atual.
self.core.last_frame_quality_result = None
return np.ascontiguousarray(
tensor.astype(np.float32, copy=False)
)
return tensor
def decode_stream_cameras(self, frame, meta):
if str(meta.get("frame_type", self.frame_type)).upper() == "PREVIEW":
@ -872,14 +505,10 @@ class OakFcc3Client:
arr = arr[:, :, 0]
if bit_depth == 10 and arr.ndim == 2:
role_size = self.sensor_size_by_role.get(
str(role).lower(),
[self.rgb_native_width, self.rgb_native_height],
)
raw16 = self.core.unpack_raw10_packed(
arr,
sensor_width=int(info.get("width", role_size[0])),
sensor_height=int(info.get("height", role_size[1])),
sensor_width=int(info.get("width", self.width)),
sensor_height=int(info.get("height", self.height)),
)
if role == "rgb":
@ -941,7 +570,7 @@ class OakFcc3Client:
or cam_meta.get("bayer")
or stream_meta.get("bayer_pattern")
or bayer_pattern
or self.bayer
or "RGGB"
)
bayer = str(bayer).upper()
@ -973,17 +602,19 @@ class OakFcc3Client:
real_w = int(cam_meta.get("width", sensor_width))
real_h = int(cam_meta.get("height", sensor_height))
cache_key = (real_w, real_h, bayer)
preview = self._preview_cache.get(cache_key)
if preview is None:
preview = RawProcessorPreview(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
self._preview_cache[cache_key] = preview
core = RawProcessorCore(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
raw16 = self.core.unpack_raw10_packed(
preview = RawProcessorPreview(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
raw16 = core.unpack_raw10_packed(
packed,
sensor_width=real_w,
sensor_height=real_h,
@ -1050,11 +681,6 @@ class OakFcc3Client:
def decode_oak_aligned_multispec(self, frame, meta):
if self.product_contract:
raise RuntimeError(
"MULTISPEC alinhado pela OAK é legado e não faz parte do contrato produto."
)
"""
Decodifica frames já alinhados pela OAK.
@ -1103,11 +729,6 @@ class OakFcc3Client:
return decoded
def build_multispec_tensor_from_oak_aligned(self, decoded, meta=None):
if self.product_contract:
raise RuntimeError(
"Tensor MULTISPEC pré-alinhado pela OAK é legado no contrato produto."
)
"""
Monta CHW [R,G,B,RE,NIR] sem reaplicar homografia.
"""

View File

@ -12,60 +12,27 @@ import numpy as np
from itertools import product
OAK_FCC3_MANAGER_VERSION = "production_v1_2026_08_24"
class OakFcc3Manager:
"""
Hardware manager de produção para OAK-FFC-3 multiespectral.
Manager OAK-FFC-3 com dois fluxos principais:
Contrato oficial:
CAM_A = RGB = OV9782 1280x800 OU AR0234 1920x1200
CAM_B = RE = OV9282 1280x800
CAM_C = NIR = OV9282 1280x800
1) RAW_BRUTO
- Mantém o comportamento antigo.
- CAM_A/CAM_B/CAM_C enviam RAW10 packed direto para o PC.
- O PC faz decode, flat/radiometric, homografia/fusão/crop/resize.
Caminho oficial de produto:
RAW_BRUTO -> RAW10 packed nativo por câmera -> RawProcessorCore.
O Manager não calibra imagem. Ele abre/valida hardware, aplica a política
inicial da câmera, sincroniza RAW e publica metadata fiel por câmera.
PREVIEW/MULTISPEC permanecem apenas para compatibilidade legada.
Um module_params produzido pelo assembler oficial exige RAW_BRUTO.
2) MULTISPEC
- Câmeras sempre em 800p nativo.
- OAK aplica homografia/crop/resize via ImageManip.
- PC recebe frames já alinhados:
CAM_A/rgb -> BGR uint8
CAM_B/re -> GRAY uint8
CAM_C/nir -> GRAY uint8
- O Client deve montar o tensor sem reaplicar homografia.
"""
PRODUCT_SCHEMA = "multispec_module_params_v3"
ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1"
LEGACY_SENSOR_W = 1280
LEGACY_SENSOR_H = 800
PRODUCT_TOPOLOGY = {
"rgb": {"socket": "CAM_A", "allowed_sensors": ("OV9782", "AR0234")},
"re": {"socket": "CAM_B", "allowed_sensors": ("OV9282",)},
"nir": {"socket": "CAM_C", "allowed_sensors": ("OV9282",)},
}
SENSOR_PROFILES = {
"OV9782": {
"kind": "color",
"resolution_name": "THE_800_P",
"width": 1280,
"height": 800,
},
"AR0234": {
"kind": "color",
"resolution_name": "THE_1200_P",
"width": 1920,
"height": 1200,
},
"OV9282": {
"kind": "mono",
"resolution_name": "THE_800_P",
"width": 1280,
"height": 800,
},
}
SENSOR_W = 1280
SENSOR_H = 800
def __init__(
self,
@ -77,75 +44,41 @@ class OakFcc3Manager:
capture_mode="AUTO",
raw_policy="allow_single",
roles=None,
sync_mode="best",
hardware_sync_enabled=True,
frame_sync_master="CAM_A",
sync_tolerance_ms=12.0,
buffer_size=8,
sync_mode=None,
hardware_sync_enabled=None,
frame_sync_master=None,
sync_tolerance_ms=None,
buffer_size=None,
only_camera=None,
mx_id=None,
module_calibration_json=None,
module_params=None,
require_product_contract=False,
imu_modo="rotation_vector",
imu_freq_hz=200,
):
self.hardware_sync_enabled = bool(hardware_sync_enabled)
self.frame_sync_master = str(frame_sync_master).upper()
self.fps = float(fps)
self.fps = fps
# width/height são apenas saída LEGADA. RAW_BRUTO usa resolução nativa.
# Para compatibilidade, mantemos width/height.
# No RAW_BRUTO isso não muda o sensor, pois usamos 800p fixo.
# No MULTISPEC isso representa a saída final alinhada da OAK.
self.width = int(width)
self.height = int(height)
self.size = (self.width, self.height)
self.sensor_width = self.SENSOR_W
self.sensor_height = self.SENSOR_H
self.frame_type = str(frame_type).upper()
self.output_dtype = output_dtype
self.capture_mode = str(capture_mode).upper()
self.raw_policy = str(raw_policy).lower()
self.capture_mode = capture_mode
self.raw_policy = raw_policy
self.only_camera = only_camera
self.module_calibration_json = module_calibration_json
self.module_params = (
copy.deepcopy(module_params)
if isinstance(module_params, dict)
else self._load_module_params(module_calibration_json)
)
self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {}
self.require_product_contract = bool(require_product_contract)
self.product_contract = self._is_product_module_params()
self.expected_device_mx_id = self._expected_device_mx_id()
if self.product_contract:
self.roles = {
contract["socket"]: role
for role, contract in self.PRODUCT_TOPOLOGY.items()
}
else:
self.roles = roles or {
"CAM_A": "rgb",
"CAM_B": "re",
"CAM_C": "nir",
}
self.expected_camera_hardware = self._resolve_expected_camera_hardware()
self.sensor_size_by_role = self._resolve_sensor_size_by_role()
rgb_size = self.sensor_size_by_role.get(
"rgb",
[self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
)
self.sensor_width = int(rgb_size[0])
self.sensor_height = int(rgb_size[1])
self.bayer_pattern = str(
(self.module_params or {}).get("bayer_pattern", "BGGR")
).upper()
self.sync_mode = str(sync_mode).lower()
self.sync_tolerance_ms = float(sync_tolerance_ms)
self.buffer_size = int(buffer_size)
self.roles = roles or {
"CAM_A": "rgb",
"CAM_B": "re",
"CAM_C": "nir",
}
self.mx_id = str(mx_id) if mx_id else None
self.dev_info = None
@ -157,12 +90,14 @@ class OakFcc3Manager:
self.imu_modo = self._validate_imu_modo(imu_modo)
self.imu_freq_hz = int(imu_freq_hz)
if self.imu_freq_hz <= 0:
raise ValueError(
f"imu_freq_hz deve ser maior que zero: {self.imu_freq_hz}"
)
self.imu_sensor_type = None
self.has_imu_pipeline = False
self.tem_imu = False
self.q_imu = None
@ -170,18 +105,32 @@ class OakFcc3Manager:
self.running = False
self.frame_id = 0
# Serializa start/stop e leituras nativas de telemetria.
# Evita getChipTemperature/getUsbSpeed concorrendo com device.close().
self._device_lock = threading.RLock()
self.control_queues = {}
self._last_raw_dims = {}
self.aligned_geometry = None
# Startup Profile entra ANTES do pipeline existir.
self.camera_controls = {
cam_id: self._default_controls_for_role(role)
for cam_id, role in self.roles.items()
}
self._hydrate_camera_controls_from_module_params()
self._validate_static_product_contract()
self._last_raw_dims = {}
self.module_calibration_json = module_calibration_json
self.module_params = module_params if isinstance(module_params, dict) else self._load_module_params(module_calibration_json)
self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {}
self.aligned_geometry = None
sync_cfg = (self.module_params.get("capture_synchronization", {}) if isinstance(self.module_params, dict) else {})
if hardware_sync_enabled is None: hardware_sync_enabled = sync_cfg.get("hardware_sync_enabled", False)
if frame_sync_master is None: frame_sync_master = sync_cfg.get("frame_sync_master", "CAM_A")
if sync_mode is None: sync_mode = sync_cfg.get("software_sync_mode", "best")
if sync_tolerance_ms is None: sync_tolerance_ms = sync_cfg.get("sync_tolerance_ms", 12.0)
if buffer_size is None: buffer_size = sync_cfg.get("buffer_size", 8)
self.hardware_sync_enabled = bool(hardware_sync_enabled)
self.frame_sync_master = str(frame_sync_master)
self.sync_mode = str(sync_mode)
self.sync_tolerance_ms = float(sync_tolerance_ms)
self.buffer_size = int(buffer_size)
self.async_capture_enabled = True
self.async_capture_mode = "latest" # latest | queue
@ -228,327 +177,11 @@ class OakFcc3Manager:
# ============================================================
def _load_module_params(self, path):
if not path:
if not path or not os.path.isfile(path):
return {}
if not os.path.isfile(path):
raise FileNotFoundError(
f"module_params não encontrado: {path}"
)
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise RuntimeError(
f"module_params root deve ser dict/object: {path}"
)
return data
def _is_product_module_params(self):
mp = self.module_params or {}
assembly = mp.get("assembly_metadata", {}) or {}
return bool(
mp.get("schema") == self.PRODUCT_SCHEMA
and assembly.get("schema") == self.ASSEMBLY_SCHEMA
and isinstance(mp.get("camera_hardware"), dict)
and isinstance(mp.get("sensor_size_by_role"), dict)
and isinstance(mp.get("calibration_provenance"), dict)
)
def _expected_device_mx_id(self):
prov = (self.module_params or {}).get("calibration_provenance", {}) or {}
value = prov.get("device_mx_id")
return str(value) if value else None
def _normalize_size(self, value, *, label):
if not (isinstance(value, (list, tuple)) and len(value) == 2):
raise RuntimeError(f"{label} deve ser [W,H], recebido={value!r}")
w = int(value[0])
h = int(value[1])
if w <= 0 or h <= 0:
raise RuntimeError(f"{label} inválido: {value!r}")
return [w, h]
def _resolve_expected_camera_hardware(self):
if not self.product_contract:
return {}
raw = (self.module_params or {}).get("camera_hardware", {}) or {}
out = {}
for role, contract in self.PRODUCT_TOPOLOGY.items():
item = raw.get(role)
if not isinstance(item, dict):
raise RuntimeError(
f"module_params.camera_hardware sem role={role}"
)
socket = str(item.get("socket") or "").upper()
sensor = str(item.get("sensor") or "").upper()
size = self._normalize_size(
item.get("size"),
label=f"camera_hardware.{role}.size",
)
if socket != contract["socket"]:
raise RuntimeError(
f"camera_hardware.{role}.socket={socket!r}, "
f"esperado={contract['socket']!r}"
)
if sensor not in contract["allowed_sensors"]:
raise RuntimeError(
f"camera_hardware.{role}.sensor={sensor!r}, "
f"permitidos={contract['allowed_sensors']}"
)
profile = self.SENSOR_PROFILES.get(sensor)
if profile is None:
raise RuntimeError(
f"Sensor sem profile de runtime: {sensor}"
)
native = [int(profile["width"]), int(profile["height"])]
if size != native:
raise RuntimeError(
f"camera_hardware.{role}.size={size} "
f"não corresponde ao nativo de {sensor}: {native}"
)
out[role] = {
"role": role,
"socket": socket,
"sensor": sensor,
"size": size,
"kind": profile["kind"],
"resolution_name": profile["resolution_name"],
}
return out
def _resolve_sensor_size_by_role(self):
if self.product_contract:
raw = (self.module_params or {}).get("sensor_size_by_role", {}) or {}
out = {}
for role in ("rgb", "re", "nir"):
size = self._normalize_size(
raw.get(role),
label=f"sensor_size_by_role.{role}",
)
expected = self.expected_camera_hardware[role]["size"]
if size != expected:
raise RuntimeError(
f"sensor_size_by_role.{role}={size} != "
f"camera_hardware={expected}"
)
out[role] = size
return out
return {
"rgb": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
"re": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
"nir": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
}
def _validate_static_product_contract(self):
if self.require_product_contract and not self.product_contract:
raise RuntimeError(
"Contrato de produção obrigatório, mas o module_params "
"não foi gerado pelo assembler oficial."
)
if not self.product_contract:
return
if self.frame_type != "RAW_BRUTO":
raise RuntimeError(
"module_params de produção aceita somente frame_type=RAW_BRUTO. "
f"Recebido={self.frame_type!r}"
)
if self.capture_mode not in ("TRIPLE", "AUTO"):
raise RuntimeError(
"Produto multiespectral exige capture_mode TRIPLE "
f"(ou AUTO com require_triple). Recebido={self.capture_mode!r}"
)
if self.raw_policy != "require_triple":
raise RuntimeError(
"module_params de produção exige raw_policy='require_triple'. "
f"Recebido={self.raw_policy!r}"
)
if self.only_camera is not None:
raise RuntimeError(
"only_camera não é permitido no contrato de produção."
)
if self.bayer_pattern not in ("RGGB", "BGGR", "GRBG", "GBRG"):
raise RuntimeError(
f"bayer_pattern inválido no module_params: {self.bayer_pattern!r}"
)
if (
self.mx_id is not None
and self.expected_device_mx_id is not None
and self.mx_id != self.expected_device_mx_id
):
raise RuntimeError(
"MX ID solicitado diverge da calibração homologada: "
f"requested={self.mx_id}, calibrated={self.expected_device_mx_id}"
)
def _role_to_cam_id(self, role):
role = str(role).lower()
for cam_id, mapped_role in self.roles.items():
if str(mapped_role).lower() == role:
return cam_id
return None
def _hydrate_camera_controls_from_module_params(self):
settings = (self.module_params or {}).get("camera_settings", {}) or {}
if not isinstance(settings, dict):
if self.product_contract:
raise RuntimeError(
"module_params de produção sem camera_settings."
)
return
for role, cfg in settings.items():
if not isinstance(cfg, dict):
continue
cam_id = self._role_to_cam_id(role)
if cam_id is None:
continue
state = self.camera_controls.setdefault(
cam_id,
self._default_controls_for_role(role),
)
for key in (
"ae_enable",
"awb_enable",
"exposure_time_us",
"analogue_gain",
"colour_gains",
):
if key in cfg:
state[key] = copy.deepcopy(cfg[key])
if self.product_contract:
for role in ("rgb", "re", "nir"):
cam_id = self._role_to_cam_id(role)
if cam_id is None or cam_id not in self.camera_controls:
raise RuntimeError(
f"camera_settings não resolveu role={role}"
)
def _sensor_profile(self, sensor_name, role=None):
sensor = str(sensor_name or "").upper()
profile = self.SENSOR_PROFILES.get(sensor)
if profile is None:
raise RuntimeError(
f"Sensor não suportado pelo runtime: {sensor!r}"
)
if role is not None:
role = str(role).lower()
expected_kind = "color" if role == "rgb" else "mono"
if profile["kind"] != expected_kind:
raise RuntimeError(
f"Sensor {sensor} é {profile['kind']}, "
f"mas role={role} exige {expected_kind}."
)
return profile
def _feature_rows(self, features):
rows = []
for f in features:
socket = f.socket.name
sensor = str(f.sensorName or "").upper()
rows.append({
"socket": socket,
"sensor": sensor,
"width": int(getattr(f, "width", 0) or 0),
"height": int(getattr(f, "height", 0) or 0),
"role": self.roles.get(socket, "unknown"),
})
return rows
def _validate_connected_hardware(self, features):
rows = self._feature_rows(features)
by_socket = {row["socket"]: row for row in rows}
if not self.product_contract:
return rows
if (
self.expected_device_mx_id is not None
and self.mx_id is not None
and self.mx_id != self.expected_device_mx_id
):
raise RuntimeError(
"OAK conectada não corresponde ao módulo calibrado: "
f"connected={self.mx_id}, calibrated={self.expected_device_mx_id}"
)
errors = []
for role, expected in self.expected_camera_hardware.items():
row = by_socket.get(expected["socket"])
if row is None:
errors.append(f"{role}: {expected['socket']} ausente")
continue
if row["sensor"] != expected["sensor"]:
errors.append(
f"{role}: {expected['socket']} sensor={row['sensor']}, "
f"esperado={expected['sensor']}"
)
if row["width"] > 0 and row["height"] > 0:
advertised = [row["width"], row["height"]]
if advertised != expected["size"]:
errors.append(
f"{role}: {expected['socket']}/{row['sensor']} anunciou "
f"{advertised}, esperado={expected['size']}"
)
if errors:
raise RuntimeError(
"Hardware OAK não corresponde ao module_params homologado:\n - "
+ "\n - ".join(errors)
)
return rows
return json.load(f)
def _default_controls_for_role(self, role: str):
role = str(role).lower()
@ -579,31 +212,12 @@ class OakFcc3Manager:
with dai.Device(dev_info) as dev:
result = []
features = dev.getConnectedCameraFeatures()
for row in self._feature_rows(features):
role = str(row.get("role", "unknown")).lower()
expected = self.expected_camera_hardware.get(role)
for f in dev.getConnectedCameraFeatures():
result.append({
**row,
"expected": copy.deepcopy(expected),
"matches_product_contract": (
None
if not self.product_contract
else bool(
expected is not None
and row["socket"] == expected["socket"]
and row["sensor"] == expected["sensor"]
and (
row["width"] <= 0
or row["height"] <= 0
or [row["width"], row["height"]] == expected["size"]
)
)
),
"socket": f.socket.name,
"sensor": f.sensorName,
"role": self.roles.get(f.socket.name, "unknown"),
})
return result
def _device_id_from_info(self, dev_info):
@ -677,48 +291,35 @@ class OakFcc3Manager:
def _create_camera_node_classic(self, socket, sensor_name: str, role: str):
"""
RAW/PREVIEW sensor-aware.
Fluxo clássico.
Produto:
OV9782 -> ColorCamera THE_800_P 1280x800
AR0234 -> ColorCamera THE_1200_P 1920x1200
OV9282 -> MonoCamera THE_800_P 1280x800
RGB/OV9782:
ColorCamera raw para RAW_BRUTO.
No produto não existe fallback silencioso de resolução.
MONO/OV9282:
MonoCamera raw quando disponível.
"""
sensor = str(sensor_name or "").upper()
role = str(role or "").lower()
sensor_name_u = str(sensor_name or "").upper()
role_u = str(role or "").lower()
if self.product_contract:
profile = self._sensor_profile(sensor, role=role)
else:
profile = self.SENSOR_PROFILES.get(sensor)
is_rgb = (
role_u == "rgb"
or "OV9782" in sensor_name_u
or socket == dai.CameraBoardSocket.CAM_A
)
if profile is None:
profile = {
"kind": "color" if role == "rgb" else "mono",
"resolution_name": "THE_800_P",
"width": self.LEGACY_SENSOR_W,
"height": self.LEGACY_SENSOR_H,
}
if profile["kind"] == "color":
if is_rgb:
cam = self.pipeline.createColorCamera()
cam.setBoardSocket(socket)
resolution = getattr(
dai.ColorCameraProperties.SensorResolution,
profile["resolution_name"],
None,
)
try:
cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_800_P)
except Exception:
try:
cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_1080_P)
except Exception:
pass
if resolution is None:
raise RuntimeError(
f"DepthAI não expõe ColorCamera "
f"{profile['resolution_name']} para {sensor}."
)
cam.setResolution(resolution)
cam.setInterleaved(False)
cam.setColorOrder(dai.ColorCameraProperties.ColorOrder.RGB)
cam.setFps(float(self.fps))
@ -730,42 +331,31 @@ class OakFcc3Manager:
except Exception:
return cam, cam.preview
if not hasattr(cam, "raw"):
raise RuntimeError(
f"ColorCamera {sensor} não expõe raw output."
)
return cam, cam.raw
mono = self.pipeline.create(dai.node.MonoCamera)
mono.setBoardSocket(socket)
resolution = getattr(
dai.MonoCameraProperties.SensorResolution,
profile["resolution_name"],
None,
)
try:
mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_800_P)
except Exception:
try:
mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_720_P)
except Exception:
try:
mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_400_P)
except Exception:
pass
if resolution is None:
raise RuntimeError(
f"DepthAI não expõe MonoCamera "
f"{profile['resolution_name']} para {sensor}."
)
mono.setResolution(resolution)
mono.setFps(float(self.fps))
if self._is_preview_mode():
return mono, mono.out
if not hasattr(mono, "raw"):
if self.product_contract:
raise RuntimeError(
f"MonoCamera {sensor} não expõe raw output."
)
return mono, mono.out
if hasattr(mono, "raw"):
return mono, mono.raw
return mono, mono.raw
return mono, mono.out
def _create_imu_node(self, pipeline):
self.has_imu_pipeline = False
@ -1306,13 +896,6 @@ class OakFcc3Manager:
self.pipeline = dai.Pipeline()
features = self.device.getConnectedCameraFeatures()
self._validate_connected_hardware(features)
if self.product_contract and self._is_multispec_mode():
raise RuntimeError(
"MULTISPEC alinhado na OAK é legado e não faz parte do "
"contrato de produção. Use RAW_BRUTO."
)
self.queues.clear()
self.buffers.clear()
@ -1360,44 +943,11 @@ class OakFcc3Manager:
self.buffers[cam_id] = deque(maxlen=self.buffer_size)
self.control_queues[cam_id] = None
sensor_u = str(f.sensorName or "").upper()
if self.product_contract or sensor_u in self.SENSOR_PROFILES:
profile = self._sensor_profile(sensor_u, role=role)
else:
profile = {
"kind": "unknown",
"width": int(getattr(f, "width", 0) or self.LEGACY_SENSOR_W),
"height": int(getattr(f, "height", 0) or self.LEGACY_SENSOR_H),
"resolution_name": None,
}
self.camera_info[cam_id] = {
"id": cam_id,
"socket": socket_name,
"sensor": sensor_u,
"sensor": f.sensorName,
"role": role,
"native_width": int(profile["width"]),
"native_height": int(profile["height"]),
"native_size": [
int(profile["width"]),
int(profile["height"]),
],
"sensor_kind": profile["kind"],
"resolution_mode": profile.get("resolution_name"),
"bayer_pattern": (
self.bayer_pattern
if str(role).lower() == "rgb"
else None
),
"product_expected": (
copy.deepcopy(
self.expected_camera_hardware.get(
str(role).lower()
)
)
if self.product_contract
else None
),
}
self._validate_capture_mode()
@ -1599,14 +1149,6 @@ class OakFcc3Manager:
"height": self.height,
"sensor_width": self.sensor_width,
"sensor_height": self.sensor_height,
"sensor_size_by_role": copy.deepcopy(self.sensor_size_by_role),
"camera_hardware_expected": copy.deepcopy(self.expected_camera_hardware),
"bayer_pattern": self.bayer_pattern,
"product_contract": bool(self.product_contract),
"require_product_contract": bool(self.require_product_contract),
"manager_version": OAK_FCC3_MANAGER_VERSION,
"module_params_schema": (self.module_params or {}).get("schema"),
"module_calibration_json": self.module_calibration_json,
"frame_type": self.frame_type,
"output_dtype": self.output_dtype,
"capture_mode": self.capture_mode,
@ -2184,32 +1726,6 @@ class OakFcc3Manager:
item["shape"] = list(arr.shape)
item["dtype"] = str(arr.dtype)
item["packed"] = True
item["native_width"] = int(
item.get("native_width", item["width"])
)
item["native_height"] = int(
item.get("native_height", item["height"])
)
item["native_size"] = [
item["native_width"],
item["native_height"],
]
if str(item.get("role", "")).lower() == "rgb":
item["bayer_pattern"] = self.bayer_pattern
if self.product_contract:
role = str(item.get("role", "")).lower()
expected = self.expected_camera_hardware.get(role)
if expected is not None:
actual = [int(item["width"]), int(item["height"])]
if actual != expected["size"]:
raise RuntimeError(
f"{cam_id}/{role}: RAW frame {actual} != "
f"hardware homologado {expected['size']}"
)
camera_info[cam_id] = item
@ -2217,18 +1733,6 @@ class OakFcc3Manager:
"frame_id": self.frame_id,
"backend": "oak_fcc3",
"frame_type": self.frame_type,
"product_contract": bool(self.product_contract),
"module_params_schema": (self.module_params or {}).get("schema"),
"module_calibration_json": self.module_calibration_json,
"device_mx_id": self.mx_id,
"reference_camera": "rgb",
"sensor_size_by_role": copy.deepcopy(self.sensor_size_by_role),
"camera_hardware": copy.deepcopy(
self.expected_camera_hardware
if self.product_contract
else {}
),
"bayer_pattern": self.bayer_pattern,
"capture_mode": self.capture_mode,
"output_dtype": self.output_dtype,
"dtype": self.output_dtype,
@ -2417,65 +1921,18 @@ class OakFcc3Manager:
return result
def apply_initial_camera_controls_to_node(self, cam, cam_id):
"""
Aplica Startup Profile antes de startPipeline().
O Client pode reaplicar depois do start como confirmação, mas os
primeiros frames já nascem sob a política homologada.
"""
ctrl_state = self.camera_controls.get(cam_id, {})
ae = bool(ctrl_state.get("ae_enable", False))
awb = bool(ctrl_state.get("awb_enable", False))
exp_us = int(ctrl_state.get("exposure_time_us") or 15000)
gain = float(ctrl_state.get("analogue_gain") or 1.0)
if ae:
fn = getattr(cam.initialControl, "setAutoExposureEnable", None)
if callable(fn):
fn()
elif self.product_contract:
raise RuntimeError(
f"{cam_id}: DepthAI sem setAutoExposureEnable no initialControl."
)
else:
if not ae:
cam.initialControl.setManualExposure(
exp_us,
self._gain_to_iso(gain),
)
role = str(self.roles.get(cam_id, "")).lower()
if role == "rgb":
if awb:
fn = getattr(
cam.initialControl,
"setAutoWhiteBalanceLock",
None,
)
if callable(fn):
fn(False)
else:
try:
cam.initialControl.setAutoWhiteBalanceMode(
dai.CameraControl.AutoWhiteBalanceMode.AUTO
)
except Exception:
if self.product_contract:
raise RuntimeError(
f"{cam_id}: não foi possível habilitar AWB inicial."
)
else:
fn = getattr(
cam.initialControl,
"setAutoWhiteBalanceLock",
None,
)
if callable(fn):
fn(True)
def _send_control(self, cam_id, ctrl):
if cam_id not in self.control_queues:
raise RuntimeError(f"Fila de controle não existe para {cam_id}")

View File

@ -1,15 +1,17 @@
using OperationControl.Models;
using OperationControl.Mapas;
using OperationControl.Mapas.Services;
using OperationControl.Models;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media.Animation;
using UserControl = System.Windows.Controls.UserControl;
using KeyEventArgs = System.Windows.Input.KeyEventArgs;
using Binding = System.Windows.Data.Binding;
using Application = System.Windows.Application;
using Binding = System.Windows.Data.Binding;
using KeyEventArgs = System.Windows.Input.KeyEventArgs;
using Point = System.Windows.Point;
using Size = System.Windows.Size;
using UserControl = System.Windows.Controls.UserControl;
namespace OperationControl.Controls
{
@ -315,7 +317,8 @@ namespace OperationControl.Controls
}
else
{
if (!(TgManual.IsChecked ?? false) && !(((App)Application.Current).Shell.Main?.MAP?.RuasMapaCarregado?.Any(x => x.Selected) ?? false))
var ruasPercorrer = ((App)Application.Current).Shell.Dock?._vm?._mapas?.ObterRuasPercorrer() ?? new List<string>();
if (!(TgManual.IsChecked ?? false) && !ruasPercorrer.Any())
{
System.Windows.MessageBox.Show("Para iniciar a operação automática, é necessário carregar um mapa e selecionar as ruas onde o equipamento irá operar!", "Parametrização", MessageBoxButton.OK, MessageBoxImage.Warning);
return;

View File

@ -2,9 +2,18 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:m="clr-namespace:Mapsui.UI.Wpf;assembly=Mapsui.UI.Wpf"
xmlns:editing="clr-namespace:OperationControl.Mapas.Editing.Views"
x:Name="Root"
MinHeight="100"
MinWidth="300">
MinWidth="300"
Focusable="True"
FocusVisualStyle="{x:Null}"
PreviewMouseLeftButtonDown="Root_PreviewMouseLeftButtonDown"
PreviewMouseMove="Root_PreviewMouseMove"
PreviewMouseLeftButtonUp="Root_PreviewMouseLeftButtonUp"
LostMouseCapture="Root_LostMouseCapture"
PreviewKeyDown="Root_PreviewKeyDown"
>
<Grid>
<!-- Mapsui MapControl preenche todo o espaço disponível (responsivo) -->
<m:MapControl x:Name="Mapa"/>
@ -17,7 +26,70 @@
<ComboBox x:Name="CmbBaseMap" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,10,70,0" Width="189" SelectionChanged="OnBaseMapChanged"/>
<CheckBox x:Name="chbAcompanhar" Content="Seguir" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,13,10,0"/>
<Button Content="Carregar Mapa" HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,10,10" Padding="8,4" Background="#2ECC71" Foreground="White" FontWeight="Bold" Click="OnLoadMapClicked" Visibility="Collapsed"/>
<Button Content="Carregar Mapa" HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,10,10" Padding="8,4" Background="#2ECC71" Foreground="White" FontWeight="Bold" Visibility="Collapsed"/>
<!--<Button Content="Centralizar" HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,10,10" Padding="8,4" Background="#2ECC71" Foreground="White" FontWeight="Bold" Click="OnCenterAreaClicked"/>-->
<!-- INTERAÇÃO VISUAL DO EDITOR -->
<Canvas x:Name="EditorInteractionCanvas" Background="Transparent" IsHitTestVisible="False" Panel.ZIndex="90">
<!--
Área interna do recorte.
O Polygon já mostra visualmente
como o último ponto será fechado
até o primeiro.
-->
<Polygon
x:Name="EditorCutPolygonFill"
Visibility="Collapsed"
Fill="#38FF3B30"
Stroke="#99FF6B60"
StrokeThickness="1"
IsHitTestVisible="False"/>
<!-- Linha realmente percorrida pelo mouse -->
<Polyline
x:Name="EditorCutPolyline"
Visibility="Collapsed"
Stroke="#FFFF675C"
StrokeThickness="3"
StrokeLineJoin="Round"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
IsHitTestVisible="False"/>
<!--
Ligação visual do último ponto
até o primeiro.
-->
<Line
x:Name="EditorCutClosingLine"
Visibility="Collapsed"
Stroke="#CCFFB0AA"
StrokeThickness="2"
StrokeDashArray="4,3"
IsHitTestVisible="False"/>
<Canvas x:Name="EditorCutVerticesCanvas" IsHitTestVisible="False"/>
<!-- Seleção retangular existente -->
<Border
x:Name="EditorSelectionRectangle"
Visibility="Collapsed"
Background="#334E8EF7"
BorderBrush="#FF6BA6FF"
BorderThickness="2"
CornerRadius="2"/>
</Canvas>
<!-- COCKPIT DO EDITOR DE MAPAS -->
<editing:MapaEditorOverlay x:Name="EditorOverlay" Panel.ZIndex="100"/>
</Grid>
</UserControl>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,722 @@
using NetTopologySuite.Geometries;
using OperationControl.Controls;
using OperationControl.Mapas.Editing;
using OperationControl.Mapas.Interaction;
namespace OperationControl.Mapas
{
public sealed partial class MapaOperacaoController
{
/*
* =========================================================
* CONFIGURAÇÃO DE INTERAÇÃO
* =========================================================
*/
/// <summary>
/// Define explicitamente como o MapViewControl deve
/// interpretar a interação do operador.
///
/// Se a View estiver em uma sessão de edição ativa,
/// o modo Edicao tem prioridade até a sessão terminar.
/// </summary>
public void DefinirModoInteracao(
MapViewControl? mapaControl,
MapaModoInteracao modo)
{
if (
mapaControl?.Mapa?.Map == null
)
{
return;
}
RegistrarMapa(
mapaControl
);
MapaModoInteracao modoEfetivo =
_estadoEdicao.EmEdicao &&
_mapasEmEdicao.Contains(
mapaControl
)
? MapaModoInteracao.Edicao
: modo;
AplicarModoInteracao(
mapaControl,
modoEfetivo
);
}
private void AplicarModoInteracao(
MapViewControl mapaControl,
MapaModoInteracao modo)
{
if (
mapaControl.ModoInteracao ==
modo
)
{
return;
}
var anterior =
mapaControl.ModoInteracao;
mapaControl.DefinirModoInteracao(
modo
);
Log(
"[MAPA][INTERACAO] " +
$"Anterior={anterior} | " +
$"Atual={modo}"
);
}
/*
* =========================================================
* RUA
* =========================================================
*/
private void OnMapaNovoRuaClicada(string ruaId)
{
try
{
/*
* =============================================
* MODO EDIÇÃO
* =============================================
*
* A seleção operacional fica completamente
* intocada.
*/
if (_estadoEdicao.EmEdicao)
{
/*
* Uma rua só responde ao clique direto
* enquanto a ferramenta Selecionar
* estiver ativa.
*/
if (
_ferramentaEdicaoAtual !=
MapaFerramentaEdicao.Selecionar
)
{
return;
}
AlternarRuaEdicao(
ruaId
);
Log(
"[MAPA][EDICAO][CLIQUE] " +
$"Rua={ruaId} | " +
$"Selecionadas=" +
$"{QuantidadeRuasSelecionadasEdicao}"
);
return;
}
/*
* =============================================
* MODO NORMAL
* =============================================
*/
AlternarRuaOperacao(
ruaId
);
string ruas =
string.Join(
" -> ",
ObterRuasPercorrer()
);
Log(
"[MAPA][CLIQUE] " +
$"Rua={ruaId} | " +
$"Percurso=[{ruas}]"
);
}
catch (Exception ex)
{
Log(
"[MAPA][CLIQUE][ERRO] " +
ex.Message
);
}
}
/*
* =========================================================
* RETORNO
* =========================================================
*/
private void OnMapaNovoPontoRetornoClicado(
double latitude,
double longitude)
{
try
{
AlternarPontoRetorno(
latitude,
longitude
);
double distancia =
ObterDistanciaRotaRetornoMetros();
Log(
"[MAPA][RETORNO][CLIQUE] " +
$"Pontos={QuantidadePontosRetorno} | " +
$"Distancia={distancia:F2} m"
);
}
catch (Exception ex)
{
Log(
"[MAPA][RETORNO][CLIQUE][ERRO] " +
ex.Message
);
}
}
/*
* =========================================================
* ENTIDADE
* =========================================================
*/
private void OnMapaNovaEntidadeClicada(
string entidadeId)
{
try
{
var entidade =
ObterEntidade(
entidadeId
);
if (entidade == null)
return;
Log(
"[MAPA][ENTIDADE][CLIQUE] " +
$"Id={entidade.Id} | " +
$"Tipo={entidade.Tipo}"
);
/*
* Fim da responsabilidade do mapa.
*
* Dock decidirá o que significa
* selecionar Base ou Rover.
*/
EntidadeClicada?
.Invoke(
entidade.Id
);
}
catch (Exception ex)
{
Log(
"[MAPA][ENTIDADE][CLIQUE][ERRO] " +
ex.Message
);
}
}
/*
* =========================================================
* VIEWPORT
* =========================================================
*/
private void OnMapaNovoViewportAlterado(
MapViewControl mapaControl)
{
/*
* Somente entidades dependem da resolução
* atual para manter tamanho visual adequado.
*/
RenderizarEntidadesDinamicasNoMapa(
mapaControl
);
mapaControl
.Mapa
.Refresh();
}
private void OnMapaEditorFerramentaSelecionada(MapViewControl mapaControl, MapaFerramentaEdicao ferramenta)
{
if (
!_estadoEdicao.EmEdicao ||
!_mapasEmEdicao.Contains(
mapaControl
)
)
{
return;
}
DefinirFerramentaEdicao(
ferramenta
);
}
private void OnMapaEditorLimparSelecaoSolicitado(MapViewControl mapaControl)
{
if (
!_estadoEdicao.EmEdicao ||
!_mapasEmEdicao.Contains(
mapaControl
)
)
{
return;
}
LimparSelecaoEdicao();
}
private void OnMapaEditorRetanguloSelecionado(MapViewControl mapaControl, MapaRetanguloSelecao retangulo)
{
if (
!_estadoEdicao.EmEdicao ||
!_mapasEmEdicao.Contains(
mapaControl
) ||
_ferramentaEdicaoAtual !=
MapaFerramentaEdicao
.SelecionarRetangulo
)
{
return;
}
try
{
var encontrados =
_edicaoService
.SelecionarPorRetangulo(
_estadoEdicao,
retangulo
.MinLatitude,
retangulo
.MinLongitude,
retangulo
.MaxLatitude,
retangulo
.MaxLongitude,
substituirSelecao:
true
);
foreach (
var mapa
in _mapasEmEdicao
.ToArray())
{
if (
_rendersMapa.TryGetValue(
mapa,
out var render
)
)
{
_edicaoRenderService
.AplicarSelecao(
render,
_estadoEdicao
);
mapa.Mapa.Refresh();
}
}
SincronizarCockpitEdicao();
EstadoEdicaoAlterado?
.Invoke();
Log(
"[MAPA][EDICAO][RETANGULO] " +
$"Encontradas={encontrados.Count} | " +
$"Selecionadas=" +
$"{QuantidadeRuasSelecionadasEdicao}"
);
}
catch (Exception ex)
{
Log(
"[MAPA][EDICAO][RETANGULO][ERRO] " +
ex.Message
);
}
}
private void OnMapaEditorPoligonoRecorteDefinido(MapViewControl mapaControl, MapaPoligonoRecorte recorte)
{
bool ferramentaPoligono =
_ferramentaEdicaoAtual ==
MapaFerramentaEdicao.RecorteLivre
||
_ferramentaEdicaoAtual ==
MapaFerramentaEdicao.RecortePorPontos;
if (
!_estadoEdicao.EmEdicao ||
!_mapasEmEdicao.Contains(
mapaControl
) ||
!ferramentaPoligono
)
{
return;
}
if (
recorte == null ||
recorte.QuantidadePontos < 3
)
{
return;
}
try
{
/*
* Fronteira UI → domínio.
*
* DTO:
* Latitude / Longitude
*
* NTS:
* X = longitude
* Y = latitude
*/
Coordinate[] pontos =
recorte
.Pontos
.Select(
p =>
new Coordinate(
p.Longitude,
p.Latitude
)
)
.ToArray();
/*
* V1:
* o próprio polígono define o escopo.
*
* Depois adicionaremos a opção
* "Somente ruas selecionadas".
*/
if (_estadoRecorte.SomenteSelecionadas && !_estadoEdicao.PossuiSelecao)
{
Log(
"[MAPA][EDICAO][RECORTE][IGNORADO] " +
"Escopo=SomenteSelecionadas | " +
"Nenhuma rua selecionada."
);
SincronizarCockpitEdicao();
return;
}
MapaResultadoRecorte resultado =
_edicaoService
.RecortarPorPoligono(
_estadoEdicao,
pontos,
somenteSelecionadas: _estadoRecorte.SomenteSelecionadas
);
/*
* Mesmo sem alteração podemos ter
* ruas ambíguas importantes para log.
*/
if (!resultado.HouveAlteracao)
{
string tipoRecorte =
_ferramentaEdicaoAtual ==
MapaFerramentaEdicao.RecorteLivre
? "RECORTE-LIVRE"
: "RECORTE-PONTOS";
Log(
$"[MAPA][EDICAO][{tipoRecorte}] " +
$"Escopo={_estadoRecorte.Escopo} | " +
resultado
);
return;
}
/*
* É alteração estrutural/geometria.
*
* Não basta atualizar estilo.
*/
RenderizarDocumentoEdicaoAtual();
SincronizarCockpitEdicao();
EstadoEdicaoAlterado?
.Invoke();
Log(
"[MAPA][EDICAO][RECORTE-LIVRE] " +
resultado
);
}
catch (Exception ex)
{
/*
* O MapaEdicaoService já restaura
* o snapshot transacional em caso
* de erro.
*/
Log(
"[MAPA][EDICAO][RECORTE-LIVRE][ERRO] " +
ex.Message
);
}
}
private void OnMapaEditorDesfazerSolicitado(MapViewControl mapaControl)
{
if (
!_estadoEdicao.EmEdicao ||
!_mapasEmEdicao.Contains(
mapaControl
)
)
{
return;
}
DesfazerEdicao();
}
private void OnMapaEditorRefazerSolicitado(MapViewControl mapaControl)
{
if (
!_estadoEdicao.EmEdicao ||
!_mapasEmEdicao.Contains(
mapaControl
)
)
{
return;
}
RefazerEdicao();
}
private void OnMapaEditorExcluirSelecionadasSolicitado(MapViewControl mapaControl)
{
if (
!_estadoEdicao.EmEdicao ||
!_mapasEmEdicao.Contains(
mapaControl
) ||
!_estadoEdicao.PossuiSelecao
)
{
return;
}
RemoverRuasSelecionadasEdicao();
}
private void OnMapaEditorAplicarEdicaoSolicitado(MapViewControl mapaControl)
{
if (!_estadoEdicao.EmEdicao || !_mapasEmEdicao.Contains(mapaControl))
{
return;
}
AplicarEdicao();
}
private void OnMapaEditorCancelarEdicaoSolicitado(MapViewControl mapaControl)
{
if (!_estadoEdicao.EmEdicao || !_mapasEmEdicao.Contains(mapaControl))
{
return;
}
CancelarEdicao(mapaControl);
}
private void OnMapaEditorEscopoRecorteAlterado(MapViewControl mapaControl, MapaEscopoRecorte escopo)
{
if (!_estadoEdicao.EmEdicao || !_mapasEmEdicao.Contains(mapaControl))
return;
_estadoRecorte.Escopo = escopo;
SincronizarCockpitEdicao();
Log(
"[MAPA][EDICAO][RECORTE][ESCOPO] " +
$"Atual={escopo}"
);
}
private void OnMapaEditorBordaGnssAcaoSolicitada(MapViewControl mapaControl, MapaAcaoBordaGnss acao)
{
if (
!_estadoEdicao.EmEdicao ||
!_mapasEmEdicao.Contains(mapaControl) ||
_ferramentaEdicaoAtual != MapaFerramentaEdicao.RecorteBordaGnss
)
{
return;
}
switch (acao)
{
case MapaAcaoBordaGnss
.RegistrarPonto:
RegistrarPontoBordaGnssManual();
break;
case MapaAcaoBordaGnss
.RemoverUltimo:
RemoverUltimoPontoBordaGnss();
break;
case MapaAcaoBordaGnss
.Finalizar:
FinalizarCapturaBordaGnss();
break;
case MapaAcaoBordaGnss
.Continuar:
ContinuarCapturaBordaGnss();
break;
case MapaAcaoBordaGnss
.Descartar:
DescartarCapturaBordaGnss();
break;
case MapaAcaoBordaGnss.Aplicar:
AplicarBordaGnss();
break;
case MapaAcaoBordaGnss.DefinirModoManual:
DefinirModoCapturaBordaGnss(MapaModoCapturaGnss.Manual);
break;
case MapaAcaoBordaGnss.DefinirModoAutomatico:
DefinirModoCapturaBordaGnss(MapaModoCapturaGnss.Automatico);
break;
case MapaAcaoBordaGnss.IniciarGravacaoAutomatica:
IniciarGravacaoBordaGnssAutomatica();
break;
}
}
private void OnMapaEditorRestaurarFonteSolicitado(MapViewControl mapaControl)
{
if (!_estadoEdicao.EmEdicao || !_mapasEmEdicao.Contains(mapaControl))
{
return;
}
RestaurarMapaFonteEdicao();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,599 @@
using AgroBase.Models;
using AgroBase.Models.Operacoes;
using OperationControl.Controls;
using OperationControl.Mapas.Models;
using OperationControl.Models;
using static AgroBase.Models.Enums;
namespace OperationControl.Mapas
{
public sealed partial class MapaOperacaoController
{
/*
* =========================================================
* BASE
* =========================================================
*/
public void AtualizarBase(
GPSModel? dados,
MapViewControl? mapaAtual = null)
{
if (dados == null)
return;
AtualizarSnapshotGnssLocal(dados);
_entidadeService
.DefinirBase(
_estadoDinamico,
VariaveisControleOperacao
.BaseMarkerID,
"Base",
dados.Latitude,
dados.Longitude,
dados.OrientacaoReal,
dados.LatitudeAnt,
dados.LongitudeAnt
);
if (
mapaAtual?.Mapa?.Map != null
)
{
RegistrarMapa(
mapaAtual
);
RenderizarReferenciasGnssNoMapa(
mapaAtual
);
RenderizarEntidadesDinamicasNoMapa(
mapaAtual
);
mapaAtual.Mapa.Refresh();
}
}
/*
* =========================================================
* ROVER
* =========================================================
*/
public void AtualizarRover(
OperacaoParametrosModel? rover,
MapViewControl? mapaAtual = null)
{
if (
rover == null ||
string.IsNullOrWhiteSpace(
rover.RoverId
)
)
{
return;
}
AtualizarRoverNoEstadoDinamico(
rover
);
AtualizarMovimentoRover(
rover
);
if (
mapaAtual?.Mapa?.Map != null
)
{
RegistrarMapa(
mapaAtual
);
/*
* Ordem proposital.
*/
RenderizarReferenciasGnssNoMapa(
mapaAtual
);
RenderizarMovimentoNoMapa(
mapaAtual
);
RenderizarEntidadesDinamicasNoMapa(
mapaAtual
);
mapaAtual.Mapa.Refresh();
}
}
public void SincronizarRovers(
IEnumerable<OperacaoParametrosModel>?
rovers,
MapViewControl? mapaAtual = null)
{
var lista =
rovers?
.Where(
x =>
x != null &&
!string.IsNullOrWhiteSpace(
x.RoverId
)
)
.ToList()
??
new List<
OperacaoParametrosModel>();
var idsAtuais =
lista
.Select(
x => x.RoverId.Trim()
)
.ToHashSet(
StringComparer.OrdinalIgnoreCase
);
foreach (
var rover
in lista)
{
AtualizarRoverNoEstadoDinamico(
rover
);
}
/*
* Remove somente entidades dinâmicas.
*
* O rastro histórico continua disponível
* mesmo que um rover desapareça momentaneamente
* da lista da rede.
*/
var remover =
_estadoDinamico
.Entidades
.Where(
x =>
x.Tipo ==
MapaTipoEntidadeDinamica.Rover &&
!idsAtuais.Contains(
x.Id
)
)
.Select(
x => x.Id
)
.ToList();
foreach (
string id
in remover)
{
_entidadeService
.Remover(
_estadoDinamico,
id
);
}
if (
mapaAtual?.Mapa?.Map != null
)
{
RegistrarMapa(
mapaAtual
);
RenderizarReferenciasGnssNoMapa(
mapaAtual
);
RenderizarEntidadesDinamicasNoMapa(
mapaAtual
);
mapaAtual.Mapa.Refresh();
}
}
private void AtualizarRoverNoEstadoDinamico(
OperacaoParametrosModel rover)
{
var obj =
rover.DadosLeitura;
var gnss =
obj?.Gnss;
_entidadeService
.DefinirRover(
_estadoDinamico,
rover.RoverId,
string.IsNullOrWhiteSpace(
rover.Descricao
)
? rover.RoverId
: rover.Descricao,
gnss?.Latitude,
gnss?.Longitude,
gnss?.OrientacaoReal,
gnss?.LatitudeAnt,
gnss?.LongitudeAnt
);
if (obj?.Operacao != null)
{
_entidadeService
.AtualizarStatus(
_estadoDinamico,
rover.RoverId,
obj.Operacao.Status
);
}
}
private void AtualizarMovimentoRover(
OperacaoParametrosModel rover)
{
var dados =
rover.DadosLeitura;
if (dados == null)
return;
/*
* =====================================================
* RASTRO
* =====================================================
*/
double? latitude =
dados.Gnss?.Latitude;
double? longitude =
dados.Gnss?.Longitude;
bool coordenadaValida =
latitude.HasValue &&
longitude.HasValue &&
double.IsFinite(
latitude.Value
) &&
double.IsFinite(
longitude.Value
) &&
latitude.Value >= -90.0 &&
latitude.Value <= 90.0 &&
longitude.Value >= -180.0 &&
longitude.Value <= 180.0;
bool pulverizando =
dados.Atuador?
.Bicos?
.Any(
x =>
x.ComandoEstado ==
true
)
== true;
if (coordenadaValida)
{
_movimentoService
.AtualizarPosicao(
_estadoMovimento,
rover.RoverId,
latitude!.Value,
longitude!.Value,
pulverizando,
distanciaMinimaMetros:
0.10,
distanciaMaximaSegmentoMetros:
20.0
);
}
/*
* =====================================================
* MPC
* =====================================================
*/
var predicao =
dados.Controle?
.SimulacaoMPC?
.Where(
x =>
double.IsFinite(
x.latitude
) &&
double.IsFinite(
x.longitude
) &&
x.latitude >= -90.0 &&
x.latitude <= 90.0 &&
x.longitude >= -180.0 &&
x.longitude <= 180.0
)
.Select(
x =>
new MapaPontoWgs84(
x.latitude,
x.longitude
)
)
.ToList();
_movimentoService
.DefinirPredicao(
_estadoMovimento,
rover.RoverId,
predicao
);
}
/*
* =========================================================
* FOCO
* =========================================================
*/
public void DefinirFocoEntidade(
string? entidadeId,
MapViewControl? mapaAtual = null)
{
_entidadeService
.DefinirFoco(
_estadoDinamico,
entidadeId
);
if (
mapaAtual?.Mapa?.Map != null
)
{
RegistrarMapa(
mapaAtual
);
RenderizarEntidadesDinamicasNoMapa(
mapaAtual
);
mapaAtual.Mapa.Refresh();
}
Log(
"[MAPA][FOCO] " +
$"Id={(entidadeId ?? "NENHUM")}"
);
}
public MapaEntidadeDinamica?
ObterEntidade(
string? entidadeId)
{
if (
string.IsNullOrWhiteSpace(
entidadeId
)
)
{
return null;
}
return _entidadeService
.Obter(
_estadoDinamico,
entidadeId
);
}
/*
* =========================================================
* STATUS EXTERNO
* =========================================================
*/
public void AtualizarStatusRoverMapa(
string? roverId,
StatusOperacao status,
MapViewControl? mapaAtual = null)
{
if (
string.IsNullOrWhiteSpace(
roverId
)
)
{
return;
}
var entidade =
_entidadeService
.Obter(
_estadoDinamico,
roverId
);
if (entidade == null)
return;
_entidadeService
.AtualizarStatus(
_estadoDinamico,
roverId,
status
);
if (
mapaAtual?.Mapa?.Map != null
)
{
RenderizarEntidadesDinamicasNoMapa(
mapaAtual
);
mapaAtual.Mapa.Refresh();
}
else
{
foreach (
var mapa
in _mapasRegistrados
.ToArray())
{
RenderizarEntidadesDinamicasNoMapa(
mapa
);
mapa.Mapa.Refresh();
}
}
}
/*
* =========================================================
* MOVIMENTO
* =========================================================
*/
public void LimparMovimentoRover(
string? roverId,
bool limparPredicao = true)
{
if (
string.IsNullOrWhiteSpace(
roverId
)
)
{
return;
}
_movimentoService
.LimparRastro(
_estadoMovimento,
roverId,
limparPredicao
);
foreach (
var mapa
in _mapasRegistrados.ToArray())
{
RenderizarMovimentoNoMapa(
mapa
);
GarantirEntidadesNoTopo(
mapa
);
mapa.Mapa.Refresh();
}
}
/*
* =========================================================
* REFERÊNCIA GNSS
* =========================================================
*/
public void DefinirExibicaoReferenciasGnss(
bool mostrar)
{
if (
_mostrarReferenciasGnss ==
mostrar
)
{
return;
}
_mostrarReferenciasGnss =
mostrar;
foreach (
var mapa
in _mapasRegistrados.ToArray())
{
RenderizarReferenciasGnssNoMapa(
mapa
);
GarantirEntidadesNoTopo(
mapa
);
mapa.Mapa.Refresh();
}
}
}
}

View File

@ -0,0 +1,767 @@
using AgroBase.Models;
using AgroBase.Models.Operacoes;
using Mapsui.Layers;
using OperationControl.Controls;
using OperationControl.Mapas.Editing;
using OperationControl.Mapas.Interaction;
using OperationControl.Mapas.Models;
using OperationControl.Mapas.Rendering;
using OperationControl.Mapas.Services;
using static AgroBase.Models.Enums;
namespace OperationControl.Mapas
{
public sealed partial class MapaOperacaoController
{
/*
* =========================================================
* EVENTOS
* =========================================================
*/
/// <summary>
/// Informa à aplicação que uma entidade dinâmica
/// foi clicada no mapa.
///
/// O Controller NÃO decide qual Rover deve ser
/// selecionado na aplicação.
/// </summary>
public event Action<string>? EntidadeClicada;
/// <summary>
/// Permite ao Dock encaminhar mensagens do
/// subsistema de mapas para o logger da aplicação.
/// </summary>
public event Action<string>? LogGerado;
public event Action? EstadoEdicaoAlterado;
/// <summary>
/// Disparado quando algum estado estrutural do mapa
/// muda e uma interface externa pode precisar
/// atualizar seus indicadores.
///
/// Exemplos:
/// mapa carregado,
/// mapa limpo,
/// seleção operacional alterada.
/// </summary>
public event Action? EstadoMapaAlterado;
public event Action<MapaResultadoValidacao>? ValidacaoEdicaoFalhou;
/*
* =========================================================
* ESTADOS
* =========================================================
*/
private readonly MapaWorkspace _workspace = new();
private readonly MapaEstadoDinamico _estadoDinamico = new();
private readonly MapaEstadoMovimento _estadoMovimento = new();
private readonly MapaEstadoEdicao _estadoEdicao = new();
private readonly MapaEstadoRecorte _estadoRecorte = new();
private MapaFerramentaEdicao _ferramentaEdicaoAtual = MapaFerramentaEdicao.Nenhuma;
/*
* =========================================================
* SERVICES
* =========================================================
*/
private readonly MapaImportService _importService = new();
private readonly MapaSelectionService _selectionService = new();
private readonly MapaExportService _exportService = new();
private readonly MapaContratoImportService _contratoImportService = new();
private readonly MapaRotaRetornoService _rotaRetornoService = new();
private readonly MapaEntidadeDinamicaService _entidadeService = new();
private readonly MapaMovimentoService _movimentoService = new();
private readonly MapaEdicaoService _edicaoService = new();
private readonly MapaDocumentoCloneService _documentoCloneService = new();
private readonly MapaEstimativaOperacaoService _estimativaOperacaoService = new();
private readonly MapaValidationService _validationService = new();
/*
* =========================================================
* RENDERERS
* =========================================================
*/
private readonly MapaRenderService _renderService = new();
private readonly MapaRotaRetornoRenderService _rotaRenderService = new();
private readonly MapaEntidadeDinamicaRenderService _entidadeRenderService = new();
private readonly MapaRastroRenderService _rastroRenderService = new();
private readonly MapaPredicaoMpcRenderService _predicaoRenderService = new();
private readonly MapaReferenciaGnssRenderService _referenciaGnssRenderService = new();
private readonly MapaEdicaoRenderService _edicaoRenderService = new();
/*
* =========================================================
* ESTADO DE RENDER POR MAPVIEW
* =========================================================
*/
private readonly Dictionary<
MapViewControl,
MapaRenderResult>
_rendersMapa =
new();
private readonly Dictionary<
MapViewControl,
MemoryLayer>
_layersRetorno =
new();
private readonly Dictionary<
MapViewControl,
MemoryLayer>
_layersEntidades =
new();
private readonly Dictionary<
MapViewControl,
MemoryLayer>
_layersRastro =
new();
private readonly Dictionary<
MapViewControl,
MemoryLayer>
_layersPredicao =
new();
private readonly Dictionary<
MapViewControl,
MemoryLayer>
_layersReferenciaGnss =
new();
/*
* MapViews que já passaram pelo Controller.
*
* Não significa que estão visíveis neste instante.
*/
private readonly HashSet<MapViewControl>
_mapasRegistrados =
new();
private readonly HashSet<MapViewControl> _mapasEmEdicao = new();
/*
* Modo anterior ao início da edição.
*
* Permite ao Controller restaurar exatamente o contexto
* da View quando a sessão de edição for cancelada.
*/
private readonly Dictionary<
MapViewControl,
MapaModoInteracao>
_modoInteracaoAntesEdicao =
new();
/*
* =========================================================
* PREFERÊNCIAS VISUAIS
* =========================================================
*/
private bool
_mostrarReferenciasGnss =
true;
/*
* =========================================================
* PROPRIEDADES PÚBLICAS
* =========================================================
*/
public bool PossuiDocumentoFonte => _workspace.PossuiDocumentoFonte;
public int QuantidadeRuasFonte => _workspace.QuantidadeRuasFonte;
public bool PossuiMapa =>
_workspace.PossuiMapa;
public MapaDocumento? Documento =>
_workspace.Documento;
public TipoMapaOperacao TipoMapa =>
_workspace.Documento?.Tipo
?? TipoMapaOperacao.Indefinido;
public int QuantidadeRuas =>
_workspace.Documento?
.QuantidadeRuas
?? 0;
public int QuantidadeRuasSelecionadas =>
_workspace.Selecao?
.Quantidade
?? 0;
public bool PossuiPontosRetorno =>
_workspace.RotaRetorno?
.PossuiPontos
?? false;
public int QuantidadePontosRetorno =>
_workspace.RotaRetorno?
.Quantidade
?? 0;
public bool MostrarReferenciasGnss =>
_mostrarReferenciasGnss;
public MapaFerramentaEdicao FerramentaEdicaoAtual => _ferramentaEdicaoAtual;
/*
* =========================================================
* EDIÇÃO
* =========================================================
*/
public bool EdicaoAtiva =>
_estadoEdicao.EmEdicao;
public bool EdicaoModificada =>
_estadoEdicao.Modificado;
public int QuantidadeRuasSelecionadasEdicao =>
_estadoEdicao.QuantidadeSelecionadas;
public bool CapturaBordaGnssAutomaticaAtiva => _estadoRecorte.GravacaoAutomaticaAtiva;
public MapaDocumento? DocumentoEmEdicao => _estadoEdicao.DocumentoTrabalho;
public IReadOnlyCollection<string> RuasSelecionadasEdicao => _estadoEdicao.RuasSelecionadas;
/*
* =========================================================
* CARREGAMENTO
* =========================================================
*/
public MapaDocumento CarregarMapaArquivo(string caminhoArquivo, TipoMapaOperacao tipo)
{
if (
string.IsNullOrWhiteSpace(
caminhoArquivo
)
)
{
throw new ArgumentException(
"Caminho do mapa inválido.",
nameof(caminhoArquivo)
);
}
/*
* Importa primeiro.
*
* Se falhar, o Workspace atual permanece intacto.
*/
var documentoImportado =
_importService.Importar(
caminhoArquivo,
tipo
);
/*
* O objeto retornado pelo importador é apenas
* matéria-prima desta transação.
*
* Nenhuma das duas cópias persistentes
* compartilha estado mutável.
*/
var documentoFonte =
_documentoCloneService
.Clonar(
documentoImportado
);
var documentoAtivo =
_documentoCloneService
.Clonar(
documentoImportado
);
/*
* Um mapa novo invalida qualquer sessão
* de edição anterior.
*/
CancelarEdicao();
InvalidarRendersWorkspace();
InvalidarRendersRotaRetorno();
_workspace.DefinirNovoDocumento(
documentoFonte,
documentoAtivo
);
EstadoMapaAlterado?.Invoke();
Log(
"[MAPA] " +
$"Arquivo carregado | " +
$"Fonte={documentoFonte.QuantidadeRuas} | " +
$"Oficial={documentoAtivo.QuantidadeRuas} | " +
$"Nome={documentoAtivo.Nome}"
);
return documentoAtivo;
}
/// <summary>
/// Reconstrói o Workspace a partir do contrato
/// operacional recebido do rover.
///
/// Retorna null quando o rover não possui mapa.
/// </summary>
public MapaDocumento?
CarregarMapaDoRover(
OperacaoParametrosModel dados)
{
if (dados == null)
{
throw new ArgumentNullException(
nameof(dados)
);
}
/*
* Rover sem mapa.
*/
if (
dados.Mapa?.features?.Any()
!= true
)
{
CancelarEdicao();
InvalidarRendersWorkspace();
InvalidarRendersRotaRetorno();
_workspace.Limpar();
EstadoMapaAlterado?.Invoke();
Log(
"[MAPA][ROVER] " +
"Rover não possui mapa operacional."
);
return null;
}
/*
* Toda reconstrução acontece fora
* do estado oficial primeiro.
*/
var documentoImportado =
_contratoImportService.Importar(
dados.Mapa,
dados.TipoMapa,
$"Mapa rover {dados.RoverId}"
);
var documentoFonte =
_documentoCloneService
.Clonar(
documentoImportado
);
var novoDocumento =
_documentoCloneService
.Clonar(
documentoImportado
);
var novaSelecao =
new MapaSelecaoOperacao();
_selectionService.DefinirSelecao(
novoDocumento,
novaSelecao,
dados.RuasPercorrer
?? Enumerable.Empty<string>()
);
var novaRotaRetorno =
new MapaRotaRetorno();
_rotaRetornoService
.DefinirPeloContrato(
novaRotaRetorno,
dados.PontosRetorno
);
/*
* Só agora alteramos o estado oficial.
*/
CancelarEdicao();
InvalidarRendersWorkspace();
InvalidarRendersRotaRetorno();
_workspace.DefinirNovoEstado(
documentoFonte,
novoDocumento,
novaSelecao,
novaRotaRetorno
);
EstadoMapaAlterado?.Invoke();
Log(
"[MAPA][ROVER] " +
$"Mapa recebido | " +
$"Fonte={documentoFonte.QuantidadeRuas} | " +
$"Oficial={novoDocumento.QuantidadeRuas} | " +
$"Selecionadas={novaSelecao.Quantidade} | " +
$"Percurso=[" +
$"{string.Join(" -> ", novaSelecao.RuasOrdenadas)}]"
);
return novoDocumento;
}
public void LimparMapa()
{
CancelarEdicao();
InvalidarRendersWorkspace();
InvalidarRendersRotaRetorno();
_workspace.Limpar();
EstadoMapaAlterado?.Invoke();
/*
* Os estados de Base/Rover e movimento
* NÃO pertencem ao mapa operacional.
*
* Portanto não são apagados aqui.
*/
Log(
"[MAPA] Workspace limpo."
);
}
/*
* =========================================================
* CONTRATO OPERACIONAL
* =========================================================
*/
public MapaFeatureCollectionModel?
CriarMapaOperacional()
{
if (
_workspace.Documento == null
)
{
return null;
}
return _exportService
.CriarMapaOperacional(
_workspace.Documento
);
}
public List<string>
ObterRuasPercorrer()
{
if (
_workspace.Documento == null
)
{
return new List<string>();
}
return _selectionService
.ObterRuasOrdenadas(
_workspace.Selecao
)
.ToList();
}
/*
* =========================================================
* SELEÇÃO OPERACIONAL
* =========================================================
*/
public bool AlternarRuaOperacao(
string ruaId)
{
if (
_workspace.Documento == null
)
{
throw new InvalidOperationException(
"Não existe mapa operacional carregado."
);
}
bool selecionada =
_selectionService
.AlternarRua(
_workspace.Documento,
_workspace.Selecao,
ruaId
);
AtualizarEstiloSelecaoRua(
ruaId
);
EstadoMapaAlterado?.Invoke();
int? ordem =
_selectionService
.ObterOrdem(
_workspace.Selecao,
ruaId
);
Log(
"[MAPA][SELECAO] " +
$"Rua={ruaId} | " +
$"Selecionada={selecionada} | " +
$"Ordem={(ordem?.ToString() ?? "-")}"
);
return selecionada;
}
public void LimparSelecaoOperacao()
{
_selectionService.Limpar(
_workspace.Selecao
);
foreach (
var item
in _rendersMapa.ToArray())
{
_renderService
.AplicarSelecao(
item.Value,
_workspace.Selecao
);
item.Key.Mapa.Refresh();
}
EstadoMapaAlterado?.Invoke();
Log(
"[MAPA][SELECAO] Seleção limpa."
);
}
/*
* =========================================================
* ROTA DE RETORNO
* =========================================================
*/
public bool AlternarPontoRetorno(
double latitude,
double longitude)
{
bool adicionado =
_rotaRetornoService
.AlternarPonto(
_workspace.RotaRetorno,
latitude,
longitude
);
RenderizarRotasRegistradas();
Log(
"[MAPA][RETORNO] " +
$"Lat={latitude:F8} | " +
$"Lon={longitude:F8} | " +
$"Acao={(adicionado ? "ADICIONADO" : "REMOVIDO")} | " +
$"Pontos={QuantidadePontosRetorno}"
);
return adicionado;
}
public void LimparRotaRetorno()
{
_rotaRetornoService.Limpar(
_workspace.RotaRetorno
);
RenderizarRotasRegistradas();
Log(
"[MAPA][RETORNO] Rota limpa."
);
}
public double
ObterDistanciaRotaRetornoMetros()
{
return _rotaRetornoService
.ObterDistanciaTotalMetros(
_workspace.RotaRetorno
);
}
public List<double[]>
ExportarRotaRetorno()
{
return _rotaRetornoService
.ExportarContrato(
_workspace.RotaRetorno
)
.ToList();
}
public MapaEstimativaOperacao CalcularEstimativaOperacao(double? velocidadeSemErvasMs = null, double? velocidadeComErvasMs = null, double percentualErvas = 0)
{
try
{
return _estimativaOperacaoService
.Calcular(
_workspace.Documento,
_workspace.Selecao,
velocidadeSemErvasMs,
velocidadeComErvasMs,
percentualErvas
);
}
catch (Exception ex)
{
Log(
"[MAPA][ESTIMATIVA][ERRO] " +
ex.Message
);
return new MapaEstimativaOperacao
{
QuantidadeRuasSelecionadas =
_workspace
.Selecao
.Quantidade,
MotivoIndisponivel =
ex.Message
};
}
}
/*
* =========================================================
* LOG
* =========================================================
*/
private void Log(
string mensagem)
{
LogGerado?.Invoke(
mensagem
);
}
}
}

View File

@ -0,0 +1,20 @@
public enum MapaAcaoBordaGnss
{
RegistrarPonto = 0,
RemoverUltimo = 1,
Finalizar = 2,
Continuar = 3,
Descartar = 4,
Aplicar = 5,
DefinirModoManual = 6,
DefinirModoAutomatico = 7,
IniciarGravacaoAutomatica = 8
}

View File

@ -0,0 +1,85 @@
using OperationControl.Mapas.Models;
namespace OperationControl.Mapas.Editing
{
internal sealed class MapaEdicaoSnapshot
{
public MapaDocumento Documento
{
get;
}
public IReadOnlyCollection<string>
RuasSelecionadas
{
get;
}
public MapaEdicaoSnapshot(
MapaDocumento documento,
IEnumerable<string> ruasSelecionadas)
{
Documento =
documento
?? throw new ArgumentNullException(
nameof(documento)
);
RuasSelecionadas =
new HashSet<string>(
ruasSelecionadas
?? Array.Empty<string>(),
StringComparer.OrdinalIgnoreCase
);
}
}
internal sealed class MapaEdicaoHistoricoItem
{
public string Descricao
{
get;
}
public MapaEdicaoSnapshot Antes
{
get;
}
public MapaEdicaoSnapshot Depois
{
get;
}
public MapaEdicaoHistoricoItem(
string descricao,
MapaEdicaoSnapshot antes,
MapaEdicaoSnapshot depois)
{
Descricao =
string.IsNullOrWhiteSpace(
descricao
)
? "Alteração no mapa"
: descricao.Trim();
Antes =
antes
?? throw new ArgumentNullException(
nameof(antes)
);
Depois =
depois
?? throw new ArgumentNullException(
nameof(depois)
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
namespace OperationControl.Mapas.Editing
{
public enum MapaEscopoRecorte
{
TodasRuasAtingidas = 0,
SomenteRuasSelecionadas = 1
}
}

View File

@ -0,0 +1,94 @@
using OperationControl.Mapas.Models;
namespace OperationControl.Mapas.Editing
{
public sealed class MapaEstadoEdicao
{
private readonly HashSet<string>
_ruasSelecionadas =
new(
StringComparer.OrdinalIgnoreCase
);
public MapaDocumento?
DocumentoOriginal
{
get;
internal set;
}
public MapaDocumento?
DocumentoTrabalho
{
get;
internal set;
}
public IReadOnlyCollection<string>
RuasSelecionadas =>
_ruasSelecionadas;
public bool EmEdicao =>
DocumentoTrabalho != null;
public bool PossuiSelecao =>
_ruasSelecionadas.Count > 0;
public int QuantidadeSelecionadas =>
_ruasSelecionadas.Count;
public bool Modificado =>
DocumentoTrabalho?
.Modificado
?? false;
internal HashSet<string>
RuasSelecionadasMutaveis =>
_ruasSelecionadas;
private readonly List<MapaEdicaoHistoricoItem>
_historicoDesfazer =
new();
private readonly List<MapaEdicaoHistoricoItem>
_historicoRefazer =
new();
public bool PodeDesfazer =>
_historicoDesfazer.Count > 0;
public bool PodeRefazer =>
_historicoRefazer.Count > 0;
public int QuantidadeHistoricoDesfazer =>
_historicoDesfazer.Count;
public int QuantidadeHistoricoRefazer =>
_historicoRefazer.Count;
internal List<MapaEdicaoHistoricoItem>
HistoricoDesfazerMutavel =>
_historicoDesfazer;
internal List<MapaEdicaoHistoricoItem>
HistoricoRefazerMutavel =>
_historicoRefazer;
}
}

View File

@ -0,0 +1,341 @@
using NetTopologySuite.Geometries;
namespace OperationControl.Mapas.Editing
{
public sealed class MapaEstadoRecorte
{
public double DistanciaMinimaCapturaAutomaticaMetros
{
get;
internal set;
} = 0.50;
public MapaEscopoRecorte Escopo
{
get;
internal set;
} =
MapaEscopoRecorte
.TodasRuasAtingidas;
public bool SomenteSelecionadas =>
Escopo ==
MapaEscopoRecorte
.SomenteRuasSelecionadas;
private readonly List<Coordinate> _pontos = new();
private bool _bordaGnssFinalizada;
public MapaTipoRecorte Tipo
{
get;
internal set;
} =
MapaTipoRecorte.Nenhum;
public MapaModoCapturaGnss ModoCapturaGnss
{
get;
internal set;
} =
MapaModoCapturaGnss.Manual;
public bool CapturaAtiva
{
get;
internal set;
}
public IReadOnlyList<Coordinate> Pontos =>
_pontos;
public int QuantidadePontos =>
_pontos.Count;
public bool PossuiPontos =>
_pontos.Count > 0;
/*
* Para polígono precisamos de pelo menos
* três vértices distintos.
*/
public bool PodeConcluirPoligono =>
_pontos.Count >= 3;
/*
* Para uma borda GNSS aberta precisamos
* de pelo menos dois pontos.
*/
public bool PodeConcluirLinha =>
_pontos.Count >= 2;
internal List<Coordinate> PontosMutaveis => _pontos;
public bool BordaGnssFinalizada => Tipo == MapaTipoRecorte.BordaGnss && _bordaGnssFinalizada;
public bool GravacaoAutomaticaAtiva => Tipo == MapaTipoRecorte.BordaGnss && ModoCapturaGnss == MapaModoCapturaGnss.Automatico && CapturaAtiva && !_bordaGnssFinalizada;
internal void AtivarBordaGnssManual()
{
/*
* Primeira entrada na ferramenta.
*/
if (Tipo != MapaTipoRecorte.BordaGnss)
{
_pontos.Clear();
Tipo = MapaTipoRecorte.BordaGnss;
ModoCapturaGnss = MapaModoCapturaGnss.Manual;
CapturaAtiva = true;
_bordaGnssFinalizada = false;
return;
}
/*
* Se já existia uma borda, preservamos
* exatamente o estado.
*
* Isso permite sair da ferramenta,
* selecionar ruas e voltar sem perder
* a caminhada.
*/
}
internal void AdicionarPontoBordaGnss(Coordinate ponto)
{
if (
Tipo !=
MapaTipoRecorte.BordaGnss ||
!CapturaAtiva
)
{
throw new InvalidOperationException(
"A captura de borda GNSS não está ativa."
);
}
_pontos.Add(
new Coordinate(
ponto.X,
ponto.Y
)
);
}
internal bool RemoverUltimoPontoBordaGnss()
{
if (
Tipo !=
MapaTipoRecorte.BordaGnss ||
_bordaGnssFinalizada ||
_pontos.Count == 0
)
{
return false;
}
/*
* Durante gravação automática não deixamos
* o usuário disputar com o gravador.
*/
if (
ModoCapturaGnss ==
MapaModoCapturaGnss.Automatico &&
CapturaAtiva
)
{
return false;
}
_pontos.RemoveAt(
_pontos.Count - 1
);
return true;
}
internal bool FinalizarBordaGnss()
{
if (
Tipo !=
MapaTipoRecorte.BordaGnss ||
!CapturaAtiva ||
!PodeConcluirLinha
)
{
return false;
}
CapturaAtiva =
false;
_bordaGnssFinalizada =
true;
return true;
}
internal bool ContinuarBordaGnss()
{
if (
Tipo !=
MapaTipoRecorte.BordaGnss ||
!_bordaGnssFinalizada
)
{
return false;
}
_bordaGnssFinalizada =
false;
/*
* Manual volta imediatamente a aceitar pontos.
*
* Automático volta para "pronto",
* aguardando Iniciar gravação.
*/
CapturaAtiva =
ModoCapturaGnss ==
MapaModoCapturaGnss.Manual;
return true;
}
internal void DescartarBordaGnss()
{
_pontos.Clear();
_bordaGnssFinalizada =
false;
/*
* Manual já fica pronto.
* Automático espera Iniciar novamente.
*/
CapturaAtiva =
ModoCapturaGnss ==
MapaModoCapturaGnss.Manual;
}
internal bool DefinirModoCapturaGnss(MapaModoCapturaGnss modo)
{
if (
Tipo !=
MapaTipoRecorte.BordaGnss ||
_bordaGnssFinalizada
)
{
return false;
}
if (
ModoCapturaGnss ==
modo
)
{
return false;
}
ModoCapturaGnss =
modo;
/*
* MANUAL:
* operador já pode registrar pontos.
*
* AUTOMÁTICO:
* espera explicitamente o botão
* "Iniciar gravação".
*/
CapturaAtiva =
modo ==
MapaModoCapturaGnss.Manual;
return true;
}
internal bool IniciarGravacaoAutomatica()
{
if (
Tipo !=
MapaTipoRecorte.BordaGnss ||
ModoCapturaGnss !=
MapaModoCapturaGnss.Automatico ||
_bordaGnssFinalizada ||
CapturaAtiva
)
{
return false;
}
CapturaAtiva =
true;
return true;
}
internal void Limpar()
{
_pontos.Clear();
Tipo = MapaTipoRecorte.Nenhum;
ModoCapturaGnss = MapaModoCapturaGnss.Manual;
CapturaAtiva = false;
Escopo = MapaEscopoRecorte.TodasRuasAtingidas;
_bordaGnssFinalizada = false;
}
}
}

View File

@ -0,0 +1,41 @@
namespace OperationControl.Mapas.Editing
{
/// <summary>
/// Ferramenta atualmente ativa dentro
/// de uma sessão de edição do mapa.
/// </summary>
public enum MapaFerramentaEdicao
{
Nenhuma = 0,
Selecionar = 1,
SelecionarRetangulo = 2,
SelecionarPoligono = 3,
/*
* Contorno livre desenhado com
* clique + arraste.
*/
RecorteLivre = 4,
/*
* Futuro:
* polígono formado por cliques.
*/
RecortePorPontos = 5,
/*
* Futuro:
* linha real obtida por GNSS.
*/
RecorteBordaGnss = 6,
/*
* Alias temporário para qualquer código
* antigo que ainda utilize Cortar.
*/
Cortar = RecorteLivre
}
}

View File

@ -0,0 +1,8 @@
namespace OperationControl.Mapas.Editing
{
public enum MapaModoCapturaGnss
{
Manual = 0,
Automatico = 1
}
}

View File

@ -0,0 +1,77 @@
namespace OperationControl.Mapas.Editing
{
public sealed class MapaResultadoRecorte
{
public int RuasAnalisadas
{
get;
internal set;
}
public int RuasIgnoradas
{
get;
internal set;
}
public List<string>
RuasAlteradasIds
{
get;
} = new();
public List<string>
RuasRemovidasIds
{
get;
} = new();
public List<string>
RuasAmbiguasIds
{
get;
} = new();
public int RuasAlteradas =>
RuasAlteradasIds.Count;
public int RuasRemovidas =>
RuasRemovidasIds.Count;
public int RuasAmbiguas =>
RuasAmbiguasIds.Count;
public double
ComprimentoRemovidoMetros
{
get;
internal set;
}
public bool HouveAlteracao =>
RuasAlteradas > 0 ||
RuasRemovidas > 0;
public override string ToString()
{
return
$"Analisadas={RuasAnalisadas} | " +
$"Alteradas={RuasAlteradas} | " +
$"Removidas={RuasRemovidas} | " +
$"Ignoradas={RuasIgnoradas} | " +
$"Ambíguas={RuasAmbiguas} | " +
$"Removido=" +
$"{ComprimentoRemovidoMetros:F2} m";
}
}
}

View File

@ -0,0 +1,76 @@
namespace OperationControl.Mapas.Editing
{
public sealed class MapaResumoBordaGnss
{
public MapaModoCapturaGnss Modo
{
get;
init;
} =
MapaModoCapturaGnss.Manual;
public int QuantidadePontos
{
get;
init;
}
public double ComprimentoMetros
{
get;
init;
}
public bool CapturaAtiva
{
get;
init;
}
public bool Finalizada
{
get;
init;
}
public bool LeituraGnssValida
{
get;
init;
}
public bool QualidadeAlta
{
get;
init;
}
public string StatusGnss
{
get;
init;
} = "GNSS sem leitura";
public bool GravacaoAutomaticaAtiva
{
get;
init;
}
public double DistanciaMinimaAutomaticaMetros
{
get;
init;
}
}
}

View File

@ -0,0 +1,28 @@
namespace OperationControl.Mapas.Editing
{
public enum MapaTipoRecorte
{
Nenhum = 0,
/*
* Mouse pressionado e arrastado.
* Ao finalizar, fecha automaticamente
* o polígono.
*/
Livre = 1,
/*
* Vértices inseridos manualmente
* por cliques no mapa.
* Enter conclui o polígono.
*/
PorPontos = 2,
/*
* Linha aberta formada por posições GNSS.
* Cada rua cruzada é aparada mantendo
* o maior fragmento.
*/
BordaGnss = 3
}
}

View File

@ -0,0 +1,794 @@
<UserControl x:Class="OperationControl.Mapas.Editing.Views.MapaEditorOverlay"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Visibility="Collapsed"
HorizontalAlignment="Left"
VerticalAlignment="Stretch">
<UserControl.Resources>
<Style x:Key="ToolButtonStyle" TargetType="ToggleButton">
<Setter Property="Height"
Value="36"/>
<Setter Property="MinWidth"
Value="105"/>
<Setter Property="Padding"
Value="12,5"/>
<Setter Property="Margin"
Value="0,0,8,0"/>
<Setter Property="Foreground"
Value="#D0D0D0"/>
<Setter Property="Background"
Value="#2A2A2A"/>
<Setter Property="BorderBrush"
Value="#505050"/>
<Setter Property="BorderThickness"
Value="1"/>
<Setter Property="Cursor"
Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="7">
<ContentPresenter
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsChecked"
Value="True">
<Setter Property="Background"
Value="#27573A"/>
<Setter Property="BorderBrush"
Value="#48A868"/>
<Setter Property="Foreground"
Value="White"/>
</Trigger>
<Trigger Property="IsMouseOver"
Value="True">
<Setter Property="BorderBrush"
Value="#707070"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="EditorButtonStyle" TargetType="Button">
<Setter Property="Height"
Value="36"/>
<Setter Property="MinWidth"
Value="105"/>
<Setter Property="Padding"
Value="12,5"/>
<Setter Property="Foreground"
Value="#D8D8D8"/>
<Setter Property="Background"
Value="#2A2A2A"/>
<Setter Property="BorderBrush"
Value="#505050"/>
<Setter Property="BorderThickness"
Value="1"/>
<Setter Property="Cursor"
Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="7">
<ContentPresenter
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver"
Value="True">
<Setter Property="Background"
Value="#343434"/>
<Setter Property="BorderBrush"
Value="#707070"/>
</Trigger>
<Trigger Property="IsEnabled"
Value="False">
<Setter Property="Opacity"
Value="0.4"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="DangerButtonStyle" TargetType="Button" BasedOn="{StaticResource EditorButtonStyle}">
<Setter
Property="Foreground"
Value="#FFB8B8"/>
<Setter
Property="Background"
Value="#382020"/>
<Setter
Property="BorderBrush"
Value="#704040"/>
<Style.Triggers>
<Trigger
Property="IsMouseOver"
Value="True">
<Setter
Property="Background"
Value="#4A2525"/>
<Setter
Property="BorderBrush"
Value="#B55050"/>
<Setter
Property="Foreground"
Value="White"/>
</Trigger>
<Trigger
Property="IsEnabled"
Value="False">
<Setter
Property="Opacity"
Value="0.35"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="ApplyButtonStyle" TargetType="Button" BasedOn="{StaticResource EditorButtonStyle}">
<Setter
Property="Foreground"
Value="#CFFFE0"/>
<Setter
Property="Background"
Value="#183526"/>
<Setter
Property="BorderBrush"
Value="#356A49"/>
<Style.Triggers>
<Trigger
Property="IsMouseOver"
Value="True">
<Setter
Property="Background"
Value="#204A31"/>
<Setter
Property="BorderBrush"
Value="#51A66F"/>
<Setter
Property="Foreground"
Value="White"/>
</Trigger>
<Trigger
Property="IsEnabled"
Value="False">
<Setter
Property="Opacity"
Value="0.35"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="ThinScrollBar" TargetType="ScrollBar">
<Setter Property="Width" Value="8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Width="8" Background="Transparent">
<Border Margin="2,0,2,0" Background="#2F2F2F" CornerRadius="3"/>
<Track x:Name="PART_Track" Margin="2,0,2,0" IsDirectionReversed="True">
<Track.DecreaseRepeatButton>
<RepeatButton Opacity="0" IsHitTestVisible="False"/>
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border Background="#6B7280" CornerRadius="3" MinHeight="30"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Opacity="0" IsHitTestVisible="False"/>
</Track.IncreaseRepeatButton>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Border
Width="370"
Margin="12"
Padding="14"
VerticalAlignment="Stretch"
CornerRadius="10"
Background="#EE202020"
BorderBrush="#4A4A4A"
BorderThickness="1">
<ScrollViewer
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
CanContentScroll="False"
PanningMode="VerticalOnly">
<ScrollViewer.Resources>
<Style
TargetType="ScrollBar"
BasedOn="{StaticResource ThinScrollBar}" />
</ScrollViewer.Resources>
<StackPanel Margin="0,0,4,0">
<!-- CABEÇALHO -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel
Grid.Column="0"
Orientation="Horizontal"
VerticalAlignment="Center">
<TextBlock
Text="✎"
Foreground="#5FCF80"
FontSize="16"
Margin="0,0,7,0"/>
<TextBlock
Text="EDITANDO MAPA"
Foreground="White"
FontSize="14"
FontWeight="SemiBold"/>
</StackPanel>
<Border
Grid.Column="1"
Background="#244E32"
BorderBrush="#3D8E57"
BorderThickness="1"
CornerRadius="10"
Padding="8,3">
<TextBlock
Text="ATIVO"
Foreground="#7DE19A"
FontSize="10"
FontWeight="Bold"/>
</Border>
</Grid>
<!-- FERRAMENTAS -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="8"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="8"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="8"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="8"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ToggleButton
x:Name="btnSelecionar"
Grid.Row="0"
Grid.Column="0"
Content="◎ Selecionar"
Margin="0"
Style="{StaticResource ToolButtonStyle}"
Click="btnSelecionar_Click"/>
<ToggleButton
x:Name="btnSelecionarRetangulo"
Grid.Row="0"
Grid.Column="2"
Content="▭ Retângulo"
Margin="0"
Style="{StaticResource ToolButtonStyle}"
Click="btnSelecionarRetangulo_Click"/>
<ToggleButton
x:Name="btnRecorteLivre"
Grid.Row="2"
Grid.Column="0"
Content="✂ Livre"
Margin="0"
Style="{StaticResource ToolButtonStyle}"
Click="btnRecorteLivre_Click"/>
<ToggleButton
x:Name="btnRecortePorPontos"
Grid.Row="2"
Grid.Column="2"
Content="⬡ Por pontos"
Margin="0"
Style="{StaticResource ToolButtonStyle}"
Click="btnRecortePorPontos_Click"/>
<ToggleButton
x:Name="btnRecorteBordaGnss"
Grid.Row="4"
Grid.Column="0"
Grid.ColumnSpan="3"
Content="📡 Borda GNSS"
Margin="0"
Style="{StaticResource ToolButtonStyle}"
Click="btnRecorteBordaGnss_Click"/>
<Button
x:Name="btnLimparSelecao"
Grid.Row="6"
Grid.Column="0"
Grid.ColumnSpan="3"
Content="Limpar seleção"
HorizontalAlignment="Stretch"
Style="{StaticResource EditorButtonStyle}"
Click="btnLimparSelecao_Click"/>
</Grid>
<!-- ESCOPO DO RECORTE -->
<Border
x:Name="pnlEscopoRecorte"
Visibility="Collapsed"
Background="#181818"
BorderBrush="#363636"
BorderThickness="1"
CornerRadius="7"
Padding="10"
Margin="0,0,0,12">
<StackPanel>
<TextBlock
Text="Aplicar recorte em"
Foreground="#CFCFCF"
FontSize="11"
FontWeight="SemiBold"
Margin="0,0,0,7"/>
<RadioButton
x:Name="rbRecorteTodas"
GroupName="EscopoRecorteMapa"
Content="Todas as ruas atingidas"
Foreground="#D0D0D0"
FontSize="11"
Margin="0,0,0,6"
IsChecked="True"
Checked="rbRecorteTodas_Checked"/>
<RadioButton
x:Name="rbRecorteSelecionadas"
GroupName="EscopoRecorteMapa"
Content="Somente ruas selecionadas"
Foreground="#D0D0D0"
FontSize="11"
Checked="rbRecorteSelecionadas_Checked"/>
<TextBlock
x:Name="txtEscopoRecorteAviso"
Visibility="Collapsed"
Text="⚠ Nenhuma rua está selecionada."
Foreground="#F0B45B"
FontSize="10"
Margin="0,7,0,0"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- BORDA GNSS -->
<Border
x:Name="pnlBordaGnss"
Visibility="Collapsed"
Background="#142126"
BorderBrush="#315D68"
BorderThickness="1"
CornerRadius="7"
Padding="10"
Margin="0,0,0,12">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock
Text="📡 Levantamento GNSS"
Foreground="#BFEFFF"
FontSize="11"
FontWeight="SemiBold"/>
<TextBlock
x:Name="txtBordaGnssEstado"
Grid.Column="1"
Text="CAPTURANDO"
Foreground="#5FCF80"
FontSize="9"
FontWeight="Bold"/>
</Grid>
<StackPanel
Orientation="Horizontal"
Margin="0,0,0,8">
<RadioButton
x:Name="rbGnssManual"
Content="Manual"
IsChecked="True"
Foreground="#D0D0D0"
FontSize="11"
Margin="0,0,16,0"
Checked="rbGnssManual_Checked"/>
<RadioButton
x:Name="rbGnssAutomatico"
Content="Automático"
IsEnabled="True"
Foreground="#D0D0D0"
FontSize="11"
Checked="rbGnssAutomatico_Checked"/>
</StackPanel>
<TextBlock
x:Name="txtBordaGnssStatus"
Text="GNSS sem leitura."
Foreground="#F0B45B"
FontSize="10"
Margin="0,0,0,5"
TextWrapping="Wrap"/>
<TextBlock
x:Name="txtBordaGnssResumo"
Text="0 pontos • 0,00 m"
Foreground="White"
FontSize="12"
FontWeight="SemiBold"
Margin="0,0,0,10"/>
<TextBlock
x:Name="txtGnssAutomaticoInfo"
Visibility="Collapsed"
Text="Registro automático a cada 0,50 m"
Foreground="#8EBAC5"
FontSize="10"
Margin="0,0,0,8"/>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button
x:Name="btnGnssRegistrarPonto"
Grid.Column="0"
Content="+ Registrar ponto"
Style="{StaticResource ApplyButtonStyle}"
Click="btnGnssRegistrarPonto_Click"/>
<Button
x:Name="btnGnssIniciarAutomatico"
Grid.Column="0"
Visibility="Collapsed"
Content="● Iniciar gravação"
Style="{StaticResource ApplyButtonStyle}"
Click="btnGnssIniciarAutomatico_Click"/>
<Button
x:Name="btnGnssRemoverUltimo"
Grid.Column="2"
Content="↶ Remover último"
Style="{StaticResource EditorButtonStyle}"
Click="btnGnssRemoverUltimo_Click"/>
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button
x:Name="btnGnssDescartar"
Grid.Column="0"
Content="✕ Descartar"
Style="{StaticResource DangerButtonStyle}"
Click="btnGnssDescartar_Click"/>
<Button
x:Name="btnGnssFinalizar"
Grid.Column="2"
Content="✓ Finalizar borda"
Style="{StaticResource ApplyButtonStyle}"
Click="btnGnssFinalizar_Click"/>
<Button
x:Name="btnGnssContinuar"
Grid.Column="2"
Visibility="Collapsed"
Content="✎ Continuar"
Style="{StaticResource ApplyButtonStyle}"
Click="btnGnssContinuar_Click"/>
</Grid>
<Button
x:Name="btnGnssAplicarBorda"
Visibility="Collapsed"
Content="✂ Aplicar borda no mapa"
Height="40"
Margin="0,8,0,0"
HorizontalAlignment="Stretch"
Style="{StaticResource ApplyButtonStyle}"
Click="btnGnssAplicarBorda_Click"/>
</StackPanel>
</Border>
<!-- HISTÓRICO -->
<StackPanel
Orientation="Horizontal"
Margin="0,0,0,12">
<Button
x:Name="btnDesfazer"
Content="↶ Desfazer"
Width="160"
Margin="0,0,8,0"
ToolTip="Ctrl+Z"
Style="{StaticResource EditorButtonStyle}"
Click="btnDesfazer_Click"/>
<Button
x:Name="btnRefazer"
Content="↷ Refazer"
Width="160"
ToolTip="Ctrl+Y"
Style="{StaticResource EditorButtonStyle}"
Click="btnRefazer_Click"/>
</StackPanel>
<!-- OPERAÇÕES -->
<StackPanel Margin="0,0,0,12">
<Button
x:Name="btnExcluirSelecionadas"
Content="🗑 Excluir ruas selecionadas"
Height="36"
HorizontalAlignment="Stretch"
Style="{StaticResource DangerButtonStyle}"
ToolTip="Delete"
Click="btnExcluirSelecionadas_Click"/>
<Button
x:Name="btnRestaurarMapaFonte"
Content="↺ Restaurar mapa original"
Height="38"
Margin="0,8,0,0"
HorizontalAlignment="Stretch"
Style="{StaticResource EditorButtonStyle}"
Foreground="#FFD77A"
Background="#352F20"
BorderBrush="#78642E"
ToolTip="Restaura no editor o mapa que entrou originalmente no sistema. Ctrl+Z desfaz."
Click="btnRestaurarMapaFonte_Click"/>
</StackPanel>
<!-- STATUS -->
<Border
Background="#181818"
BorderBrush="#363636"
BorderThickness="1"
CornerRadius="7"
Padding="10">
<StackPanel>
<TextBlock
x:Name="txtSelecionadas"
Text="0 ruas selecionadas"
Foreground="#FF70CA"
FontSize="12"
FontWeight="SemiBold"/>
<TextBlock
x:Name="txtInstrucao"
Text="Clique nas ruas que deseja editar."
Foreground="#AFAFAF"
FontSize="11"
Margin="0,5,0,0"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
<!-- FINALIZAÇÃO DA EDIÇÃO -->
<Grid Margin="0,14,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button
x:Name="btnCancelarEdicao"
Grid.Column="0"
Content="✕ Cancelar"
Height="38"
Style="{StaticResource EditorButtonStyle}"
Click="btnCancelarEdicao_Click"/>
<Button
x:Name="btnAplicarEdicao"
Grid.Column="2"
Content="✓ Aplicar alterações"
Height="38"
ToolTip="Ctrl+Enter"
Style="{StaticResource ApplyButtonStyle}"
Click="btnAplicarEdicao_Click"/>
</Grid>
</StackPanel>
</ScrollViewer>
</Border>
</UserControl>

View File

@ -0,0 +1,517 @@
using System.Windows;
using UserControl = System.Windows.Controls.UserControl;
namespace OperationControl.Mapas.Editing.Views
{
public partial class MapaEditorOverlay : UserControl
{
public event Action<MapaFerramentaEdicao>? FerramentaSelecionada;
public event Action? LimparSelecaoSolicitado;
public event Action? DesfazerSolicitado;
public event Action? RefazerSolicitado;
public event Action? ExcluirSelecionadasSolicitado;
public event Action? AplicarEdicaoSolicitado;
public event Action? CancelarEdicaoSolicitado;
public event Action<MapaEscopoRecorte>? EscopoRecorteAlterado;
public event Action<MapaAcaoBordaGnss>? BordaGnssAcaoSolicitada;
public event Action? RestaurarMapaFonteSolicitado;
private bool _atualizandoEstado;
public MapaEditorOverlay()
{
InitializeComponent();
}
public void DefinirEstado(
bool ativo,
MapaFerramentaEdicao ferramenta,
int quantidadeSelecionadas,
bool modificado,
bool podeDesfazer,
bool podeRefazer,
MapaEscopoRecorte escopoRecorte = MapaEscopoRecorte.TodasRuasAtingidas,
MapaResumoBordaGnss? bordaGnss = null,
bool possuiMapaFonte = false)
{
Visibility = ativo ? Visibility.Visible : Visibility.Collapsed;
if (!ativo)
return;
_atualizandoEstado = true;
try
{
bool ferramentaRecorte =
ferramenta == MapaFerramentaEdicao.RecorteLivre ||
ferramenta == MapaFerramentaEdicao.RecortePorPontos ||
ferramenta == MapaFerramentaEdicao.RecorteBordaGnss;
pnlEscopoRecorte.Visibility =
ferramentaRecorte
? Visibility.Visible
: Visibility.Collapsed;
rbRecorteTodas.IsChecked = escopoRecorte == MapaEscopoRecorte.TodasRuasAtingidas;
rbRecorteSelecionadas.IsChecked = escopoRecorte == MapaEscopoRecorte.SomenteRuasSelecionadas;
bool avisarSemSelecao =
ferramentaRecorte &&
escopoRecorte == MapaEscopoRecorte.SomenteRuasSelecionadas &&
quantidadeSelecionadas <= 0;
txtEscopoRecorteAviso.Visibility =
avisarSemSelecao
? Visibility.Visible
: Visibility.Collapsed;
}
finally
{
_atualizandoEstado = false;
}
btnDesfazer.IsEnabled = podeDesfazer;
btnRefazer.IsEnabled = podeRefazer;
btnSelecionar.IsChecked = ferramenta == MapaFerramentaEdicao.Selecionar;
btnSelecionarRetangulo.IsChecked = ferramenta == MapaFerramentaEdicao.SelecionarRetangulo;
btnRecorteLivre.IsChecked = ferramenta == MapaFerramentaEdicao.RecorteLivre;
btnRecortePorPontos.IsChecked = ferramenta == MapaFerramentaEdicao.RecortePorPontos;
btnExcluirSelecionadas.IsEnabled = quantidadeSelecionadas > 0;
btnLimparSelecao.IsEnabled = quantidadeSelecionadas > 0;
btnAplicarEdicao.IsEnabled = modificado;
btnCancelarEdicao.IsEnabled = ativo;
btnRecorteBordaGnss.IsChecked = ferramenta == MapaFerramentaEdicao.RecorteBordaGnss;
btnRestaurarMapaFonte.IsEnabled = possuiMapaFonte;
bool exibindoBordaGnss = ferramenta == MapaFerramentaEdicao.RecorteBordaGnss;
pnlBordaGnss.Visibility = exibindoBordaGnss ? Visibility.Visible : Visibility.Collapsed;
if (exibindoBordaGnss && bordaGnss != null)
{
rbGnssManual.IsChecked = bordaGnss.Modo == MapaModoCapturaGnss.Manual;
bool automatico = bordaGnss.Modo == MapaModoCapturaGnss.Automatico;
bool gravandoAutomatico = bordaGnss.GravacaoAutomaticaAtiva;
rbGnssManual.IsChecked = !automatico;
rbGnssAutomatico.IsChecked = automatico;
/*
* Não permitimos trocar de modo
* no meio de uma gravação ou depois
* da borda finalizada.
*/
bool podeTrocarModo =
!bordaGnss.Finalizada &&
!gravandoAutomatico;
rbGnssManual.IsEnabled = podeTrocarModo;
rbGnssAutomatico.IsEnabled = podeTrocarModo;
btnGnssRegistrarPonto.Visibility =
!automatico &&
!bordaGnss.Finalizada
? Visibility.Visible
: Visibility.Collapsed;
btnGnssRegistrarPonto.IsEnabled =
!automatico &&
bordaGnss.CapturaAtiva &&
bordaGnss.LeituraGnssValida;
btnGnssIniciarAutomatico.Visibility =
automatico &&
!bordaGnss.Finalizada &&
!gravandoAutomatico
? Visibility.Visible
: Visibility.Collapsed;
btnGnssIniciarAutomatico.IsEnabled =
automatico &&
!bordaGnss.Finalizada &&
!gravandoAutomatico &&
bordaGnss.LeituraGnssValida;
btnGnssRemoverUltimo.IsEnabled =
!bordaGnss.Finalizada &&
bordaGnss.QuantidadePontos > 0 &&
!gravandoAutomatico;
bool podeFinalizar =
bordaGnss.QuantidadePontos >= 2 &&
(
!automatico
||
gravandoAutomatico
);
btnGnssFinalizar.IsEnabled = podeFinalizar;
btnGnssFinalizar.Visibility =
bordaGnss.Finalizada
? Visibility.Collapsed
: (
automatico &&
!gravandoAutomatico
? Visibility.Collapsed
: Visibility.Visible
);
btnGnssFinalizar.Content =
automatico
? "■ Finalizar gravação"
: "✓ Finalizar borda";
txtBordaGnssResumo.Text =
$"{bordaGnss.QuantidadePontos} " +
$"{(bordaGnss.QuantidadePontos == 1 ? "ponto" : "pontos")} " +
$"• {bordaGnss.ComprimentoMetros:F2} m";
txtGnssAutomaticoInfo.Visibility =
automatico
? Visibility.Visible
: Visibility.Collapsed;
txtGnssAutomaticoInfo.Text =
$"Registro automático a cada " +
$"{bordaGnss.DistanciaMinimaAutomaticaMetros:F2} m";
txtBordaGnssEstado.Text =
bordaGnss.Finalizada
? "PRONTA"
: automatico
? (
gravandoAutomatico
? "GRAVANDO"
: "PRONTO"
)
: "CAPTURANDO";
txtBordaGnssStatus.Text =
bordaGnss.StatusGnss;
txtBordaGnssStatus.Foreground =
bordaGnss.LeituraGnssValida
? (
bordaGnss.QualidadeAlta
? new System.Windows.Media
.SolidColorBrush(
System.Windows.Media
.Color
.FromRgb(
95,
207,
128
)
)
: new System.Windows.Media
.SolidColorBrush(
System.Windows.Media
.Color
.FromRgb(
240,
180,
91
)
)
)
: new System.Windows.Media
.SolidColorBrush(
System.Windows.Media
.Color
.FromRgb(
240,
100,
100
)
);
txtBordaGnssEstado.Text =
bordaGnss.Finalizada
? "PRONTA"
: "CAPTURANDO";
txtBordaGnssEstado.Foreground =
bordaGnss.Finalizada
? System.Windows.Media.Brushes
.DeepSkyBlue
: new System.Windows.Media
.SolidColorBrush(
System.Windows.Media
.Color
.FromRgb(
95,
207,
128
)
);
btnGnssRegistrarPonto.IsEnabled =
bordaGnss.CapturaAtiva &&
bordaGnss.LeituraGnssValida;
btnGnssRemoverUltimo.IsEnabled =
bordaGnss.CapturaAtiva &&
bordaGnss.QuantidadePontos > 0;
btnGnssFinalizar.IsEnabled =
bordaGnss.CapturaAtiva &&
bordaGnss.QuantidadePontos >= 2;
btnGnssFinalizar.Visibility =
bordaGnss.Finalizada
? Visibility.Collapsed
: Visibility.Visible;
btnGnssContinuar.Visibility =
bordaGnss.Finalizada
? Visibility.Visible
: Visibility.Collapsed;
btnGnssDescartar.IsEnabled =
bordaGnss.QuantidadePontos > 0;
bool escopoBordaValido =
escopoRecorte !=
MapaEscopoRecorte
.SomenteRuasSelecionadas
||
quantidadeSelecionadas > 0;
btnGnssAplicarBorda.Visibility =
bordaGnss.Finalizada
? Visibility.Visible
: Visibility.Collapsed;
btnGnssAplicarBorda.IsEnabled =
bordaGnss.Finalizada &&
bordaGnss.QuantidadePontos >= 2 &&
escopoBordaValido;
}
txtSelecionadas.Text =
quantidadeSelecionadas switch
{
0 => "Nenhuma rua selecionada",
1 => "1 rua selecionada",
_ => $"{quantidadeSelecionadas} ruas selecionadas"
};
txtInstrucao.Text =
ferramenta switch
{
MapaFerramentaEdicao.Selecionar =>
"Clique nas ruas que deseja editar.",
MapaFerramentaEdicao.SelecionarRetangulo =>
"Arraste para selecionar ruas. Ctrl + arraste para mover o mapa.",
MapaFerramentaEdicao.RecorteLivre =>
"Arraste contornando a área a remover. Ctrl + arraste move o mapa.",
MapaFerramentaEdicao.SelecionarPoligono =>
"Defina os vértices da área desejada.",
MapaFerramentaEdicao.RecortePorPontos =>
"Clique para marcar os vértices. Enter ou duplo clique conclui. Backspace remove o último ponto. Esc descarta o traçado.",
MapaFerramentaEdicao.RecorteBordaGnss =>
"Registre a borda real usando GNSS.",
_ =>
"Selecione uma ferramenta."
};
}
private void btnSelecionar_Click(object sender, RoutedEventArgs e)
{
FerramentaSelecionada?.Invoke(MapaFerramentaEdicao.Selecionar);
}
private void btnSelecionarRetangulo_Click(object sender, RoutedEventArgs e)
{
FerramentaSelecionada?.Invoke(MapaFerramentaEdicao.SelecionarRetangulo);
}
private void btnRecorteLivre_Click(object sender, RoutedEventArgs e)
{
FerramentaSelecionada?.Invoke(MapaFerramentaEdicao.RecorteLivre);
}
private void btnRecortePorPontos_Click(object sender, RoutedEventArgs e)
{
FerramentaSelecionada?.Invoke(MapaFerramentaEdicao.RecortePorPontos);
}
private void btnLimparSelecao_Click(object sender, RoutedEventArgs e)
{
LimparSelecaoSolicitado?.Invoke();
}
private void btnDesfazer_Click(object sender, RoutedEventArgs e)
{
DesfazerSolicitado?.Invoke();
}
private void btnRefazer_Click(object sender, RoutedEventArgs e)
{
RefazerSolicitado?.Invoke();
}
private void btnExcluirSelecionadas_Click(object sender, RoutedEventArgs e)
{
ExcluirSelecionadasSolicitado?.Invoke();
}
private void rbRecorteTodas_Checked(object sender, RoutedEventArgs e)
{
if (_atualizandoEstado)
return;
EscopoRecorteAlterado?.Invoke(MapaEscopoRecorte.TodasRuasAtingidas);
}
private void rbRecorteSelecionadas_Checked(object sender, RoutedEventArgs e)
{
if (_atualizandoEstado)
return;
EscopoRecorteAlterado?.Invoke(MapaEscopoRecorte.SomenteRuasSelecionadas);
}
private void btnRecorteBordaGnss_Click(object sender, RoutedEventArgs e)
{
FerramentaSelecionada?.Invoke(MapaFerramentaEdicao.RecorteBordaGnss);
}
private void btnGnssRegistrarPonto_Click(object sender, RoutedEventArgs e)
{
BordaGnssAcaoSolicitada?.Invoke(MapaAcaoBordaGnss.RegistrarPonto);
}
private void btnGnssRemoverUltimo_Click(object sender, RoutedEventArgs e)
{
BordaGnssAcaoSolicitada?.Invoke(MapaAcaoBordaGnss.RemoverUltimo);
}
private void btnGnssFinalizar_Click(object sender, RoutedEventArgs e)
{
BordaGnssAcaoSolicitada?.Invoke(MapaAcaoBordaGnss.Finalizar);
}
private void btnGnssContinuar_Click(object sender, RoutedEventArgs e)
{
BordaGnssAcaoSolicitada?.Invoke(MapaAcaoBordaGnss.Continuar);
}
private void btnGnssDescartar_Click(object sender, RoutedEventArgs e)
{
BordaGnssAcaoSolicitada?.Invoke(MapaAcaoBordaGnss.Descartar);
}
private void btnGnssAplicarBorda_Click(object sender, RoutedEventArgs e)
{
BordaGnssAcaoSolicitada?.Invoke(MapaAcaoBordaGnss.Aplicar);
}
private void btnAplicarEdicao_Click(object sender, RoutedEventArgs e)
{
AplicarEdicaoSolicitado?.Invoke();
}
private void btnCancelarEdicao_Click(object sender, RoutedEventArgs e)
{
CancelarEdicaoSolicitado?.Invoke();
}
private void btnRestaurarMapaFonte_Click(object sender, RoutedEventArgs e)
{
RestaurarMapaFonteSolicitado?.Invoke();
}
private void rbGnssManual_Checked(object sender, RoutedEventArgs e)
{
if (_atualizandoEstado)
return;
BordaGnssAcaoSolicitada?.Invoke(MapaAcaoBordaGnss.DefinirModoManual);
}
private void rbGnssAutomatico_Checked(object sender, RoutedEventArgs e)
{
if (_atualizandoEstado)
return;
BordaGnssAcaoSolicitada?.Invoke(MapaAcaoBordaGnss.DefinirModoAutomatico);
}
private void btnGnssIniciarAutomatico_Click(object sender, RoutedEventArgs e)
{
BordaGnssAcaoSolicitada?.Invoke(MapaAcaoBordaGnss.IniciarGravacaoAutomatica);
}
}
}

View File

@ -0,0 +1,17 @@
namespace OperationControl.Mapas.Interaction
{
/// <summary>
/// Define como o MapViewControl interpreta a interação do operador.
///
/// O controle não decide sozinho em qual modo deve estar.
/// Essa decisão pertence à aplicação/Controller.
/// </summary>
public enum MapaModoInteracao
{
Visualizacao = 0,
SelecaoEntidade = 1,
SelecaoOperacao = 2,
RetornoManual = 3,
Edicao = 4
}
}

View File

@ -0,0 +1,86 @@
namespace OperationControl.Mapas.Interaction
{
public readonly struct MapaPontoWgs84
{
public double Latitude
{
get;
}
public double Longitude
{
get;
}
public MapaPontoWgs84(
double latitude,
double longitude)
{
if (
!double.IsFinite(latitude) ||
!double.IsFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
)
{
throw new ArgumentOutOfRangeException(
nameof(latitude),
"Coordenada WGS84 inválida."
);
}
Latitude =
latitude;
Longitude =
longitude;
}
}
public sealed class MapaPoligonoRecorte
{
public IReadOnlyList<MapaPontoWgs84>
Pontos
{
get;
}
public int QuantidadePontos =>
Pontos.Count;
public MapaPoligonoRecorte(
IEnumerable<MapaPontoWgs84> pontos)
{
if (pontos == null)
{
throw new ArgumentNullException(
nameof(pontos)
);
}
var lista =
pontos.ToList();
if (lista.Count < 3)
{
throw new ArgumentException(
"O polígono precisa de pelo menos 3 pontos.",
nameof(pontos)
);
}
Pontos =
lista;
}
}
}

View File

@ -0,0 +1,51 @@
namespace OperationControl.Mapas.Interaction
{
/// <summary>
/// Retângulo geográfico produzido por uma
/// interação do operador no mapa.
///
/// Coordenadas em WGS84.
/// </summary>
public sealed class MapaRetanguloSelecao
{
public double MinLatitude { get; }
public double MinLongitude { get; }
public double MaxLatitude { get; }
public double MaxLongitude { get; }
public MapaRetanguloSelecao(
double minLatitude,
double minLongitude,
double maxLatitude,
double maxLongitude)
{
MinLatitude =
Math.Min(
minLatitude,
maxLatitude
);
MaxLatitude =
Math.Max(
minLatitude,
maxLatitude
);
MinLongitude =
Math.Min(
minLongitude,
maxLongitude
);
MaxLongitude =
Math.Max(
minLongitude,
maxLongitude
);
}
}
}

View File

@ -0,0 +1,171 @@
using OperationControl.Mapas.Models;
namespace OperationControl.Mapas
{
/// <summary>
/// Representa o contexto de trabalho atual do módulo de mapas.
///
/// Centraliza o mapa carregado e o estado operacional associado
/// a ele, sem depender de Mapsui, WPF ou qualquer tela.
/// </summary>
public sealed class MapaWorkspace
{
/// <summary>
/// Cópia canônica e imutável, por convenção,
/// do mapa no instante em que ele entrou
/// no subsistema.
///
/// Não é alterado por Apply, edição,
/// Undo/Redo ou alterações operacionais.
/// </summary>
public MapaDocumento? DocumentoFonte { get; private set; }
public MapaDocumento? Documento { get; private set; }
public MapaSelecaoOperacao Selecao { get; private set; } = new();
public MapaRotaRetorno RotaRetorno { get; private set; } = new();
public bool PossuiDocumentoFonte => DocumentoFonte != null && DocumentoFonte.PossuiRuas;
public int QuantidadeRuasFonte => DocumentoFonte?.QuantidadeRuas ?? 0;
public bool PossuiMapa => Documento != null && Documento.PossuiRuas;
public bool PossuiSelecao => Selecao.PossuiSelecao;
public int QuantidadeRuas => Documento?.QuantidadeRuas ?? 0;
public int QuantidadeSelecionadas => Selecao.Quantidade;
/// <summary>
/// Define um mapa completamente novo.
///
/// Fonte e documento ativo DEVEM ser
/// cópias independentes.
/// </summary>
public void DefinirNovoDocumento(MapaDocumento documentoFonte, MapaDocumento documentoAtivo)
{
DefinirNovoEstado(
documentoFonte,
documentoAtivo,
new MapaSelecaoOperacao(),
new MapaRotaRetorno()
);
}
public void DefinirNovoEstado(MapaDocumento documentoFonte, MapaDocumento documentoAtivo, MapaSelecaoOperacao selecao, MapaRotaRetorno rotaRetorno)
{
if (documentoFonte == null)
{
throw new ArgumentNullException(
nameof(documentoFonte)
);
}
if (documentoAtivo == null)
{
throw new ArgumentNullException(
nameof(documentoAtivo)
);
}
/*
* Essa igualdade seria um bug arquitetural:
*
* editar Documento significaria editar
* também nosso backup.
*/
if (
ReferenceEquals(
documentoFonte,
documentoAtivo
)
)
{
throw new InvalidOperationException(
"DocumentoFonte e Documento ativo " +
"devem ser instâncias independentes."
);
}
DocumentoFonte =
documentoFonte;
DefinirEstado(
documentoAtivo,
selecao,
rotaRetorno
);
}
/// <summary>
/// Substitui somente o documento oficial ativo.
///
/// DocumentoFonte não é alterado.
///
/// Útil para fluxos que já possuem uma fonte
/// previamente registrada.
/// </summary>
public void DefinirDocumento(MapaDocumento documento)
{
DefinirEstado(
documento,
new MapaSelecaoOperacao(),
new MapaRotaRetorno()
);
}
/// <summary>
/// Remove o mapa atualmente carregado
/// e todo o estado operacional associado.
/// </summary>
public void Limpar()
{
DocumentoFonte = null;
Documento = null;
Selecao = new MapaSelecaoOperacao();
RotaRetorno = new MapaRotaRetorno();
}
public void DefinirEstado(MapaDocumento documento, MapaSelecaoOperacao selecao, MapaRotaRetorno rotaRetorno)
{
Documento =
documento
?? throw new ArgumentNullException(
nameof(documento)
);
Selecao =
selecao
?? throw new ArgumentNullException(
nameof(selecao)
);
RotaRetorno =
rotaRetorno
?? throw new ArgumentNullException(
nameof(rotaRetorno)
);
}
}
}

View File

@ -0,0 +1,88 @@
using AgroBase.Models;
using NetTopologySuite.Geometries;
using System.Linq;
using static AgroBase.Models.Enums;
namespace OperationControl.Mapas.Models
{
public class MapaDocumento
{
public string Nome { get; set; } = string.Empty;
/// <summary>
/// Arquivo que originou este documento.
/// Pode deixar de existir futuramente após edições/salvamento,
/// portanto não deve ser usado como fonte de verdade do mapa.
/// </summary>
public string? CaminhoOrigem { get; set; }
public MapaFormatoOrigem FormatoOrigem { get; set; }
= MapaFormatoOrigem.Desconhecido;
public TipoMapaOperacao Tipo { get; set; }
= TipoMapaOperacao.Indefinido;
/// <summary>
/// O documento interno trabalha sempre em WGS84.
/// </summary>
public int Srid => 4326;
public List<MapaRua> Ruas { get; set; } = new();
/// <summary>
/// Indica alteração posterior à importação.
/// No carregamento inicial sempre começa false.
/// </summary>
public bool Modificado { get; set; }
/// <summary>
/// Avisos que não impediram a importação.
///
/// Exemplos:
/// - SHP sem PRJ, mas já aparentemente em longitude/latitude;
/// - PRJ inválido ignorado com segurança;
/// - IDs ausentes/duplicados corrigidos.
/// </summary>
public List<string> AvisosImportacao { get; set; } = new();
public bool PossuiRuas =>
Ruas != null && Ruas.Count > 0;
public int QuantidadeRuas =>
Ruas?.Count ?? 0;
public double ComprimentoTotalMetros =>
Ruas?.Sum(x => x?.ComprimentoMetros ?? 0.0) ?? 0.0;
/// <summary>
/// Limite espacial do documento em WGS84.
/// Pode ser usado posteriormente pelo renderizador
/// para enquadrar o mapa.
/// </summary>
public Envelope Limites
{
get
{
var envelope = new Envelope();
if (Ruas == null)
return envelope;
foreach (var rua in Ruas)
{
if (rua?.Geometria == null ||
rua.Geometria.IsEmpty)
{
continue;
}
envelope.ExpandToInclude(
rua.Geometria.EnvelopeInternal
);
}
return envelope;
}
}
}
}

View File

@ -0,0 +1,95 @@
using static AgroBase.Models.Enums;
namespace OperationControl.Mapas.Models
{
/// <summary>
/// Estado lógico de uma entidade móvel/fixa exibida no mapa.
///
/// Não conhece Mapsui.
/// Não possui GeometryFeature.
/// Não possui Style.
/// </summary>
public sealed class MapaEntidadeDinamica
{
public string Id { get; internal set; } = "";
public MapaTipoEntidadeDinamica Tipo
{
get;
internal set;
}
public string Nome
{
get;
internal set;
} = "";
public double? Latitude
{
get;
internal set;
}
public double? Longitude
{
get;
internal set;
}
public double? Heading
{
get;
internal set;
}
/*
* GNSS bruto, antes da correção de lever arm.
*
* Mantemos no domínio porque é dado real,
* não representação visual.
*/
public double? LatitudeBruta
{
get;
internal set;
}
public double? LongitudeBruta
{
get;
internal set;
}
public StatusOperacao Status
{
get;
internal set;
}
= StatusOperacao.NaoIniciado;
public bool Pulverizando
{
get;
internal set;
}
public bool EmFoco
{
get;
internal set;
}
public bool PossuiPosicao =>
Latitude.HasValue &&
Longitude.HasValue;
}
}

View File

@ -0,0 +1,35 @@
namespace OperationControl.Mapas.Models
{
/// <summary>
/// Estado vivo das entidades atualmente conhecidas.
///
/// Independente de mapa carregado e independente da UI.
/// </summary>
public sealed class MapaEstadoDinamico
{
private readonly Dictionary<
string,
MapaEntidadeDinamica>
_entidades =
new(
StringComparer.OrdinalIgnoreCase
);
public IReadOnlyCollection<
MapaEntidadeDinamica>
Entidades =>
_entidades.Values;
public int Quantidade =>
_entidades.Count;
internal Dictionary<
string,
MapaEntidadeDinamica>
EntidadesMutaveis =>
_entidades;
}
}

View File

@ -0,0 +1,30 @@
namespace OperationControl.Mapas.Models
{
public sealed class MapaEstadoMovimento
{
private readonly Dictionary<
string,
MapaMovimentoRover>
_rovers =
new(
StringComparer.OrdinalIgnoreCase
);
public IReadOnlyCollection<
MapaMovimentoRover>
Rovers =>
_rovers.Values;
public int Quantidade =>
_rovers.Count;
internal Dictionary<
string,
MapaMovimentoRover>
RoversMutaveis =>
_rovers;
}
}

View File

@ -0,0 +1,87 @@
namespace OperationControl.Mapas.Models
{
public sealed class MapaEstimativaCorredor
{
public string RuaAId { get; set; } = string.Empty;
public string RuaBId { get; set; } = string.Empty;
public double ComprimentoMetros { get; set; }
public double LarguraMetros { get; set; }
public double AreaMetrosQuadrados =>
ComprimentoMetros *
LarguraMetros;
}
public sealed class MapaEstimativaOperacao
{
public int QuantidadeRuasSelecionadas
{
get;
set;
}
public List<MapaEstimativaCorredor> Corredores
{
get;
set;
} = new();
public int QuantidadeCorredores =>
Corredores?.Count ?? 0;
public double DistanciaMetros
{
get;
set;
}
public double AreaMetrosQuadrados
{
get;
set;
}
public double AreaHectares =>
AreaMetrosQuadrados /
10000.0;
public double LarguraMediaMetros
{
get;
set;
}
public double? TempoEstimadoSegundos
{
get;
set;
}
public double PercentualErvasConsiderado
{
get;
set;
}
public string? MotivoIndisponivel
{
get;
set;
}
public bool PossuiEstimativa =>
QuantidadeCorredores > 0 &&
DistanciaMetros > 0;
}
}

View File

@ -0,0 +1,10 @@
namespace OperationControl.Mapas.Models
{
public enum MapaFormatoOrigem
{
Desconhecido = 0,
GeoJson = 1,
Shapefile = 2,
ContratoOperacional = 3
}
}

View File

@ -0,0 +1,64 @@
namespace OperationControl.Mapas.Models
{
public sealed class MapaMovimentoRover
{
private readonly List<MapaTrechoRastro>
_trechos = new();
private readonly List<MapaPontoWgs84>
_predicao = new();
public string RoverId
{
get;
internal set;
} = "";
public IReadOnlyList<MapaTrechoRastro>
Trechos => _trechos;
public IReadOnlyList<MapaPontoWgs84>
Predicao => _predicao;
public MapaPontoWgs84?
UltimaPosicaoAceita
{
get;
internal set;
}
public int QuantidadeTrechos =>
_trechos.Count;
public int QuantidadePontosPredicao =>
_predicao.Count;
public int QuantidadePontosRastro =>
_trechos.Sum(
x => x.QuantidadePontos
);
internal bool QuebrarRastro
{
get;
set;
} = true;
internal List<MapaTrechoRastro>
TrechosMutaveis => _trechos;
internal List<MapaPontoWgs84>
PredicaoMutavel => _predicao;
}
}

View File

@ -0,0 +1,33 @@
namespace OperationControl.Mapas.Models
{
/// <summary>
/// Ponto WGS84 pertencente à rota manual de retorno.
///
/// Latitude = eixo norte/sul.
/// Longitude = eixo leste/oeste.
///
/// Não utiliza arrays internamente.
/// </summary>
public sealed class MapaPontoRetorno
{
public double Latitude { get; }
public double Longitude { get; }
public MapaPontoRetorno(
double latitude,
double longitude)
{
Latitude = latitude;
Longitude = longitude;
}
public override string ToString()
{
return
$"{Latitude:F8}, {Longitude:F8}";
}
}
}

View File

@ -0,0 +1,17 @@
namespace OperationControl.Mapas.Models
{
public sealed class MapaPontoWgs84
{
public double Latitude { get; }
public double Longitude { get; }
public MapaPontoWgs84(
double latitude,
double longitude)
{
Latitude = latitude;
Longitude = longitude;
}
}
}

View File

@ -0,0 +1,30 @@
namespace OperationControl.Mapas.Models
{
public class MapaResultadoValidacao
{
public List<string> Erros { get; } = new();
public List<string> Avisos { get; } = new();
public bool Valido =>
Erros.Count == 0;
public int QuantidadeErros =>
Erros.Count;
public int QuantidadeAvisos =>
Avisos.Count;
public void AdicionarErro(string mensagem)
{
if (!string.IsNullOrWhiteSpace(mensagem))
Erros.Add(mensagem);
}
public void AdicionarAviso(string mensagem)
{
if (!string.IsNullOrWhiteSpace(mensagem))
Avisos.Add(mensagem);
}
}
}

View File

@ -0,0 +1,32 @@
namespace OperationControl.Mapas.Models
{
/// <summary>
/// Representa a rota manual ordenada utilizada
/// para retorno seguro do rover à base.
///
/// A ordem dos pontos é a ordem de passagem.
/// </summary>
public sealed class MapaRotaRetorno
{
private readonly List<MapaPontoRetorno>
_pontos = new();
public IReadOnlyList<MapaPontoRetorno>
Pontos =>
_pontos;
public int Quantidade =>
_pontos.Count;
public bool PossuiPontos =>
_pontos.Count > 0;
internal List<MapaPontoRetorno>
PontosMutaveis =>
_pontos;
}
}

View File

@ -0,0 +1,53 @@
using NetTopologySuite.Geometries;
namespace OperationControl.Mapas.Models
{
public class MapaRua
{
/// <summary>
/// ID canônico utilizado pelo Operation Control e enviado ao rover.
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// ID encontrado no arquivo original, quando existente.
/// Pode ser útil para diagnóstico e rastreabilidade.
/// </summary>
public string? IdOriginal { get; set; }
/// <summary>
/// Indica que o importador precisou criar um ID para esta rua.
/// IDs gerados automaticamente são sempre strings numéricas.
/// </summary>
public bool IdGeradoAutomaticamente { get; set; }
public string Nome { get; set; } = string.Empty;
/// <summary>
/// Geometria canônica da rua.
///
/// Regra do módulo:
/// todas as geometrias armazenadas no documento estão em
/// WGS84 / EPSG:4326:
///
/// X = Longitude
/// Y = Latitude
/// </summary>
public LineString Geometria { get; set; } = null!;
/// <summary>
/// Comprimento calculado a partir da geometria WGS84.
/// </summary>
public double ComprimentoMetros { get; set; }
public double Dist1 { get; set; }
public double Dist2 { get; set; }
/// <summary>
/// Posição da feature no arquivo original.
/// Serve somente para rastreabilidade/diagnóstico.
/// </summary>
public int IndiceFeatureOrigem { get; set; }
}
}

View File

@ -0,0 +1,35 @@
namespace OperationControl.Mapas.Models
{
/// <summary>
/// Representa a seleção de ruas de uma operação.
///
/// Não pertence ao mapa físico.
/// O mesmo MapaDocumento pode ser utilizado por várias
/// operações com seleções e ordens diferentes.
/// </summary>
public class MapaSelecaoOperacao
{
private readonly List<string> _ruasOrdenadas = new();
/// <summary>
/// IDs das ruas na ordem em que foram selecionadas
/// e deverão ser percorridas.
/// </summary>
public IReadOnlyList<string> RuasOrdenadas =>
_ruasOrdenadas;
public int Quantidade =>
_ruasOrdenadas.Count;
public bool PossuiSelecao =>
_ruasOrdenadas.Count > 0;
/*
* O serviço de seleção é o único responsável por
* modificar esta lista.
*/
internal List<string> RuasMutaveis =>
_ruasOrdenadas;
}
}

View File

@ -0,0 +1,8 @@
namespace OperationControl.Mapas.Models
{
public enum MapaTipoEntidadeDinamica
{
Base = 1,
Rover = 2
}
}

View File

@ -0,0 +1,35 @@
namespace OperationControl.Mapas.Models
{
public sealed class MapaTrechoRastro
{
private readonly List<MapaPontoWgs84>
_pontos = new();
public bool Pulverizando
{
get;
internal set;
}
public IReadOnlyList<MapaPontoWgs84>
Pontos => _pontos;
public int QuantidadePontos =>
_pontos.Count;
internal List<MapaPontoWgs84>
PontosMutaveis => _pontos;
internal MapaTrechoRastro(
bool pulverizando)
{
Pulverizando =
pulverizando;
}
}
}

View File

@ -0,0 +1,261 @@
using Mapsui;
using Mapsui.Layers;
using Mapsui.Nts;
using Mapsui.Projections;
using Mapsui.Styles;
using NetTopologySuite.Geometries;
using OperationControl.Mapas.Editing;
using Brush = Mapsui.Styles.Brush;
using Color = Mapsui.Styles.Color;
using Font = Mapsui.Styles.Font;
using Pen = Mapsui.Styles.Pen;
namespace OperationControl.Mapas.Rendering
{
public sealed class MapaBordaGnssRenderService
{
public const string NomeCamada =
"MapaBordaGnssEdicao";
public MemoryLayer CriarCamada(
MapaEstadoRecorte estado)
{
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
return new MemoryLayer
{
Name =
NomeCamada,
Features =
CriarFeatures(
estado
)
};
}
public void AtualizarCamada(
MemoryLayer camada,
MapaEstadoRecorte estado)
{
if (camada == null)
{
throw new ArgumentNullException(
nameof(camada)
);
}
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
camada.Features =
CriarFeatures(
estado
);
}
private static IEnumerable<IFeature>
CriarFeatures(
MapaEstadoRecorte estado)
{
var features =
new List<IFeature>();
if (
estado.Tipo !=
MapaTipoRecorte.BordaGnss
)
{
return features;
}
/*
* LINHA
*/
if (
estado.Pontos.Count >= 2
)
{
Coordinate[] coordenadas =
estado
.Pontos
.Select(
ponto =>
{
var mercator =
SphericalMercator
.FromLonLat(
ponto.X,
ponto.Y
);
return new Coordinate(
mercator.x,
mercator.y
);
}
)
.ToArray();
var featureLinha =
new GeometryFeature
{
Geometry =
new LineString(
coordenadas
)
};
featureLinha["Tipo"] =
"BordaGnss";
featureLinha.Styles.Add(
new VectorStyle
{
Line =
new Pen(
new Color(
0,
210,
255,
255
),
4
)
}
);
features.Add(
featureLinha
);
}
/*
* PONTOS
*/
for (
int i = 0;
i < estado.Pontos.Count;
i++)
{
Coordinate ponto =
estado.Pontos[i];
var mercator =
SphericalMercator
.FromLonLat(
ponto.X,
ponto.Y
);
var feature =
new PointFeature(
mercator.x,
mercator.y
);
feature["Tipo"] =
"PontoBordaGnss";
feature["Ordem"] =
i + 1;
feature["label"] =
(i + 1).ToString();
feature.Styles.Add(
new SymbolStyle
{
SymbolType =
SymbolType.Ellipse,
SymbolScale =
1.15,
Fill =
new Brush(
new Color(
0,
210,
255,
255
)
),
Outline =
new Pen(
Color.White,
2
)
}
);
feature.Styles.Add(
new LabelStyle
{
LabelColumn =
"label",
ForeColor =
Color.White,
BackColor =
null,
HorizontalAlignment =
LabelStyle
.HorizontalAlignmentEnum
.Center,
VerticalAlignment =
LabelStyle
.VerticalAlignmentEnum
.Center,
Font =
new Font
{
Size = 11
}
}
);
features.Add(
feature
);
}
return features;
}
}
}

View File

@ -0,0 +1,365 @@
using Mapsui;
using Mapsui.Layers;
using Mapsui.Nts;
using Mapsui.Projections;
using Mapsui.Providers;
using Mapsui.Styles;
using NetTopologySuite.Geometries;
using OperationControl.Mapas.Editing;
using Color = Mapsui.Styles.Color;
using Pen = Mapsui.Styles.Pen;
namespace OperationControl.Mapas.Rendering
{
public sealed class MapaEdicaoRenderService
{
public const string NomeCamada =
"MapaEdicaoNovo";
private static readonly VectorStyle
EstiloNormal =
new()
{
Line =
new Pen(
new Color(
46,
204,
113,
210
),
2
)
};
/*
* Cor própria da edição.
*
* Não confundir com o amarelo usado
* na seleção operacional da missão.
*/
private static readonly VectorStyle
EstiloSelecionado =
new()
{
Line =
new Pen(
new Color(
255,
64,
190,
255
),
5
)
};
public MapaRenderResult CriarCamada(
MapaEstadoEdicao estado)
{
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
if (
!estado.EmEdicao ||
estado.DocumentoTrabalho == null
)
{
throw new InvalidOperationException(
"Não existe uma sessão de edição ativa."
);
}
var featuresPorRuaId =
new Dictionary<
string,
GeometryFeature>(
StringComparer.OrdinalIgnoreCase
);
var features =
new List<IFeature>();
foreach (
var rua
in estado.DocumentoTrabalho.Ruas)
{
if (
rua == null ||
rua.Geometria == null ||
rua.Geometria.IsEmpty ||
rua.Geometria.NumPoints < 2
)
{
continue;
}
/*
* Documento canônico:
*
* EPSG:4326
* X = longitude
* Y = latitude
*
* Mapsui:
*
* desenhamos diretamente em
* WebMercator EPSG:3857.
*/
var coordenadas =
rua.Geometria
.Coordinates
.Select(
coordenada =>
{
var mercator =
SphericalMercator
.FromLonLat(
coordenada.X,
coordenada.Y
);
return new Coordinate(
mercator.x,
mercator.y
);
}
)
.ToArray();
if (coordenadas.Length < 2)
continue;
var feature =
new GeometryFeature
{
Geometry =
new LineString(
coordenadas
)
};
/*
* Mantemos os mesmos atributos
* esperados pelo hit-test atual.
*/
feature["RuaId"] =
rua.Id;
feature["Id"] =
rua.Id;
feature["Tipo"] =
"RuaEdicao";
bool selecionada =
EstaSelecionada(
estado,
rua.Id
);
feature
.Styles
.Add(
selecionada
? EstiloSelecionado
: EstiloNormal
);
features.Add(
feature
);
featuresPorRuaId[
rua.Id
] =
feature;
}
/*
* MapaRenderResult trabalha com Layer,
* portanto seguimos exatamente esse
* contrato.
*/
var provider =
new MemoryProvider(
features
);
var camada =
new Layer
{
Name =
NomeCamada,
DataSource =
provider,
/*
* Cada feature possui seu
* próprio estilo.
*/
Style =
null
};
/*
* IMPORTANTE:
*
* MapaRenderResult NÃO possui
* construtor vazio.
*
* Layer e dicionário entram pelo
* construtor obrigatório.
*/
return new MapaRenderResult(
camada,
featuresPorRuaId
);
}
public void AtualizarEstiloRua(
MapaRenderResult render,
MapaEstadoEdicao estado,
string ruaId)
{
if (
render == null ||
estado == null ||
string.IsNullOrWhiteSpace(
ruaId
)
)
{
return;
}
/*
* Aproveitamos a API que já existe
* no próprio MapaRenderResult.
*/
var feature =
render.ObterFeature(
ruaId
);
if (feature == null)
return;
bool selecionada =
EstaSelecionada(
estado,
ruaId
);
feature
.Styles
.Clear();
feature
.Styles
.Add(
selecionada
? EstiloSelecionado
: EstiloNormal
);
}
public void AplicarSelecao(
MapaRenderResult render,
MapaEstadoEdicao estado)
{
if (
render == null ||
estado == null
)
{
return;
}
foreach (
var item
in render.FeaturesPorRuaId)
{
bool selecionada =
EstaSelecionada(
estado,
item.Key
);
item.Value
.Styles
.Clear();
item.Value
.Styles
.Add(
selecionada
? EstiloSelecionado
: EstiloNormal
);
}
}
private static bool EstaSelecionada(
MapaEstadoEdicao estado,
string ruaId)
{
if (
string.IsNullOrWhiteSpace(
ruaId
)
)
{
return false;
}
return estado
.RuasSelecionadas
.Any(
id =>
string.Equals(
id,
ruaId,
StringComparison
.OrdinalIgnoreCase
)
);
}
}
}

View File

@ -0,0 +1,606 @@
using Mapsui;
using Mapsui.Layers;
using Mapsui.Nts;
using Mapsui.Styles;
using NetTopologySuite.Geometries;
using OperationControl.Mapas.Models;
using Brush = Mapsui.Styles.Brush;
using Font = Mapsui.Styles.Font;
using Pen = Mapsui.Styles.Pen;
namespace OperationControl.Mapas.Rendering
{
/// <summary>
/// Converte o estado dinâmico de Base/Rovers
/// em representação visual Mapsui.
///
/// Não armazena estado operacional.
/// </summary>
public sealed class MapaEntidadeDinamicaRenderService
{
public const string NomeCamada =
"MapaEntidadesDinamicasNovo";
public MemoryLayer CriarCamada(
MapaEstadoDinamico estado,
double resolution)
{
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
return new MemoryLayer
{
Name = NomeCamada,
Features =
CriarFeatures(
estado,
resolution
)
};
}
public void AtualizarCamada(
MemoryLayer camada,
MapaEstadoDinamico estado,
double resolution)
{
if (camada == null)
{
throw new ArgumentNullException(
nameof(camada)
);
}
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
camada.Features =
CriarFeatures(
estado,
resolution
);
}
private static IEnumerable<IFeature>
CriarFeatures(
MapaEstadoDinamico estado,
double resolution)
{
var features =
new List<IFeature>();
if (
!double.IsFinite(resolution) ||
resolution <= 0
)
{
resolution =
1.0;
}
foreach (
var entidade
in estado.Entidades)
{
if (!entidade.PossuiPosicao)
continue;
double latitude =
entidade.Latitude!.Value;
double longitude =
entidade.Longitude!.Value;
var mercator =
Mapsui.Projections
.SphericalMercator
.FromLonLat(
longitude,
latitude
);
var centro =
new MPoint(
mercator.x,
mercator.y
);
if (
entidade.Tipo ==
MapaTipoEntidadeDinamica.Base
)
{
CriarBase(
entidade,
centro,
resolution,
features
);
}
else
{
CriarRover(
entidade,
centro,
resolution,
features
);
}
/*
* Label e ponto central para futura
* identificação/hit-test.
*/
CriarCentroELabel(
entidade,
centro,
features
);
}
return features;
}
private static void CriarRover(
MapaEntidadeDinamica entidade,
MPoint centro,
double resolution,
List<IFeature> features)
{
/*
* Aproximadamente 24 px no zoom atual.
*/
double comprimento =
24.0 *
resolution;
double largura =
comprimento *
0.75;
double frente =
comprimento *
0.55;
double traseira =
comprimento *
0.45;
double meiaLargura =
largura *
0.5;
/*
* Convenção:
*
* 0° = Norte
* 90° = Leste
* 180° = Sul
* 270° = Oeste
*/
double heading =
entidade.Heading
?? 0.0;
double rad =
heading *
Math.PI /
180.0;
double ux =
Math.Sin(rad);
double uy =
Math.Cos(rad);
double px =
Math.Cos(rad);
double py =
-Math.Sin(rad);
var front =
new Coordinate(
centro.X +
ux * frente,
centro.Y +
uy * frente
);
var left =
new Coordinate(
centro.X -
ux * traseira +
px * meiaLargura,
centro.Y -
uy * traseira +
py * meiaLargura
);
var right =
new Coordinate(
centro.X -
ux * traseira -
px * meiaLargura,
centro.Y -
uy * traseira -
py * meiaLargura
);
var ring =
new LinearRing(
new[]
{
front,
left,
right,
front
}
);
var feature =
new GeometryFeature
{
Geometry =
new Polygon(
ring
)
};
feature["EntidadeId"] =
entidade.Id;
feature["Tipo"] =
"Rover";
var cor =
new Mapsui.Styles.Color(
45,
140,
220,
255
);
feature.Styles.Add(
new VectorStyle
{
Fill =
new Brush(
cor
),
Outline =
new Pen(
Mapsui.Styles.Color.White,
entidade.EmFoco
? 3
: 2
)
}
);
features.Add(
feature
);
/*
* Halo de foco.
*/
if (entidade.EmFoco)
{
var foco =
new PointFeature(
centro
);
foco["EntidadeId"] =
entidade.Id;
foco.Styles.Add(
new SymbolStyle
{
SymbolType =
SymbolType.Ellipse,
SymbolScale =
1.35,
Fill =
new Brush(
new Mapsui.Styles.Color(
cor.R,
cor.G,
cor.B,
40
)
),
Outline =
new Pen(
new Mapsui.Styles.Color(
cor.R,
cor.G,
cor.B,
180
),
2
)
}
);
features.Add(
foco
);
}
}
private static void CriarBase(
MapaEntidadeDinamica entidade,
MPoint centro,
double resolution,
List<IFeature> features)
{
double raio =
22.0 *
resolution;
var coords =
new List<Coordinate>();
/*
* Hexágono.
*/
for (
int i = 0;
i < 6;
i++)
{
double ang =
Math.PI / 6.0 +
i *
Math.PI / 3.0;
coords.Add(
new Coordinate(
centro.X +
Math.Cos(ang) *
raio,
centro.Y +
Math.Sin(ang) *
raio
)
);
}
coords.Add(
coords[0]
);
var feature =
new GeometryFeature
{
Geometry =
new Polygon(
new LinearRing(
coords.ToArray()
)
)
};
feature["EntidadeId"] =
entidade.Id;
feature["Tipo"] =
"Base";
var cor =
new Mapsui.Styles.Color(
220,
45,
45,
255
);
feature.Styles.Add(
new VectorStyle
{
Fill =
new Brush(
cor
),
Outline =
new Mapsui.Styles.Pen(
Mapsui.Styles.Color.White,
entidade.EmFoco
? 3
: 2
)
}
);
features.Add(
feature
);
/*
* Centro branco da base.
*/
var centroFeature =
new PointFeature(
centro
);
centroFeature["EntidadeId"] =
entidade.Id;
centroFeature.Styles.Add(
new SymbolStyle
{
SymbolType =
SymbolType.Ellipse,
SymbolScale =
0.45,
Fill =
new Brush(
Mapsui.Styles.Color.White
),
Outline =
null
}
);
features.Add(
centroFeature
);
}
private static void CriarCentroELabel(
MapaEntidadeDinamica entidade,
MPoint centro,
List<IFeature> features)
{
var ponto =
new PointFeature(
centro
);
/*
* Isso será muito útil no 7C:
*
* feature visual -> EntidadeId
*/
ponto["EntidadeId"] =
entidade.Id;
ponto["Tipo"] =
entidade.Tipo.ToString();
/*
* Pequeno ponto central.
*/
ponto.Styles.Add(
new SymbolStyle
{
SymbolScale =
0.10,
Fill =
new Brush(
Mapsui.Styles.Color.White
),
Outline =
null
}
);
ponto.Styles.Add(
new LabelStyle
{
Text =
entidade.Nome,
ForeColor =
Mapsui.Styles.Color.White,
BackColor =
new Brush(
new Mapsui.Styles.Color(
0,
0,
0,
150
)
),
Offset =
new Offset(
0,
-34
),
Font =
new Font
{
FontFamily =
"Segoe UI",
Size =
15
}
}
);
features.Add(
ponto
);
}
}
}

View File

@ -0,0 +1,175 @@
using Mapsui;
using Mapsui.Layers;
using Mapsui.Nts;
using Mapsui.Projections;
using Mapsui.Styles;
using NetTopologySuite.Geometries;
using OperationControl.Mapas.Models;
using Color = Mapsui.Styles.Color;
using Pen = Mapsui.Styles.Pen;
namespace OperationControl.Mapas.Rendering
{
public sealed class MapaPredicaoMpcRenderService
{
public const string NomeCamada =
"MapaPredicaoMpcNova";
public MemoryLayer CriarCamada(
MapaEstadoMovimento estado)
{
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
return new MemoryLayer
{
Name = NomeCamada,
Features =
CriarFeatures(
estado
),
Style = null
};
}
public void AtualizarCamada(
MemoryLayer camada,
MapaEstadoMovimento estado)
{
if (camada == null)
{
throw new ArgumentNullException(
nameof(camada)
);
}
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
camada.Features =
CriarFeatures(
estado
);
}
private static List<IFeature>
CriarFeatures(
MapaEstadoMovimento estado)
{
var features =
new List<IFeature>();
foreach (
var movimento
in estado.Rovers)
{
if (
movimento.Predicao == null ||
movimento.Predicao.Count < 2
)
{
continue;
}
var coordenadas =
new List<Coordinate>();
foreach (
var ponto
in movimento.Predicao)
{
var mercator =
SphericalMercator
.FromLonLat(
ponto.Longitude,
ponto.Latitude
);
coordenadas.Add(
new Coordinate(
mercator.x,
mercator.y
)
);
}
if (
coordenadas.Count < 2
)
{
continue;
}
var feature =
new GeometryFeature
{
Geometry =
new LineString(
coordenadas
.ToArray()
)
};
feature["RoverId"] =
movimento.RoverId;
feature["Tipo"] =
"Predict";
feature.Styles.Add(
CriarEstilo()
);
features.Add(
feature
);
}
return features;
}
private static VectorStyle
CriarEstilo()
{
return new VectorStyle
{
Line =
new Pen
{
Color =
Color.Lime,
Width =
2,
PenStyle =
PenStyle.Dash
}
};
}
}
}

View File

@ -0,0 +1,287 @@
using Mapsui;
using Mapsui.Layers;
using Mapsui.Nts;
using Mapsui.Projections;
using Mapsui.Styles;
using NetTopologySuite.Geometries;
using OperationControl.Mapas.Models;
using Color = Mapsui.Styles.Color;
using Pen = Mapsui.Styles.Pen;
namespace OperationControl.Mapas.Rendering
{
public sealed class MapaRastroRenderService
{
public const string NomeCamada =
"MapaRastroNovo";
/*
* Mesma ideia da paleta do legado:
* cada RoverId recebe uma cor estável.
*
* A cor é responsabilidade de render,
* portanto não precisa entrar no domínio.
*/
private static readonly Color[] Paleta =
{
new Color(0, 191, 255, 255), // DeepSkyBlue
new Color(50, 205, 50, 255), // LimeGreen
new Color(255, 215, 0, 255), // Gold
new Color(255, 165, 0, 255), // Orange
new Color(147, 112, 219, 255), // MediumPurple
new Color(0, 255, 255, 255), // Cyan
new Color(255, 0, 255, 255), // Magenta
new Color(0, 255, 127, 255), // SpringGreen
new Color(255, 127, 80, 255), // Coral
new Color(255, 255, 0, 255) // Yellow
};
public MemoryLayer CriarCamada(
MapaEstadoMovimento estado)
{
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
return new MemoryLayer
{
Name = NomeCamada,
Features = CriarFeatures(
estado
),
Style = null
};
}
public void AtualizarCamada(
MemoryLayer camada,
MapaEstadoMovimento estado)
{
if (camada == null)
{
throw new ArgumentNullException(
nameof(camada)
);
}
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
camada.Features =
CriarFeatures(
estado
);
}
private static List<IFeature>
CriarFeatures(
MapaEstadoMovimento estado)
{
var features =
new List<IFeature>();
foreach (
var movimento
in estado.Rovers)
{
Color corRover =
ObterCorRover(
movimento.RoverId
);
foreach (
var trecho
in movimento.Trechos)
{
if (
trecho == null ||
trecho.Pontos.Count < 2
)
{
continue;
}
var coordenadas =
new List<Coordinate>();
foreach (
var ponto
in trecho.Pontos)
{
var mercator =
SphericalMercator
.FromLonLat(
ponto.Longitude,
ponto.Latitude
);
coordenadas.Add(
new Coordinate(
mercator.x,
mercator.y
)
);
}
if (
coordenadas.Count < 2
)
{
continue;
}
var feature =
new GeometryFeature
{
Geometry =
new LineString(
coordenadas
.ToArray()
)
};
feature["RoverId"] =
movimento.RoverId;
feature["Tipo"] =
"Rastro";
feature["Pulverizando"] =
trecho.Pulverizando
? "1"
: "0";
feature.Styles.Add(
CriarEstilo(
corRover,
trecho.Pulverizando
)
);
features.Add(
feature
);
}
}
return features;
}
private static VectorStyle CriarEstilo(
Color cor,
bool pulverizando)
{
/*
* Mantemos a semântica visual do legado:
*
* normal:
* mais transparente e fino
*
* pulverizando:
* opaco e mais grosso
*/
Color corLinha =
pulverizando
? new Color(
cor.R,
cor.G,
cor.B,
255
)
: new Color(
cor.R,
cor.G,
cor.B,
180
);
return new VectorStyle
{
Line =
new Pen(
corLinha,
pulverizando
? 4
: 2
)
};
}
private static Color ObterCorRover(
string roverId)
{
if (
string.IsNullOrWhiteSpace(
roverId
)
)
{
return Paleta[0];
}
/*
* Hash determinístico.
*
* Não usamos string.GetHashCode()
* porque ele não precisa ser estável
* entre execuções do processo.
*/
uint hash =
2166136261;
foreach (
char caractere
in roverId
.Trim()
.ToUpperInvariant())
{
unchecked
{
hash ^= caractere;
hash *= 16777619;
}
}
int indice =
(int)(
hash %
(uint)Paleta.Length
);
return Paleta[
indice
];
}
}
}

View File

@ -0,0 +1,402 @@
using Mapsui;
using Mapsui.Layers;
using Mapsui.Nts;
using Mapsui.Projections;
using Mapsui.Styles;
using NetTopologySuite.Geometries;
using OperationControl.Mapas.Models;
using Brush = Mapsui.Styles.Brush;
using Color = Mapsui.Styles.Color;
using Pen = Mapsui.Styles.Pen;
namespace OperationControl.Mapas.Rendering
{
public sealed class MapaReferenciaGnssRenderService
{
public const string NomeCamada =
"MapaReferenciasGnssNovo";
public MemoryLayer CriarCamada(
MapaEstadoDinamico estado)
{
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
return new MemoryLayer
{
Name = NomeCamada,
Features =
CriarFeatures(
estado
),
Style = null
};
}
public void AtualizarCamada(
MemoryLayer camada,
MapaEstadoDinamico estado)
{
if (camada == null)
{
throw new ArgumentNullException(
nameof(camada)
);
}
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
camada.Features =
CriarFeatures(
estado
);
}
private static List<IFeature>
CriarFeatures(
MapaEstadoDinamico estado)
{
var features =
new List<IFeature>();
foreach (
var entidade
in estado.Entidades)
{
if (
entidade == null ||
!CoordenadaValida(
entidade.Latitude,
entidade.Longitude
) ||
!CoordenadaValida(
entidade.LatitudeBruta,
entidade.LongitudeBruta
)
)
{
continue;
}
/*
* Posição utilizada pelo sistema,
* já considerando a correção aplicada.
*/
var corrigido =
SphericalMercator
.FromLonLat(
entidade.Longitude!.Value,
entidade.Latitude!.Value
);
/*
* Posição GNSS de referência/bruta
* anterior à correção de lever arm.
*/
var bruto =
SphericalMercator
.FromLonLat(
entidade.LongitudeBruta!.Value,
entidade.LatitudeBruta!.Value
);
CriarLinha(
features,
entidade,
bruto.x,
bruto.y,
corrigido.x,
corrigido.y
);
CriarPontoBruto(
features,
entidade,
bruto.x,
bruto.y
);
CriarPontoCorrigido(
features,
entidade,
corrigido.x,
corrigido.y
);
}
return features;
}
private static void CriarLinha(
List<IFeature> features,
MapaEntidadeDinamica entidade,
double rawX,
double rawY,
double corrigidoX,
double corrigidoY)
{
var feature =
new GeometryFeature
{
Geometry =
new LineString(
new[]
{
new Coordinate(
rawX,
rawY
),
new Coordinate(
corrigidoX,
corrigidoY
)
}
)
};
AplicarMetadados(
feature,
entidade,
"LigacaoLeverArm"
);
feature.Styles.Add(
new VectorStyle
{
Line =
new Pen
{
Color =
new Color(
255,
255,
255,
170
),
Width =
1,
PenStyle =
PenStyle.Dash
}
}
);
features.Add(
feature
);
}
private static void CriarPontoBruto(
List<IFeature> features,
MapaEntidadeDinamica entidade,
double x,
double y)
{
var feature =
new PointFeature(
new MPoint(
x,
y
)
);
AplicarMetadados(
feature,
entidade,
"GnssBruto"
);
feature.Styles.Add(
new SymbolStyle
{
SymbolType =
SymbolType.Ellipse,
SymbolScale =
0.55,
Fill =
new Brush(
new Color(
255,
215,
0,
255
)
),
Outline =
new Pen(
new Color(
20,
20,
20,
230
),
1
)
}
);
features.Add(
feature
);
}
private static void CriarPontoCorrigido(
List<IFeature> features,
MapaEntidadeDinamica entidade,
double x,
double y)
{
var feature =
new PointFeature(
new MPoint(
x,
y
)
);
AplicarMetadados(
feature,
entidade,
"GnssCorrigidoLeverArm"
);
feature.Styles.Add(
new SymbolStyle
{
SymbolType =
SymbolType.Ellipse,
SymbolScale =
0.50,
Fill =
new Brush(
Color.White
),
Outline =
new Pen(
new Color(
20,
20,
20,
230
),
1
)
}
);
features.Add(
feature
);
}
private static void AplicarMetadados(
IFeature feature,
MapaEntidadeDinamica entidade,
string referencia)
{
feature["EntidadeId"] =
entidade.Id;
feature["Tipo"] =
"ReferenciaGnss";
feature["Referencia"] =
referencia;
}
private static bool CoordenadaValida(
double? latitude,
double? longitude)
{
if (
!latitude.HasValue ||
!longitude.HasValue
)
{
return false;
}
if (
!double.IsFinite(latitude.Value) ||
!double.IsFinite(longitude.Value)
)
{
return false;
}
if (
latitude.Value < -90.0 ||
latitude.Value > 90.0 ||
longitude.Value < -180.0 ||
longitude.Value > 180.0
)
{
return false;
}
/*
* Mesma proteção que existia no legado:
* não desenhar referência fake em 0,0.
*/
if (
Math.Abs(latitude.Value) <
0.0000001 &&
Math.Abs(longitude.Value) <
0.0000001
)
{
return false;
}
return true;
}
}
}

View File

@ -0,0 +1,55 @@
using Mapsui.Layers;
using Mapsui.Nts;
namespace OperationControl.Mapas.Rendering
{
/// <summary>
/// Resultado da construção visual de um MapaDocumento.
///
/// Não contém a fonte de verdade do mapa.
/// As GeometryFeatures existem apenas como representação
/// para o Mapsui.
/// </summary>
public sealed class MapaRenderResult
{
public Layer Layer { get; }
public IReadOnlyDictionary<string, GeometryFeature>
FeaturesPorRuaId
{ get; }
public int QuantidadeFeatures =>
FeaturesPorRuaId.Count;
public MapaRenderResult(
Layer layer,
Dictionary<string, GeometryFeature> featuresPorRuaId)
{
Layer =
layer
?? throw new ArgumentNullException(nameof(layer));
FeaturesPorRuaId =
featuresPorRuaId
?? throw new ArgumentNullException(nameof(featuresPorRuaId));
}
public GeometryFeature? ObterFeature(
string ruaId)
{
if (string.IsNullOrWhiteSpace(ruaId))
return null;
return FeaturesPorRuaId.TryGetValue(
ruaId.Trim(),
out GeometryFeature? feature
)
? feature
: null;
}
}
}

View File

@ -0,0 +1,417 @@
using Mapsui.Layers;
using Mapsui.Nts;
using Mapsui.Providers;
using Mapsui.Styles;
using OperationControl.Mapas.Models;
using Brush = Mapsui.Styles.Brush;
using Color = Mapsui.Styles.Color;
using Pen = Mapsui.Styles.Pen;
namespace OperationControl.Mapas.Rendering
{
/// <summary>
/// Converte o modelo canônico de mapa em uma representação
/// visual compatível com Mapsui.
///
/// Este serviço NÃO altera o MapaDocumento.
/// Este serviço NÃO possui estado operacional.
/// Este serviço NÃO conhece telas WPF.
/// </summary>
public class MapaRenderService
{
public const string NomeCamadaPadrao =
"MapaOperacionalNovo";
/// <summary>
/// Cria uma nova camada visual a partir do documento.
///
/// O documento permanece sendo a fonte de verdade.
/// </summary>
public MapaRenderResult CriarCamada(
MapaDocumento documento,
string? nomeCamada = null)
{
if (documento == null)
{
throw new ArgumentNullException(
nameof(documento)
);
}
if (documento.Ruas == null ||
documento.Ruas.Count == 0)
{
throw new InvalidOperationException(
"O documento não possui ruas para renderização."
);
}
var features =
new List<GeometryFeature>(
documento.Ruas.Count
);
var featuresPorRuaId =
new Dictionary<string, GeometryFeature>(
StringComparer.OrdinalIgnoreCase
);
foreach (
MapaRua rua
in documento.Ruas)
{
if (rua == null)
continue;
if (string.IsNullOrWhiteSpace(
rua.Id))
{
throw new InvalidOperationException(
"Foi encontrada uma rua sem ID durante a renderização."
);
}
if (rua.Geometria == null ||
rua.Geometria.IsEmpty)
{
throw new InvalidOperationException(
$"A rua '{rua.Id}' não possui geometria válida."
);
}
/*
* IMPORTANTÍSSIMO:
*
* A GeometryFeature recebe uma cópia da geometria.
*
* Assim nenhuma eventual manipulação realizada
* pelo componente visual poderá alterar acidentalmente
* a geometria canônica do MapaDocumento.
*/
var geometriaVisual =
rua.Geometria.Copy();
var feature =
new GeometryFeature
{
Geometry =
geometriaVisual
};
/*
* Esses atributos existem SOMENTE para facilitar:
*
* - identificação visual;
* - hit testing;
* - depuração;
* - futura interação.
*
* Eles NÃO são fonte da verdade.
*/
feature["RuaId"] =
rua.Id;
feature["Id"] =
rua.Id;
feature["Nome"] =
rua.Nome;
feature["ComprimentoMetros"] =
rua.ComprimentoMetros;
/*
* Toda feature nasce visualmente como rua normal.
*
* O estado visual não é armazenado na feature.
* O estilo é apenas uma projeção do estado real.
*/
feature.Styles.Add(
CriarEstiloNormal()
);
if (!featuresPorRuaId.TryAdd(
rua.Id,
feature))
{
throw new InvalidOperationException(
$"O ID de rua '{rua.Id}' está duplicado " +
"durante a renderização."
);
}
features.Add(
feature
);
}
if (features.Count == 0)
{
throw new InvalidOperationException(
"Nenhuma feature visual pôde ser criada."
);
}
/*
* As geometrias do nosso documento são sempre:
*
* EPSG:4326
* X = longitude
* Y = latitude
*/
var providerWgs84 =
new MemoryProvider(
features
)
{
CRS =
"EPSG:4326"
};
/*
* O Mapsui trabalha visualmente em WebMercator.
*
* A reprojeção fica SOMENTE nesta camada visual.
*/
var providerWebMercator =
new ProjectingProvider(
providerWgs84
)
{
CRS =
"EPSG:3857"
};
string nome =
string.IsNullOrWhiteSpace(
nomeCamada)
? NomeCamadaPadrao
: nomeCamada.Trim();
var layer =
new Layer(
nome
)
{
DataSource =
providerWebMercator,
/*
* Os estilos pertencem às features visuais.
*
* Isso permitirá que cada rua tenha seu próprio
* estado visual sem duplicar camadas.
*/
Style = null
};
return new MapaRenderResult(
layer,
featuresPorRuaId
);
}
/// <summary>
/// Atualiza a representação visual das ruas de acordo
/// com a seleção operacional atual.
///
/// IMPORTANTE:
/// este método não altera MapaDocumento nem MapaSelecaoOperacao.
///
/// Ele apenas faz o Mapsui refletir visualmente o estado real.
/// </summary>
public void AplicarSelecao(
MapaRenderResult render,
MapaSelecaoOperacao selecao)
{
if (render == null)
{
throw new ArgumentNullException(
nameof(render)
);
}
if (selecao == null)
{
throw new ArgumentNullException(
nameof(selecao)
);
}
var selecionadas =
new HashSet<string>(
selecao.RuasOrdenadas,
StringComparer.OrdinalIgnoreCase
);
foreach (
var item
in render.FeaturesPorRuaId)
{
string ruaId =
item.Key;
GeometryFeature feature =
item.Value;
bool selecionada =
selecionadas.Contains(
ruaId
);
feature.Styles.Clear();
feature.Styles.Add(
selecionada
? CriarEstiloSelecionado()
: CriarEstiloNormal()
);
}
}
/// <summary>
/// Atualiza visualmente apenas uma rua.
///
/// Útil para interações pontuais, evitando percorrer
/// o mapa inteiro a cada clique.
/// </summary>
public void AtualizarEstiloRua(
MapaRenderResult render,
MapaSelecaoOperacao selecao,
string ruaId)
{
if (render == null)
{
throw new ArgumentNullException(
nameof(render)
);
}
if (selecao == null)
{
throw new ArgumentNullException(
nameof(selecao)
);
}
if (string.IsNullOrWhiteSpace(
ruaId))
{
throw new ArgumentException(
"O ID da rua não foi informado.",
nameof(ruaId)
);
}
string id =
ruaId.Trim();
GeometryFeature? feature =
render.ObterFeature(
id
);
if (feature == null)
{
throw new InvalidOperationException(
$"A rua '{id}' não possui representação visual."
);
}
bool selecionada =
selecao.RuasOrdenadas.Contains(
id,
StringComparer.OrdinalIgnoreCase
);
feature.Styles.Clear();
feature.Styles.Add(
selecionada
? CriarEstiloSelecionado()
: CriarEstiloNormal()
);
}
private static VectorStyle CriarEstiloNormal()
{
return new VectorStyle
{
Line =
new Pen(
Color.FromArgb(
255,
46,
204,
113
),
2
),
Fill =
new Brush(
Color.FromArgb(
60,
46,
204,
113
)
)
};
}
private static VectorStyle CriarEstiloSelecionado()
{
return new VectorStyle
{
Line =
new Pen(
Color.FromArgb(
255,
241,
196,
15
),
4
),
Fill = null
};
}
}
}

View File

@ -0,0 +1,275 @@
using Mapsui;
using Mapsui.Layers;
using Mapsui.Nts;
using Mapsui.Styles;
using NetTopologySuite.Geometries;
using OperationControl.Mapas.Models;
using Brush = Mapsui.Styles.Brush;
using Color = Mapsui.Styles.Color;
using Font = Mapsui.Styles.Font;
using Pen = Mapsui.Styles.Pen;
namespace OperationControl.Mapas.Rendering
{
/// <summary>
/// Representação visual da rota manual de retorno.
///
/// Não possui estado operacional.
/// A fonte da verdade continua sendo MapaRotaRetorno.
/// </summary>
public sealed class MapaRotaRetornoRenderService
{
public const string NomeCamada =
"MapaRotaRetornoNovo";
public MemoryLayer CriarCamada(
MapaRotaRetorno rota)
{
if (rota == null)
{
throw new ArgumentNullException(
nameof(rota)
);
}
return new MemoryLayer
{
Name = NomeCamada,
Features = CriarFeatures(rota)
};
}
public void AtualizarCamada(
MemoryLayer camada,
MapaRotaRetorno rota)
{
if (camada == null)
{
throw new ArgumentNullException(
nameof(camada)
);
}
if (rota == null)
{
throw new ArgumentNullException(
nameof(rota)
);
}
camada.Features =
CriarFeatures(
rota
);
}
private static IEnumerable<IFeature>
CriarFeatures(
MapaRotaRetorno rota)
{
var features =
new List<IFeature>();
/*
* Primeiro desenhamos a linha da rota.
*
* A linha só existe visualmente.
* O domínio continua sendo apenas a sequência
* de MapaPontoRetorno.
*/
if (rota.Pontos.Count >= 2)
{
var coordenadas =
rota.Pontos
.Select(
ponto =>
{
var mercator =
Mapsui.Projections
.SphericalMercator
.FromLonLat(
ponto.Longitude,
ponto.Latitude
);
return new Coordinate(
mercator.x,
mercator.y
);
}
)
.ToArray();
var linha =
new LineString(
coordenadas
);
var featureLinha =
new GeometryFeature
{
Geometry = linha
};
featureLinha["Tipo"] =
"RotaRetorno";
featureLinha.Styles.Add(
CriarEstiloLinha()
);
features.Add(
featureLinha
);
}
/*
* Depois entram os pontos numerados.
*
* Como são adicionados depois da linha,
* ficam visualmente por cima dela.
*/
for (
int i = 0;
i < rota.Pontos.Count;
i++)
{
MapaPontoRetorno ponto =
rota.Pontos[i];
var mercator =
Mapsui.Projections
.SphericalMercator
.FromLonLat(
ponto.Longitude,
ponto.Latitude
);
var feature =
new PointFeature(
mercator.x,
mercator.y
);
feature["Tipo"] =
"PontoRetorno";
feature["Ordem"] =
i + 1;
feature["label"] =
(i + 1).ToString();
feature.Styles.Add(
CriarEstiloPonto()
);
feature.Styles.Add(
CriarEstiloLabel()
);
features.Add(
feature
);
}
return features;
}
private static VectorStyle
CriarEstiloLinha()
{
return new VectorStyle
{
Line =
new Pen(
Color.Red,
3
)
};
}
private static SymbolStyle
CriarEstiloPonto()
{
return new SymbolStyle
{
SymbolType =
SymbolType.Ellipse,
SymbolScale =
1.2,
Fill =
new Brush(
Color.Red
),
Outline =
new Pen(
Color.White,
2
)
};
}
private static LabelStyle
CriarEstiloLabel()
{
return new LabelStyle
{
LabelColumn =
"label",
ForeColor =
Color.White,
BackColor =
null,
HorizontalAlignment =
LabelStyle
.HorizontalAlignmentEnum
.Center,
VerticalAlignment =
LabelStyle
.VerticalAlignmentEnum
.Center,
Offset =
new Offset(
0,
0
),
Font =
new Font
{
Size = 12
}
};
}
}
}

View File

@ -0,0 +1,318 @@
using AgroBase.Models;
using NetTopologySuite.Geometries;
using OperationControl.Mapas.Models;
using static AgroBase.Models.Enums;
namespace OperationControl.Mapas.Services
{
/// <summary>
/// Reconstrói um MapaDocumento a partir do contrato
/// operacional utilizado na comunicação base/rover.
///
/// Este serviço não lê arquivos físicos.
/// </summary>
public sealed class MapaContratoImportService
{
private readonly GeometryFactory _geometryFactory =
new GeometryFactory(
new PrecisionModel(),
4326
);
private readonly MapaValidationService
_validationService;
public MapaContratoImportService(
MapaValidationService? validationService = null)
{
_validationService =
validationService
?? new MapaValidationService();
}
public MapaDocumento Importar(
MapaFeatureCollectionModel mapa,
TipoMapaOperacao tipo,
string nome = "Mapa operacional recebido")
{
if (mapa == null)
{
throw new ArgumentNullException(
nameof(mapa)
);
}
if (mapa.features == null ||
mapa.features.Count == 0)
{
throw new InvalidOperationException(
"O mapa recebido não possui ruas."
);
}
var documento =
new MapaDocumento
{
Nome =
string.IsNullOrWhiteSpace(nome)
? "Mapa operacional recebido"
: nome.Trim(),
CaminhoOrigem = null,
FormatoOrigem =
MapaFormatoOrigem
.ContratoOperacional,
Tipo = tipo,
Modificado = false
};
var idsUsados =
new HashSet<string>(
StringComparer.OrdinalIgnoreCase
);
for (
int i = 0;
i < mapa.features.Count;
i++)
{
var feature =
mapa.features[i];
if (feature == null)
{
throw new InvalidOperationException(
$"A feature {i + 1} é nula."
);
}
if (feature.geometry == null)
{
throw new InvalidOperationException(
$"A feature {i + 1} não possui geometria."
);
}
if (!string.Equals(
feature.geometry.type,
"LineString",
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"A feature {i + 1} não é uma LineString."
);
}
if (feature.geometry.coordinates == null ||
feature.geometry.coordinates.Count < 2)
{
throw new InvalidOperationException(
$"A feature {i + 1} não possui pontos suficientes."
);
}
/*
* IMPORTANTE:
*
* properties.Id é autoritativo.
*
* O fluxo legado já produziu mapas onde
* geometry.id podia divergir de properties.Id.
*
* RuasPercorrer e a trajetória do rover trabalham
* pelo properties.Id.
*/
string? idProperties =
feature.properties?
.Id?
.Trim();
string? idGeometry =
feature.geometry?
.id?
.Trim();
string? id =
!string.IsNullOrWhiteSpace(
idProperties)
? idProperties
: idGeometry;
if (string.IsNullOrWhiteSpace(id))
{
throw new InvalidOperationException(
$"A feature {i + 1} não possui ID."
);
}
if (
!string.IsNullOrWhiteSpace(idProperties) &&
!string.IsNullOrWhiteSpace(idGeometry) &&
!string.Equals(
idProperties,
idGeometry,
StringComparison.OrdinalIgnoreCase
)
)
{
documento.AvisosImportacao.Add(
$"Feature {i + 1}: " +
$"properties.Id='{idProperties}' e " +
$"geometry.id='{idGeometry}' divergem. " +
$"Foi utilizado properties.Id."
);
}
if (!idsUsados.Add(id))
{
throw new InvalidOperationException(
$"O mapa recebido possui o ID '{id}' duplicado."
);
}
var coordenadas =
new List<Coordinate>();
for (
int p = 0;
p <
feature.geometry.coordinates.Count;
p++)
{
var ponto =
feature.geometry.coordinates[p];
if (ponto == null ||
ponto.Count < 2)
{
throw new InvalidOperationException(
$"A feature '{id}', ponto {p + 1}, " +
"possui coordenada incompleta."
);
}
double longitude =
ponto[0];
double latitude =
ponto[1];
if (
!double.IsFinite(longitude) ||
!double.IsFinite(latitude) ||
longitude < -180.0 ||
longitude > 180.0 ||
latitude < -90.0 ||
latitude > 90.0
)
{
throw new InvalidOperationException(
$"A feature '{id}', ponto {p + 1}, " +
"possui coordenada WGS84 inválida."
);
}
coordenadas.Add(
new Coordinate(
longitude,
latitude
)
);
}
LineString linha =
_geometryFactory
.CreateLineString(
coordenadas.ToArray()
);
documento.Ruas.Add(
new MapaRua
{
Id = id,
IdOriginal = id,
IdGeradoAutomaticamente =
false,
Nome =
string.IsNullOrWhiteSpace(
feature.properties?.Name)
? $"Rua {id}"
: feature.properties.Name,
Geometria =
linha,
/*
* Contrato já enviado pelo próprio sistema.
* Posteriormente o GeometryService vai
* centralizar este cálculo.
*/
ComprimentoMetros =
feature.properties?.Length ?? 0.0,
Dist1 =
feature.properties?.Dist1 ?? 0.0,
Dist2 =
feature.properties?.Dist2 ?? 0.0,
IndiceFeatureOrigem =
i + 1
}
);
}
var validacao =
_validationService.Validar(
documento
);
if (!validacao.Valido)
{
throw new InvalidOperationException(
"O mapa operacional recebido é inválido:" +
Environment.NewLine +
string.Join(
Environment.NewLine,
validacao.Erros
)
);
}
return documento;
}
}
}

View File

@ -0,0 +1,110 @@
using OperationControl.Mapas.Models;
namespace OperationControl.Mapas.Services
{
/// <summary>
/// Responsável por criar cópias profundas e independentes
/// de documentos canônicos do subsistema de mapas.
///
/// Nenhuma coleção, MapaRua ou geometria mutável é
/// compartilhada entre origem e clone.
/// </summary>
public sealed class MapaDocumentoCloneService
{
public MapaDocumento Clonar(
MapaDocumento origem)
{
if (origem == null)
{
throw new ArgumentNullException(
nameof(origem)
);
}
var clone =
new MapaDocumento
{
Nome =
origem.Nome,
CaminhoOrigem =
origem.CaminhoOrigem,
FormatoOrigem =
origem.FormatoOrigem,
Tipo =
origem.Tipo,
Modificado =
origem.Modificado
};
if (origem.AvisosImportacao != null)
{
clone
.AvisosImportacao
.AddRange(
origem.AvisosImportacao
);
}
if (origem.Ruas == null)
return clone;
foreach (
var rua
in origem.Ruas)
{
if (rua == null)
continue;
clone.Ruas.Add(
new MapaRua
{
Id =
rua.Id,
IdOriginal =
rua.IdOriginal,
IdGeradoAutomaticamente =
rua.IdGeradoAutomaticamente,
Nome =
rua.Nome,
Geometria =
rua.Geometria == null
? null
: (NetTopologySuite
.Geometries
.LineString)
rua.Geometria
.Copy(),
ComprimentoMetros =
rua.ComprimentoMetros,
Dist1 =
rua.Dist1,
Dist2 =
rua.Dist2,
IndiceFeatureOrigem =
rua.IndiceFeatureOrigem
}
);
}
return clone;
}
}
}

View File

@ -0,0 +1,419 @@
using OperationControl.Mapas.Models;
using static AgroBase.Models.Enums;
namespace OperationControl.Mapas.Services
{
public sealed class MapaEntidadeDinamicaService
{
public MapaEntidadeDinamica DefinirBase(
MapaEstadoDinamico estado,
string id,
string nome,
double? latitude,
double? longitude,
double? heading = null,
double? latitudeBruta = null,
double? longitudeBruta = null)
{
return DefinirEntidade(
estado,
id,
MapaTipoEntidadeDinamica.Base,
nome,
latitude,
longitude,
heading,
latitudeBruta,
longitudeBruta
);
}
public MapaEntidadeDinamica DefinirRover(
MapaEstadoDinamico estado,
string id,
string nome,
double? latitude,
double? longitude,
double? heading = null,
double? latitudeBruta = null,
double? longitudeBruta = null)
{
return DefinirEntidade(
estado,
id,
MapaTipoEntidadeDinamica.Rover,
nome,
latitude,
longitude,
heading,
latitudeBruta,
longitudeBruta
);
}
public void AtualizarPosicao(
MapaEstadoDinamico estado,
string id,
double? latitude,
double? longitude,
double? heading = null,
double? latitudeBruta = null,
double? longitudeBruta = null)
{
var entidade =
ObterObrigatoria(
estado,
id
);
if (
latitude.HasValue &&
longitude.HasValue
)
{
ValidarCoordenada(
latitude.Value,
longitude.Value
);
entidade.Latitude =
latitude.Value;
entidade.Longitude =
longitude.Value;
}
if (
heading.HasValue &&
double.IsFinite(
heading.Value
)
)
{
entidade.Heading =
NormalizarHeading(
heading.Value
);
}
if (
latitudeBruta.HasValue &&
longitudeBruta.HasValue
)
{
ValidarCoordenada(
latitudeBruta.Value,
longitudeBruta.Value
);
entidade.LatitudeBruta =
latitudeBruta.Value;
entidade.LongitudeBruta =
longitudeBruta.Value;
}
}
public void AtualizarStatus(
MapaEstadoDinamico estado,
string id,
StatusOperacao status,
bool? pulverizando = null)
{
var entidade =
ObterObrigatoria(
estado,
id
);
entidade.Status =
status;
if (pulverizando.HasValue)
{
entidade.Pulverizando =
pulverizando.Value;
}
}
public void DefinirFoco(
MapaEstadoDinamico estado,
string? id)
{
ValidarEstado(
estado
);
foreach (
var entidade
in estado.EntidadesMutaveis.Values)
{
entidade.EmFoco =
false;
}
if (
string.IsNullOrWhiteSpace(id)
)
{
return;
}
if (
estado.EntidadesMutaveis.TryGetValue(
id.Trim(),
out var selecionada
)
)
{
selecionada.EmFoco =
true;
}
}
public MapaEntidadeDinamica?
Obter(
MapaEstadoDinamico estado,
string id)
{
ValidarEstado(
estado
);
if (
string.IsNullOrWhiteSpace(id)
)
{
return null;
}
estado.EntidadesMutaveis
.TryGetValue(
id.Trim(),
out var entidade
);
return entidade;
}
public bool Remover(
MapaEstadoDinamico estado,
string id)
{
ValidarEstado(
estado
);
if (
string.IsNullOrWhiteSpace(id)
)
{
return false;
}
return estado
.EntidadesMutaveis
.Remove(
id.Trim()
);
}
private MapaEntidadeDinamica
DefinirEntidade(
MapaEstadoDinamico estado,
string id,
MapaTipoEntidadeDinamica tipo,
string nome,
double? latitude,
double? longitude,
double? heading,
double? latitudeBruta,
double? longitudeBruta)
{
ValidarEstado(
estado
);
if (
string.IsNullOrWhiteSpace(id)
)
{
throw new ArgumentException(
"O ID da entidade não foi informado.",
nameof(id)
);
}
string idCanonico =
id.Trim();
if (
!estado.EntidadesMutaveis
.TryGetValue(
idCanonico,
out var entidade
)
)
{
entidade =
new MapaEntidadeDinamica
{
Id =
idCanonico
};
estado.EntidadesMutaveis[
idCanonico
] =
entidade;
}
entidade.Tipo =
tipo;
entidade.Nome =
string.IsNullOrWhiteSpace(nome)
? idCanonico
: nome.Trim();
AtualizarPosicao(
estado,
idCanonico,
latitude,
longitude,
heading,
latitudeBruta,
longitudeBruta
);
return entidade;
}
private static MapaEntidadeDinamica
ObterObrigatoria(
MapaEstadoDinamico estado,
string id)
{
ValidarEstado(
estado
);
if (
string.IsNullOrWhiteSpace(id)
)
{
throw new ArgumentException(
"O ID da entidade não foi informado.",
nameof(id)
);
}
if (
!estado.EntidadesMutaveis
.TryGetValue(
id.Trim(),
out var entidade
)
)
{
throw new InvalidOperationException(
$"A entidade '{id}' não existe."
);
}
return entidade;
}
private static void ValidarCoordenada(
double latitude,
double longitude)
{
if (
!double.IsFinite(latitude) ||
latitude < -90.0 ||
latitude > 90.0
)
{
throw new ArgumentOutOfRangeException(
nameof(latitude),
"Latitude WGS84 inválida."
);
}
if (
!double.IsFinite(longitude) ||
longitude < -180.0 ||
longitude > 180.0
)
{
throw new ArgumentOutOfRangeException(
nameof(longitude),
"Longitude WGS84 inválida."
);
}
}
private static double NormalizarHeading(
double heading)
{
double normalizado =
heading % 360.0;
if (normalizado < 0)
{
normalizado +=
360.0;
}
return normalizado;
}
private static void ValidarEstado(
MapaEstadoDinamico estado)
{
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
}
}
}

View File

@ -0,0 +1,752 @@
using NetTopologySuite.Geometries;
using NetTopologySuite.LinearReferencing;
using OperationControl.Mapas.Models;
namespace OperationControl.Mapas.Services
{
public sealed class MapaEstimativaOperacaoService
{
/*
* 21 pontos distribuídos na parte útil
* da rua dão uma leitura boa do espaçamento,
* sem transformar cada clique em um processamento GIS pesado.
*/
private const int QuantidadeAmostrasLargura = 21;
/*
* Cache válido enquanto o Documento oficial
* continuar sendo a mesma instância.
*/
private MapaDocumento?
_documentoCache;
private readonly Dictionary<
string,
MapaEstimativaCorredor>
_cacheCorredores =
new(
StringComparer.OrdinalIgnoreCase
);
public MapaEstimativaOperacao Calcular(
MapaDocumento? documento,
MapaSelecaoOperacao? selecao,
double? velocidadeSemErvasMs = null,
double? velocidadeComErvasMs = null,
double percentualErvas = 0)
{
int quantidadeSelecionadas =
selecao?.Quantidade ?? 0;
var resultado =
new MapaEstimativaOperacao
{
QuantidadeRuasSelecionadas =
quantidadeSelecionadas,
PercentualErvasConsiderado =
Math.Clamp(
percentualErvas,
0,
100
)
};
if (
documento == null ||
selecao == null ||
quantidadeSelecionadas < 2
)
{
resultado.MotivoIndisponivel =
"Selecione ao menos duas ruas.";
return resultado;
}
GarantirCacheDocumento(
documento
);
var ruasPorId =
documento
.Ruas
.Where(
x =>
x != null &&
!string.IsNullOrWhiteSpace(
x.Id
)
)
.ToDictionary(
x => x.Id,
x => x,
StringComparer.OrdinalIgnoreCase
);
/*
* A ordem da seleção é a ordem operacional.
*
* Rua 0 + Rua 1 = corredor 0
* Rua 1 + Rua 2 = corredor 1
* etc.
*/
var ids =
selecao
.RuasOrdenadas
.ToList();
for (
int i = 0;
i < ids.Count - 1;
i++)
{
string idA =
ids[i];
string idB =
ids[i + 1];
if (
!ruasPorId.TryGetValue(
idA,
out var ruaA
) ||
!ruasPorId.TryGetValue(
idB,
out var ruaB
)
)
{
throw new InvalidOperationException(
$"Não foi possível montar " +
$"o corredor {idA} -> {idB}."
);
}
var corredor =
ObterCorredor(
documento,
ruaA,
ruaB
);
resultado
.Corredores
.Add(
corredor
);
}
resultado.DistanciaMetros =
resultado
.Corredores
.Sum(
x =>
x.ComprimentoMetros
);
resultado.AreaMetrosQuadrados =
resultado
.Corredores
.Sum(
x =>
x.AreaMetrosQuadrados
);
resultado.LarguraMediaMetros =
resultado.Corredores.Count > 0
? resultado
.Corredores
.Average(
x =>
x.LarguraMetros
)
: 0;
resultado.TempoEstimadoSegundos =
CalcularTempoEstimado(
resultado.DistanciaMetros,
velocidadeSemErvasMs,
velocidadeComErvasMs,
resultado
.PercentualErvasConsiderado
);
return resultado;
}
private void GarantirCacheDocumento(
MapaDocumento documento)
{
if (
ReferenceEquals(
_documentoCache,
documento
)
)
{
return;
}
_documentoCache =
documento;
_cacheCorredores.Clear();
}
private MapaEstimativaCorredor
ObterCorredor(
MapaDocumento documento,
MapaRua ruaA,
MapaRua ruaB)
{
string chave =
CriarChave(
ruaA.Id,
ruaB.Id
);
if (
_cacheCorredores
.TryGetValue(
chave,
out var existente
)
)
{
return existente;
}
var calculado =
CalcularCorredor(
ruaA,
ruaB
);
_cacheCorredores[
chave
] =
calculado;
return calculado;
}
private static string CriarChave(
string idA,
string idB)
{
return string.Compare(
idA,
idB,
StringComparison.OrdinalIgnoreCase
) <= 0
? $"{idA}\u001F{idB}"
: $"{idB}\u001F{idA}";
}
private static MapaEstimativaCorredor
CalcularCorredor(
MapaRua ruaA,
MapaRua ruaB)
{
if (
ruaA?.Geometria == null ||
ruaB?.Geometria == null ||
ruaA.Geometria.IsEmpty ||
ruaB.Geometria.IsEmpty ||
ruaA.Geometria.NumPoints < 2 ||
ruaB.Geometria.NumPoints < 2
)
{
throw new InvalidOperationException(
"Uma das ruas possui geometria inválida."
);
}
var metricas =
ProjetarParParaMetros(
ruaA.Geometria,
ruaB.Geometria
);
double comprimentoA =
ValorComprimentoValido(
ruaA.ComprimentoMetros
)
? ruaA.ComprimentoMetros
: metricas.A.Length;
double comprimentoB =
ValorComprimentoValido(
ruaB.ComprimentoMetros
)
? ruaB.ComprimentoMetros
: metricas.B.Length;
/*
* Regra operacional definida:
*
* corredor usa o comprimento da MAIOR rua,
* garantindo que a estimativa cubra completamente
* as duas laterais.
*/
double comprimentoCorredor =
Math.Max(
comprimentoA,
comprimentoB
);
double largura =
CalcularLarguraMedia(
metricas.A,
metricas.B
);
if (
!double.IsFinite(largura) ||
largura <= 0
)
{
throw new InvalidOperationException(
$"Não foi possível calcular a largura " +
$"entre as ruas {ruaA.Id} e {ruaB.Id}."
);
}
return new MapaEstimativaCorredor
{
RuaAId =
ruaA.Id,
RuaBId =
ruaB.Id,
ComprimentoMetros =
comprimentoCorredor,
LarguraMetros =
largura
};
}
private static bool ValorComprimentoValido(
double valor)
{
return
double.IsFinite(valor) &&
valor > 0;
}
/*
* O documento permanece WGS84.
*
* Criamos uma projeção cartesiana LOCAL apenas
* para medições métricas.
*
* Para a escala de um talhão agrícola,
* a aproximação é mais do que suficiente.
*/
private static (
LineString A,
LineString B)
ProjetarParParaMetros(
LineString linhaA,
LineString linhaB)
{
var envelope =
new Envelope(
linhaA.EnvelopeInternal
);
envelope.ExpandToInclude(
linhaB.EnvelopeInternal
);
double lonReferencia =
(
envelope.MinX +
envelope.MaxX
) /
2.0;
double latReferencia =
(
envelope.MinY +
envelope.MaxY
) /
2.0;
double latRad =
latReferencia *
Math.PI /
180.0;
const double metrosPorGrauLat =
110540.0;
double metrosPorGrauLon =
111320.0 *
Math.Cos(
latRad
);
var factory =
new GeometryFactory(
new PrecisionModel(),
0
);
LineString Converter(
LineString origem)
{
var coords =
origem
.Coordinates
.Select(
c =>
new Coordinate(
(
c.X -
lonReferencia
) *
metrosPorGrauLon,
(
c.Y -
latReferencia
) *
metrosPorGrauLat
)
)
.ToArray();
return factory
.CreateLineString(
coords
);
}
return (
Converter(linhaA),
Converter(linhaB)
);
}
private static double CalcularLarguraMedia(
LineString linhaA,
LineString linhaB)
{
var distancias =
new List<double>();
AdicionarAmostras(
linhaA,
linhaB,
distancias
);
/*
* Fazemos também B -> A.
*
* Assim a largura não muda simplesmente
* porque o operador selecionou as ruas
* na ordem inversa.
*/
AdicionarAmostras(
linhaB,
linhaA,
distancias
);
distancias =
distancias
.Where(
x =>
double.IsFinite(x) &&
x > 0
)
.OrderBy(
x =>
x
)
.ToList();
if (distancias.Count == 0)
return 0;
/*
* Média aparada de 10%.
*
* Pontinhas divergentes de rua, erros locais
* e finais desencontrados não devem distorcer
* a largura inteira do corredor.
*/
int remover =
distancias.Count >= 10
? (int)Math.Floor(
distancias.Count *
0.10
)
: 0;
int quantidade =
distancias.Count -
remover * 2;
if (quantidade <= 0)
{
return distancias.Average();
}
return distancias
.Skip(remover)
.Take(quantidade)
.Average();
}
private static void AdicionarAmostras(
LineString origem,
LineString destino,
List<double> resultado)
{
if (
origem == null ||
destino == null ||
origem.Length <= 0 ||
destino.Length <= 0
)
{
return;
}
var indexed =
new LengthIndexedLine(
origem
);
double inicio =
indexed.StartIndex;
double fim =
indexed.EndIndex;
for (
int i = 0;
i < QuantidadeAmostrasLargura;
i++)
{
/*
* 5% -> 95%.
*
* Evita que diferenças apenas nas pontas
* sejam interpretadas como largura lateral.
*/
double t =
0.05 +
(
0.90 *
i /
(
QuantidadeAmostrasLargura -
1.0
)
);
double indice =
inicio +
(
fim -
inicio
) *
t;
Coordinate coordenada =
indexed.ExtractPoint(
indice
);
var ponto =
origem.Factory
.CreatePoint(
coordenada
);
double distancia =
ponto.Distance(
destino
);
if (
double.IsFinite(
distancia
)
)
{
resultado.Add(
distancia
);
}
}
}
private static double?
CalcularTempoEstimado(
double distanciaMetros,
double? velocidadeSemErvasMs,
double? velocidadeComErvasMs,
double percentualErvas)
{
if (
!double.IsFinite(
distanciaMetros
) ||
distanciaMetros <= 0
)
{
return null;
}
double fatorErvas =
Math.Clamp(
percentualErvas,
0,
100
) /
100.0;
double fatorSemErvas =
1.0 -
fatorErvas;
if (
fatorSemErvas > 0 &&
(
!velocidadeSemErvasMs.HasValue ||
!double.IsFinite(
velocidadeSemErvasMs.Value
) ||
velocidadeSemErvasMs.Value <= 0
)
)
{
return null;
}
if (
fatorErvas > 0 &&
(
!velocidadeComErvasMs.HasValue ||
!double.IsFinite(
velocidadeComErvasMs.Value
) ||
velocidadeComErvasMs.Value <= 0
)
)
{
return null;
}
double tempo =
0;
if (fatorSemErvas > 0)
{
tempo +=
(
distanciaMetros *
fatorSemErvas
) /
velocidadeSemErvasMs!.Value;
}
if (fatorErvas > 0)
{
tempo +=
(
distanciaMetros *
fatorErvas
) /
velocidadeComErvasMs!.Value;
}
return
double.IsFinite(tempo)
? tempo
: null;
}
}
}

View File

@ -0,0 +1,153 @@
using AgroBase.Models;
using OperationControl.Mapas.Models;
namespace OperationControl.Mapas.Services
{
public class MapaExportService
{
private readonly MapaValidationService
_validationService;
public MapaExportService(
MapaValidationService? validationService = null)
{
_validationService =
validationService
?? new MapaValidationService();
}
/// <summary>
/// Converte o documento canônico do Operation Control
/// para o contrato atualmente consumido pelo rover.
///
/// Nenhuma referência geométrica do documento é compartilhada
/// com o objeto exportado.
/// </summary>
public MapaFeatureCollectionModel
CriarMapaOperacional(
MapaDocumento documento)
{
MapaResultadoValidacao validacao =
_validationService.Validar(
documento
);
if (!validacao.Valido)
{
string erros =
string.Join(
Environment.NewLine,
validacao.Erros
);
throw new InvalidOperationException(
"O mapa não pode ser exportado porque " +
"possui erros de validação:" +
Environment.NewLine +
erros
);
}
var mapa =
new MapaFeatureCollectionModel
{
type =
"FeatureCollection",
features =
new List<MapaFeatureModel>()
};
foreach (
MapaRua rua
in documento.Ruas)
{
var coordenadas =
new List<List<double>>();
foreach (
var coordenada
in rua.Geometria.Coordinates)
{
/*
* Contrato GeoJSON / rover:
*
* [longitude, latitude]
*/
coordenadas.Add(
new List<double>
{
coordenada.X,
coordenada.Y
}
);
}
/*
* Esta igualdade é proposital.
*
* A partir da arquitetura nova existe apenas
* UM ID canônico para a rua.
*/
string id =
rua.Id;
mapa.features.Add(
new MapaFeatureModel
{
type =
"Feature",
properties =
new MapaFeaturePropertiesModel
{
Id =
id,
Name =
string.IsNullOrWhiteSpace(
rua.Nome
)
? $"Rua {id}"
: rua.Nome,
Length =
rua.ComprimentoMetros,
Dist1 =
rua.Dist1,
Dist2 =
rua.Dist2
},
geometry =
new MapaFeatureGeometryModel
{
id =
id,
type =
"LineString",
coordinates =
coordenadas
}
}
);
}
return mapa;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,527 @@
using OperationControl.Mapas.Models;
namespace OperationControl.Mapas.Services
{
public sealed class MapaMovimentoService
{
private const double
RaioTerraMetros =
6371000.0;
public MapaMovimentoRover ObterOuCriar(
MapaEstadoMovimento estado,
string roverId)
{
ValidarEstado(
estado
);
roverId =
NormalizarId(
roverId
);
if (
estado.RoversMutaveis
.TryGetValue(
roverId,
out var existente
)
)
{
return existente;
}
var movimento =
new MapaMovimentoRover
{
RoverId =
roverId
};
estado.RoversMutaveis[
roverId
] =
movimento;
return movimento;
}
public MapaMovimentoRover? Obter(
MapaEstadoMovimento estado,
string roverId)
{
ValidarEstado(
estado
);
if (
string.IsNullOrWhiteSpace(
roverId
)
)
{
return null;
}
estado.RoversMutaveis
.TryGetValue(
roverId.Trim(),
out var movimento
);
return movimento;
}
/// <summary>
/// Atualiza a posição histórica do rover.
///
/// Retorna true somente quando um novo
/// ponto foi realmente incorporado ao rastro.
/// </summary>
public bool AtualizarPosicao(
MapaEstadoMovimento estado,
string roverId,
double latitude,
double longitude,
bool pulverizando,
double distanciaMinimaMetros = 0.05,
double distanciaMaximaSegmentoMetros = 20.0)
{
ValidarCoordenada(
latitude,
longitude
);
if (
!double.IsFinite(
distanciaMinimaMetros
) ||
distanciaMinimaMetros < 0
)
{
throw new ArgumentOutOfRangeException(
nameof(
distanciaMinimaMetros
)
);
}
if (
!double.IsFinite(
distanciaMaximaSegmentoMetros
) ||
distanciaMaximaSegmentoMetros <= 0
)
{
throw new ArgumentOutOfRangeException(
nameof(
distanciaMaximaSegmentoMetros
)
);
}
var movimento =
ObterOuCriar(
estado,
roverId
);
var atual =
new MapaPontoWgs84(
latitude,
longitude
);
var anterior =
movimento
.UltimaPosicaoAceita;
/*
* Primeira posição:
*
* apenas estabelece a origem.
* Não existe segmento ainda.
*/
if (anterior == null)
{
movimento.UltimaPosicaoAceita =
atual;
movimento.QuebrarRastro =
true;
return false;
}
double distancia =
DistanciaMetros(
anterior,
atual
);
/*
* Evita milhares de pontos praticamente
* iguais por jitter ou alta frequência.
*/
if (
distancia <
distanciaMinimaMetros
)
{
return false;
}
/*
* Salto impossível / reconexão / posição
* antiga:
*
* reposiciona a origem mas NÃO desenha
* uma linha atravessando o talhão.
*/
if (
distancia >
distanciaMaximaSegmentoMetros
)
{
movimento.UltimaPosicaoAceita =
atual;
movimento.QuebrarRastro =
true;
return false;
}
MapaTrechoRastro?
trechoAtual =
movimento
.TrechosMutaveis
.LastOrDefault();
bool precisaNovoTrecho =
movimento.QuebrarRastro ||
trechoAtual == null ||
trechoAtual.Pulverizando !=
pulverizando;
if (precisaNovoTrecho)
{
trechoAtual =
new MapaTrechoRastro(
pulverizando
);
/*
* Começamos pelo ponto anterior
* para manter continuidade visual.
*/
trechoAtual
.PontosMutaveis
.Add(
anterior
);
movimento
.TrechosMutaveis
.Add(
trechoAtual
);
}
trechoAtual
.PontosMutaveis
.Add(
atual
);
movimento.UltimaPosicaoAceita =
atual;
movimento.QuebrarRastro =
false;
return true;
}
/// <summary>
/// Substitui a previsão MPC atual.
///
/// Menos de dois pontos significa que
/// não existe previsão desenhável.
/// </summary>
public void DefinirPredicao(
MapaEstadoMovimento estado,
string roverId,
IEnumerable<MapaPontoWgs84>?
pontos)
{
var movimento =
ObterOuCriar(
estado,
roverId
);
var nova =
new List<MapaPontoWgs84>();
if (pontos != null)
{
foreach (
var ponto
in pontos)
{
if (ponto == null)
continue;
ValidarCoordenada(
ponto.Latitude,
ponto.Longitude
);
nova.Add(
new MapaPontoWgs84(
ponto.Latitude,
ponto.Longitude
)
);
}
}
movimento
.PredicaoMutavel
.Clear();
/*
* Isso também resolve um problema do
* legado: previsão velha não fica presa
* quando o MPC para de enviar pontos.
*/
if (nova.Count >= 2)
{
movimento
.PredicaoMutavel
.AddRange(
nova
);
}
}
public void LimparRastro(
MapaEstadoMovimento estado,
string roverId,
bool limparPredicao = true)
{
var movimento =
Obter(
estado,
roverId
);
if (movimento == null)
return;
movimento
.TrechosMutaveis
.Clear();
/*
* Mantemos UltimaPosicaoAceita.
*
* Assim o próximo ponto parte da posição
* atual, como o comportamento antigo.
*/
movimento.QuebrarRastro =
true;
if (limparPredicao)
{
movimento
.PredicaoMutavel
.Clear();
}
}
public void Remover(
MapaEstadoMovimento estado,
string roverId)
{
ValidarEstado(
estado
);
if (
string.IsNullOrWhiteSpace(
roverId
)
)
{
return;
}
estado.RoversMutaveis
.Remove(
roverId.Trim()
);
}
public void Limpar(
MapaEstadoMovimento estado)
{
ValidarEstado(
estado
);
estado.RoversMutaveis
.Clear();
}
private static double DistanciaMetros(
MapaPontoWgs84 a,
MapaPontoWgs84 b)
{
double lat1 =
a.Latitude *
Math.PI / 180.0;
double lat2 =
b.Latitude *
Math.PI / 180.0;
double dLat =
(
b.Latitude -
a.Latitude
) *
Math.PI / 180.0;
double dLon =
(
b.Longitude -
a.Longitude
) *
Math.PI / 180.0;
double h =
Math.Sin(dLat / 2.0) *
Math.Sin(dLat / 2.0) +
Math.Cos(lat1) *
Math.Cos(lat2) *
Math.Sin(dLon / 2.0) *
Math.Sin(dLon / 2.0);
double c =
2.0 *
Math.Atan2(
Math.Sqrt(h),
Math.Sqrt(1.0 - h)
);
return
RaioTerraMetros *
c;
}
private static void ValidarCoordenada(
double latitude,
double longitude)
{
if (
!double.IsFinite(latitude) ||
!double.IsFinite(longitude) ||
latitude < -90.0 ||
latitude > 90.0 ||
longitude < -180.0 ||
longitude > 180.0
)
{
throw new ArgumentOutOfRangeException(
nameof(latitude),
"Coordenada WGS84 inválida."
);
}
}
private static string NormalizarId(
string roverId)
{
if (
string.IsNullOrWhiteSpace(
roverId
)
)
{
throw new ArgumentException(
"RoverId inválido.",
nameof(roverId)
);
}
return roverId.Trim();
}
private static void ValidarEstado(
MapaEstadoMovimento estado)
{
if (estado == null)
{
throw new ArgumentNullException(
nameof(estado)
);
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,433 @@
using OperationControl.Mapas.Models;
namespace OperationControl.Mapas.Services
{
/// <summary>
/// Gerencia a rota manual de retorno.
///
/// Não conhece Mapsui.
/// Não conhece WPF.
/// Não conhece mouse.
///
/// Internamente trabalha sempre com propriedades
/// explícitas Latitude/Longitude.
/// </summary>
public sealed class MapaRotaRetornoService
{
public const double
ToleranciaRemocaoPadraoMetros =
2.0;
/// <summary>
/// Alterna um ponto:
///
/// - se não existir ponto próximo, adiciona ao final;
/// - se existir ponto dentro da tolerância, remove o mais próximo.
///
/// Retorna true quando adicionou.
/// Retorna false quando removeu.
/// </summary>
public bool AlternarPonto(
MapaRotaRetorno rota,
double latitude,
double longitude,
double toleranciaMetros =
ToleranciaRemocaoPadraoMetros)
{
ValidarRota(
rota
);
ValidarCoordenada(
latitude,
longitude
);
if (
double.IsNaN(toleranciaMetros) ||
double.IsInfinity(toleranciaMetros) ||
toleranciaMetros < 0
)
{
throw new ArgumentOutOfRangeException(
nameof(toleranciaMetros),
"A tolerância deve ser finita e não negativa."
);
}
var novo =
new MapaPontoRetorno(
latitude,
longitude
);
int indiceProximo =
EncontrarIndiceMaisProximo(
rota,
novo,
toleranciaMetros
);
if (indiceProximo >= 0)
{
rota.PontosMutaveis
.RemoveAt(
indiceProximo
);
return false;
}
rota.PontosMutaveis.Add(
novo
);
return true;
}
public void AdicionarPonto(
MapaRotaRetorno rota,
double latitude,
double longitude)
{
ValidarRota(
rota
);
ValidarCoordenada(
latitude,
longitude
);
rota.PontosMutaveis.Add(
new MapaPontoRetorno(
latitude,
longitude
)
);
}
public void Limpar(
MapaRotaRetorno rota)
{
ValidarRota(
rota
);
rota.PontosMutaveis.Clear();
}
/// <summary>
/// Distância acumulada entre os pontos da rota.
///
/// Com zero ou um ponto, retorna 0.
/// </summary>
public double ObterDistanciaTotalMetros(
MapaRotaRetorno rota)
{
ValidarRota(
rota
);
if (rota.Quantidade < 2)
return 0.0;
double total =
0.0;
for (
int i = 1;
i < rota.Pontos.Count;
i++)
{
total += DistanciaMetros(
rota.Pontos[i - 1],
rota.Pontos[i]
);
}
return total;
}
/// <summary>
/// Converte para o contrato legado atual do rover.
///
/// ATENÇÃO:
/// PontosRetorno trafega como:
///
/// [latitude, longitude]
///
/// Isso é diferente do GeoJSON:
///
/// [longitude, latitude]
///
/// Essa peculiaridade fica confinada SOMENTE aqui.
/// </summary>
public List<double[]> ExportarContrato(
MapaRotaRetorno rota)
{
ValidarRota(
rota
);
return rota.Pontos
.Select(
x =>
new[]
{
x.Latitude,
x.Longitude
}
)
.ToList();
}
/// <summary>
/// Reconstrói a rota a partir do contrato recebido
/// do rover.
///
/// O método é transacional:
/// somente altera a rota depois que todos
/// os pontos forem validados.
/// </summary>
public void DefinirPeloContrato(
MapaRotaRetorno rota,
IEnumerable<double[]>?
pontosContrato)
{
ValidarRota(
rota
);
var novosPontos =
new List<MapaPontoRetorno>();
foreach (
double[] item
in pontosContrato
?? Enumerable.Empty<double[]>())
{
if (
item == null ||
item.Length < 2
)
{
throw new InvalidOperationException(
"Foi recebido um ponto de retorno incompleto."
);
}
/*
* Contrato atual:
*
* [0] = latitude
* [1] = longitude
*/
double latitude =
item[0];
double longitude =
item[1];
ValidarCoordenada(
latitude,
longitude
);
novosPontos.Add(
new MapaPontoRetorno(
latitude,
longitude
)
);
}
rota.PontosMutaveis.Clear();
rota.PontosMutaveis.AddRange(
novosPontos
);
}
private static int
EncontrarIndiceMaisProximo(
MapaRotaRetorno rota,
MapaPontoRetorno referencia,
double toleranciaMetros)
{
int melhorIndice =
-1;
double melhorDistancia =
double.PositiveInfinity;
for (
int i = 0;
i < rota.Pontos.Count;
i++)
{
double distancia =
DistanciaMetros(
referencia,
rota.Pontos[i]
);
if (
distancia <= toleranciaMetros &&
distancia < melhorDistancia
)
{
melhorIndice =
i;
melhorDistancia =
distancia;
}
}
return melhorIndice;
}
private static double DistanciaMetros(
MapaPontoRetorno a,
MapaPontoRetorno b)
{
const double raioTerra =
6371000.0;
double lat1 =
GrausParaRadianos(
a.Latitude
);
double lat2 =
GrausParaRadianos(
b.Latitude
);
double deltaLat =
GrausParaRadianos(
b.Latitude -
a.Latitude
);
double deltaLon =
GrausParaRadianos(
b.Longitude -
a.Longitude
);
double h =
Math.Sin(
deltaLat / 2.0
) *
Math.Sin(
deltaLat / 2.0
) +
Math.Cos(lat1) *
Math.Cos(lat2) *
Math.Sin(
deltaLon / 2.0
) *
Math.Sin(
deltaLon / 2.0
);
double c =
2.0 *
Math.Atan2(
Math.Sqrt(h),
Math.Sqrt(1.0 - h)
);
return raioTerra * c;
}
private static double
GrausParaRadianos(
double graus)
{
return graus *
Math.PI /
180.0;
}
private static void
ValidarCoordenada(
double latitude,
double longitude)
{
if (
double.IsNaN(latitude) ||
double.IsInfinity(latitude) ||
latitude < -90.0 ||
latitude > 90.0
)
{
throw new ArgumentOutOfRangeException(
nameof(latitude),
"Latitude WGS84 inválida."
);
}
if (
double.IsNaN(longitude) ||
double.IsInfinity(longitude) ||
longitude < -180.0 ||
longitude > 180.0
)
{
throw new ArgumentOutOfRangeException(
nameof(longitude),
"Longitude WGS84 inválida."
);
}
}
private static void ValidarRota(
MapaRotaRetorno rota)
{
if (rota == null)
{
throw new ArgumentNullException(
nameof(rota)
);
}
}
}
}

View File

@ -0,0 +1,417 @@
using OperationControl.Mapas.Models;
namespace OperationControl.Mapas.Services
{
public class MapaSelectionService
{
/// <summary>
/// Alterna o estado de seleção de uma rua.
///
/// Retorna true se a rua ficou selecionada.
/// Retorna false se a rua ficou desmarcada.
/// </summary>
public bool AlternarRua(
MapaDocumento documento,
MapaSelecaoOperacao selecao,
string ruaId)
{
ValidarArgumentos(
documento,
selecao
);
string id =
NormalizarId(
ruaId
);
GarantirRuaExistente(
documento,
id
);
if (selecao.RuasMutaveis.Contains(
id,
StringComparer.OrdinalIgnoreCase))
{
RemoverRua(
documento,
selecao,
id
);
return false;
}
SelecionarRua(
documento,
selecao,
id
);
return true;
}
/// <summary>
/// Seleciona uma rua e a coloca no final da ordem atual.
///
/// Se já estiver selecionada, nada é alterado.
/// </summary>
public bool SelecionarRua(
MapaDocumento documento,
MapaSelecaoOperacao selecao,
string ruaId)
{
ValidarArgumentos(
documento,
selecao
);
string id =
NormalizarId(
ruaId
);
GarantirRuaExistente(
documento,
id
);
if (selecao.RuasMutaveis.Contains(
id,
StringComparer.OrdinalIgnoreCase))
{
return false;
}
selecao.RuasMutaveis.Add(
id
);
return true;
}
/// <summary>
/// Remove uma rua da seleção.
///
/// Como a ordem é representada pela própria posição
/// na lista, nenhuma renumeração manual é necessária.
/// </summary>
public bool RemoverRua(
MapaDocumento documento,
MapaSelecaoOperacao selecao,
string ruaId)
{
ValidarArgumentos(
documento,
selecao
);
string id =
NormalizarId(
ruaId
);
int indice =
selecao.RuasMutaveis.FindIndex(
x => string.Equals(
x,
id,
StringComparison.OrdinalIgnoreCase
)
);
if (indice < 0)
{
return false;
}
selecao.RuasMutaveis.RemoveAt(
indice
);
return true;
}
/// <summary>
/// Substitui a seleção atual por uma lista já ordenada.
///
/// Útil para restaurar uma operação previamente
/// parametrizada ou recebida de outro componente.
///
/// IDs inválidos, vazios ou duplicados não são aceitos.
/// </summary>
public void DefinirSelecao(
MapaDocumento documento,
MapaSelecaoOperacao selecao,
IEnumerable<string>? ruasOrdenadas)
{
ValidarArgumentos(
documento,
selecao
);
var novaSelecao =
new List<string>();
var idsUsados =
new HashSet<string>(
StringComparer.OrdinalIgnoreCase
);
foreach (
string? ruaId
in ruasOrdenadas ?? Enumerable.Empty<string>())
{
string id =
NormalizarId(
ruaId
);
GarantirRuaExistente(
documento,
id
);
if (!idsUsados.Add(id))
{
throw new InvalidOperationException(
$"A seleção contém o ID de rua '{id}' repetido."
);
}
novaSelecao.Add(
id
);
}
/*
* Só altera o objeto oficial depois que a nova
* seleção inteira foi validada.
*
* Assim a operação é transacional:
* se houver um ID inválido no meio da lista,
* a seleção anterior permanece intacta.
*/
selecao.RuasMutaveis.Clear();
selecao.RuasMutaveis.AddRange(
novaSelecao
);
}
public void Limpar(
MapaSelecaoOperacao selecao)
{
if (selecao == null)
{
throw new ArgumentNullException(
nameof(selecao)
);
}
selecao.RuasMutaveis.Clear();
}
public bool EstaSelecionada(
MapaSelecaoOperacao selecao,
string ruaId)
{
if (selecao == null)
{
throw new ArgumentNullException(
nameof(selecao)
);
}
string id =
NormalizarId(
ruaId
);
return selecao.RuasMutaveis.Contains(
id,
StringComparer.OrdinalIgnoreCase
);
}
/// <summary>
/// Retorna a ordem 1-based da rua.
///
/// Exemplo:
/// primeira selecionada = 1.
///
/// Retorna null caso a rua não esteja selecionada.
/// </summary>
public int? ObterOrdem(
MapaSelecaoOperacao selecao,
string ruaId)
{
if (selecao == null)
{
throw new ArgumentNullException(
nameof(selecao)
);
}
string id =
NormalizarId(
ruaId
);
int indice =
selecao.RuasMutaveis.FindIndex(
x => string.Equals(
x,
id,
StringComparison.OrdinalIgnoreCase
)
);
return indice < 0
? null
: indice + 1;
}
/// <summary>
/// Retorna uma cópia independente da seleção.
///
/// Este é o método que futuramente alimentará
/// OperacaoParametrosModel.RuasPercorrer.
/// </summary>
public List<string> ObterRuasOrdenadas(
MapaSelecaoOperacao selecao)
{
if (selecao == null)
{
throw new ArgumentNullException(
nameof(selecao)
);
}
return selecao.RuasOrdenadas
.ToList();
}
private static void GarantirRuaExistente(
MapaDocumento documento,
string ruaId)
{
int quantidade =
documento.Ruas.Count(
x =>
x != null &&
string.Equals(
x.Id,
ruaId,
StringComparison.OrdinalIgnoreCase
)
);
if (quantidade == 0)
{
throw new InvalidOperationException(
$"A rua '{ruaId}' não existe no mapa."
);
}
/*
* Isso jamais deveria acontecer depois que o
* MapaValidationService aprovou o documento.
*
* Mesmo assim protegemos esta fronteira.
*/
if (quantidade > 1)
{
throw new InvalidOperationException(
$"O mapa possui {quantidade} ruas com o ID '{ruaId}'."
);
}
}
private static string NormalizarId(
string? ruaId)
{
if (string.IsNullOrWhiteSpace(
ruaId))
{
throw new ArgumentException(
"O ID da rua não foi informado.",
nameof(ruaId)
);
}
return ruaId.Trim();
}
private static void ValidarArgumentos(
MapaDocumento documento,
MapaSelecaoOperacao selecao)
{
if (documento == null)
{
throw new ArgumentNullException(
nameof(documento)
);
}
if (selecao == null)
{
throw new ArgumentNullException(
nameof(selecao)
);
}
if (documento.Ruas == null ||
documento.Ruas.Count == 0)
{
throw new InvalidOperationException(
"O mapa não possui ruas para seleção."
);
}
}
}
}

View File

@ -0,0 +1,273 @@
using NetTopologySuite.Geometries;
using OperationControl.Mapas.Models;
namespace OperationControl.Mapas.Services
{
public class MapaValidationService
{
public MapaResultadoValidacao Validar(
MapaDocumento? documento)
{
var resultado =
new MapaResultadoValidacao();
if (documento == null)
{
resultado.AdicionarErro(
"O documento de mapa é nulo."
);
return resultado;
}
if (documento.Ruas == null ||
documento.Ruas.Count == 0)
{
resultado.AdicionarErro(
"O mapa não possui ruas."
);
return resultado;
}
var idsUsados =
new HashSet<string>(
StringComparer.OrdinalIgnoreCase
);
for (
int i = 0;
i < documento.Ruas.Count;
i++)
{
MapaRua? rua =
documento.Ruas[i];
int numeroRua =
i + 1;
if (rua == null)
{
resultado.AdicionarErro(
$"A rua na posição {numeroRua} é nula."
);
continue;
}
ValidarId(
rua,
numeroRua,
idsUsados,
resultado
);
ValidarGeometria(
rua,
numeroRua,
resultado
);
}
return resultado;
}
private static void ValidarId(
MapaRua rua,
int indice,
HashSet<string> idsUsados,
MapaResultadoValidacao resultado)
{
if (string.IsNullOrWhiteSpace(rua.Id))
{
resultado.AdicionarErro(
$"A rua na posição {indice} não possui ID."
);
return;
}
string id =
rua.Id.Trim();
if (!idsUsados.Add(id))
{
resultado.AdicionarErro(
$"O ID de rua '{id}' está duplicado."
);
}
}
private static void ValidarGeometria(
MapaRua rua,
int indice,
MapaResultadoValidacao resultado)
{
LineString? geometria =
rua.Geometria;
if (geometria == null)
{
resultado.AdicionarErro(
$"A rua '{ObterNomeRua(rua, indice)}' " +
"não possui geometria."
);
return;
}
if (geometria.IsEmpty)
{
resultado.AdicionarErro(
$"A rua '{ObterNomeRua(rua, indice)}' " +
"possui geometria vazia."
);
return;
}
if (geometria.NumPoints < 2)
{
resultado.AdicionarErro(
$"A rua '{ObterNomeRua(rua, indice)}' " +
"possui menos de dois pontos."
);
return;
}
Coordinate[] coordenadas =
geometria.Coordinates;
bool possuiSegmentoReal = false;
for (
int i = 0;
i < coordenadas.Length;
i++)
{
Coordinate coordenada =
coordenadas[i];
double longitude =
coordenada.X;
double latitude =
coordenada.Y;
bool valida =
double.IsFinite(longitude) &&
double.IsFinite(latitude) &&
longitude >= -180.0 &&
longitude <= 180.0 &&
latitude >= -90.0 &&
latitude <= 90.0;
if (!valida)
{
resultado.AdicionarErro(
$"A rua '{ObterNomeRua(rua, indice)}', " +
$"ponto {i + 1}, possui coordenada inválida: " +
$"longitude={longitude}, latitude={latitude}."
);
/*
* Não precisamos continuar analisando
* a topologia dessa coordenada.
*/
continue;
}
if (i > 0)
{
Coordinate anterior =
coordenadas[i - 1];
if (
Math.Abs(
anterior.X -
coordenada.X
) > 1e-12 ||
Math.Abs(
anterior.Y -
coordenada.Y
) > 1e-12
)
{
possuiSegmentoReal =
true;
}
else
{
resultado.AdicionarAviso(
$"A rua '{ObterNomeRua(rua, indice)}' " +
$"possui pontos consecutivos repetidos " +
$"nas posições {i} e {i + 1}."
);
}
}
}
if (!possuiSegmentoReal)
{
resultado.AdicionarErro(
$"A rua '{ObterNomeRua(rua, indice)}' " +
"não possui deslocamento geométrico."
);
}
if (
!double.IsFinite(
rua.ComprimentoMetros
) ||
rua.ComprimentoMetros < 0
)
{
resultado.AdicionarErro(
$"A rua '{ObterNomeRua(rua, indice)}' " +
"possui comprimento inválido."
);
}
}
private static string ObterNomeRua(
MapaRua rua,
int indice)
{
if (!string.IsNullOrWhiteSpace(
rua.Id))
{
return rua.Id;
}
return indice.ToString();
}
}
}

View File

@ -11,6 +11,7 @@ using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using static AgroBase.Models.Enums;
using Application = System.Windows.Application;
namespace OperationControl.Models
@ -1122,16 +1123,15 @@ namespace OperationControl.Models
"Perda de comunicação com o equipamento!"
);
Variaveis.Dock?
._vm?
._viewOperacaoCenter?
.Mapa?
.markers?
.UpdateMarkerInfo(
rover.RoverId,
status:
AgroBase.Models.Enums
.StatusOperacao.Erro
Application.Current?
.Dispatcher?
.BeginInvoke(
new Action(
() =>
{
Variaveis.Dock?._vm?.AtualizarStatusRoverMapa(rover.RoverId, StatusOperacao.Erro);
}
)
);
}
else

View File

@ -36,6 +36,7 @@
<PackageReference Include="NetTopologySuite.IO.GeoJSON" Version="4.0.0" />
<PackageReference Include="NetTopologySuite.IO.ShapeFile" Version="2.1.0" />
<PackageReference Include="OpenTK" Version="4.9.4" />
<PackageReference Include="ProjNET" Version="2.1.0" />
<PackageReference Include="ScottPlot.WPF" Version="5.1.57" />
<PackageReference Include="System.IO.Ports" Version="10.0.0" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.21" />

View File

@ -43,6 +43,9 @@
<Compile Update="Controls\TemperatureIndicator.xaml.cs">
<SubType>Code</SubType>
</Compile>
<Compile Update="Mapas\Editing\Views\MapaEditorOverlay.xaml.cs">
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\ConfigConexao\ConfigConexaoView.xaml.cs">
<SubType>Code</SubType>
</Compile>
@ -85,6 +88,9 @@
<Compile Update="Views\PreparacaoMapa\PreparacaoMapaBottomView.xaml.cs">
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\PreparacaoMapa\PreparacaoMapaStatusView.xaml.cs">
<SubType>Code</SubType>
</Compile>
<Compile Update="Views\PreparacaoMapa\PreparacaoMapaTopView.xaml.cs">
<SubType>Code</SubType>
</Compile>

View File

@ -589,15 +589,41 @@ namespace OperationControl.Services
if (!fixar)
{
Models.Variaveis.MostrarLog("Configurando explicitamente o módulo como rover parado.");
_expectedRole = GnssExpectedRole.RoverTemporary;
await BaseFix.ConfigurarComoRoverParadoAsync(
portaUsb,
portaEntrada,
_lifetimeCts.Token).ConfigureAwait(false);
Models.Variaveis.MostrarLog(
"Configurando explicitamente o módulo como rover parado."
);
await BaseFix
.ConfigurarComoRoverParadoAsync(
portaUsb,
portaEntrada,
_lifetimeCts.Token
)
.ConfigureAwait(false);
DefinirPapelEsperado(
GnssExpectedRole.RoverTemporary
);
return;
}
/*
* Vamos iniciar uma configuração de BASE.
*
* Se estávamos fazendo levantamento móvel
* com NTRIP, encerramos a injeção RTCM antes
* de começar a enviar os comandos da base.
*
* No método NTRIP, FixarBaseViaNtripAsync()
* iniciará novamente a correção no momento
* correto.
*/
await StopNtripAsync().ConfigureAwait(false);
bool sucesso = false;
string mensagem;
@ -697,7 +723,9 @@ namespace OperationControl.Services
if (sucesso)
{
var snapshot = GetPositionSnapshot();
_expectedRole = GnssExpectedRole.BaseFixed;
DefinirPapelEsperado(
GnssExpectedRole.BaseFixed
);
_lastBaseConfiguration = new BaseFixedConfiguration
{
PortaUsb = portaUsb,
@ -771,7 +799,86 @@ namespace OperationControl.Services
await Task.Delay(1000, ct).ConfigureAwait(false);
await SendCommandsAsync(commands, 300, ct).ConfigureAwait(false);
_expectedRole = GnssExpectedRole.BaseFixed;
if (AppShell.Mock)
_expectedRole = GnssExpectedRole.BaseFixed;
}
public async Task ConfigurarComoMovelParaLevantamentoAsync(bool usarNtrip = true)
{
if (!IsConnected)
{
throw new InvalidOperationException(
"O GNSS não está conectado."
);
}
if (
BaseFix?.CorrecaoEmAndamento ==
true
)
{
throw new InvalidOperationException(
"Existe uma fixação da base em andamento."
);
}
Models.Variaveis.MostrarLog(
"[GNSS][PAPEL] " +
"Alterando para modo móvel / levantamento..."
);
/*
* Se existia uma sessão NTRIP anterior,
* encerramos antes de enviar comandos ASCII.
*
* Isso evita RTCM binário concorrendo com
* configuração do UM982.
*/
await StopNtripAsync()
.ConfigureAwait(false);
BaseFix?.ReiniciarFix();
/*
* ConfigurarModulo(false):
*
* - unlog
* - mode rover uav
* - GGA
* - heading
* - papel esperado = RoverTemporary
*/
await ConfigurarModulo(
fixar: false
)
.ConfigureAwait(false);
/*
* Para levantamento queremos precisão RTK.
*
* Portanto o receptor volta a receber
* correções do caster depois de estar em
* modo rover.
*/
if (usarNtrip)
{
await StartNtripAsync()
.ConfigureAwait(false);
}
Models.Variaveis.MostrarLog(
"[GNSS][PAPEL] " +
$"Modo móvel ativo | " +
$"NTRIP={(usarNtrip ? "SOLICITADO" : "NÃO")}"
);
}
// =========================================================
@ -2148,6 +2255,53 @@ namespace OperationControl.Services
BaseFixed,
}
public enum GnssPapelEstacao
{
Indefinido = 0,
Movel = 1,
BaseFixa = 2
}
public GnssPapelEstacao PapelEstacaoAtual
{
get
{
return _expectedRole switch
{
GnssExpectedRole.RoverTemporary =>
GnssPapelEstacao.Movel,
GnssExpectedRole.BaseFixed =>
GnssPapelEstacao.BaseFixa,
/*
* Quando acabamos de abrir o programa,
* ainda podemos estar em Preserve.
*
* Se o próprio UM982 estiver reportando
* BASE FIX, conseguimos reconhecer que
* ele está funcionando como base.
*/
_ when
GetFixQualitySnapshot() ==
TiposCorrecaoGPS.BaseFix =>
GnssPapelEstacao.BaseFixa,
_ =>
GnssPapelEstacao.Indefinido
};
}
}
private void DefinirPapelEsperado(GnssExpectedRole papel)
{
_expectedRole = papel;
}
private sealed class RtcmEnvelope
{
public int Type { get; init; }

View File

@ -73,7 +73,7 @@ namespace OperationControl.ViewModels
private bool _redeOk;
public bool RedeOk
{
get => _redeOk;
get => _redeOk || AppShell.Mock;
set
{
if (_redeOk != value)

View File

@ -19,7 +19,16 @@ namespace OperationControl.ViewModels.Views.Operacao.Monitoramento
if (_parametros != value)
{
_parametros = value;
OnPropertyChanged(nameof(Parametros));
AtualizarModoOperacaoAtual(
_parametros?.Modo
?? AgroBase.Models.Enums
.ModoOperacao.NaoDefinido
);
OnPropertyChanged(
nameof(Parametros)
);
}
}
}
@ -37,34 +46,383 @@ namespace OperationControl.ViewModels.Views.Operacao.Monitoramento
private AgroBase.Models.Enums.ModoOperacao? modoOperacaoAtual { get; set; }
private int _quantidadeRuasMapa;
public int QuantidadeRuasMapa =>
_quantidadeRuasMapa;
private int _quantidadeRuasPercurso;
public int QuantidadeRuasPercurso =>
_quantidadeRuasPercurso;
private int _quantidadeRuasSelecionadasEdicao;
public int QuantidadeRuasSelecionadasEdicao =>
_quantidadeRuasSelecionadasEdicao;
private int _quantidadeCorredores;
public int QuantidadeCorredores
{
get => _quantidadeCorredores;
private set
{
if (
_quantidadeCorredores ==
value
)
{
return;
}
_quantidadeCorredores =
value;
OnPropertyChanged(
nameof(
QuantidadeCorredores
)
);
}
}
private double _distanciaOperacaoMetros;
public double DistanciaOperacaoMetros
{
get => _distanciaOperacaoMetros;
private set
{
if (
Math.Abs(
_distanciaOperacaoMetros -
value
) <
0.001
)
{
return;
}
_distanciaOperacaoMetros =
value;
OnPropertyChanged(
nameof(
DistanciaOperacaoMetros
)
);
}
}
private double _areaOperacaoHectares;
public double AreaOperacaoHectares
{
get => _areaOperacaoHectares;
private set
{
if (
Math.Abs(
_areaOperacaoHectares -
value
) <
0.000001
)
{
return;
}
_areaOperacaoHectares =
value;
OnPropertyChanged(
nameof(
AreaOperacaoHectares
)
);
}
}
private double _larguraMediaCorredorMetros;
public double LarguraMediaCorredorMetros
{
get => _larguraMediaCorredorMetros;
private set
{
if (
Math.Abs(
_larguraMediaCorredorMetros -
value
) <
0.001
)
{
return;
}
_larguraMediaCorredorMetros =
value;
OnPropertyChanged(
nameof(
LarguraMediaCorredorMetros
)
);
}
}
private double? _tempoEstimadoOperacaoSegundos;
public double? TempoEstimadoOperacaoSegundos
{
get => _tempoEstimadoOperacaoSegundos;
private set
{
_tempoEstimadoOperacaoSegundos =
value;
OnPropertyChanged(
nameof(
TempoEstimadoOperacaoSegundos
)
);
}
}
private bool _edicaoMapaAtiva;
public bool EdicaoMapaAtiva =>
_edicaoMapaAtiva;
private bool _edicaoMapaModificada;
public bool EdicaoMapaModificada =>
_edicaoMapaModificada;
public bool PossuiMapaOperacional =>
_quantidadeRuasMapa > 0;
public bool PodeEditarMapa =>
PossuiMapaOperacional &&
!EdicaoMapaAtiva;
public string MapaResumoTexto
{
get
{
if (!PossuiMapaOperacional)
{
return
"Nenhum mapa operacional carregado.";
}
return
$"{QuantidadeRuasMapa} ruas no mapa | " +
$"{QuantidadeRuasPercurso} no percurso";
}
}
public string EstadoEdicaoMapaTexto
{
get
{
if (!PossuiMapaOperacional)
{
return
"Carregue ou leia um mapa para editar.";
}
if (EdicaoMapaAtiva)
{
return EdicaoMapaModificada
? "Edição em andamento • alterações pendentes"
: "Edição em andamento";
}
return
"Pronto para edição.";
}
}
public string SelecaoEdicaoMapaTexto
{
get
{
if (
QuantidadeRuasSelecionadasEdicao ==
1
)
{
return
"1 rua selecionada para edição";
}
return
$"{QuantidadeRuasSelecionadasEdicao} " +
"ruas selecionadas para edição";
}
}
public bool PossuiEstimativaMapa => QuantidadeCorredores > 0 && DistanciaOperacaoMetros > 0;
public string MapaCorredoresTexto =>
QuantidadeCorredores switch
{
0 =>
"Selecione ao menos 2 ruas para formar um corredor.",
1 =>
$"1 corredor • largura média " +
$"{LarguraMediaCorredorMetros:0.00} m",
_ =>
$"{QuantidadeCorredores} corredores • largura média " +
$"{LarguraMediaCorredorMetros:0.00} m"
};
public string MapaDistanciaTexto => $"{DistanciaOperacaoMetros:N0} m";
public string MapaAreaTexto => $"{AreaOperacaoHectares:0.000} ha";
public string MapaTempoEstimadoTexto
{
get
{
if (
!TempoEstimadoOperacaoSegundos
.HasValue
)
{
return "--:--:--";
}
double segundos =
Math.Max(
0,
TempoEstimadoOperacaoSegundos
.Value
);
var duracao =
TimeSpan
.FromSeconds(
segundos
);
long horas =
(long)Math.Floor(
duracao.TotalHours
);
return string.Format(
"{0:00}:{1:00}:{2:00}",
horas,
duracao.Minutes,
duracao.Seconds
);
}
}
public ParametrizacaoViewModel()
{
CarregarParametrosPadraoCommand = new RelayCommand(p => CarregarParametrosPadrao((AgroBase.Models.Enums.ModoOperacao)p));
LerParametrosCommand = new RelayCommand(_ => LerParametrosSolicitado?.Invoke());
SalvarParametrosCommand = new RelayCommand(_ => SalvarParametrosSolicitado?.Invoke(Parametros));
LerParametrosCommand = new RelayCommand(_ => LerParametrosSolicitado?.Invoke(), _ => !EdicaoMapaAtiva);
SalvarParametrosCommand =new RelayCommand(_ => SalvarParametrosSolicitado?.Invoke(Parametros), _ => !EdicaoMapaAtiva);
EditarMapaCommand = new RelayCommand(_ => EditarMapaSolicitado?.Invoke(), _ => PodeEditarMapa);
CancelarEdicaoMapaCommand = new RelayCommand(_ => CancelarEdicaoMapaSolicitado?.Invoke(), _ => EdicaoMapaAtiva);
ReiniciarOperacaoCommand = new RelayCommand(_ => ReiniciarOperacao());
}
public event Action? LerParametrosSolicitado;
public event Action<OperacaoParametrosModel>? SalvarParametrosSolicitado;
public event Action? EditarMapaSolicitado;
public event Action? CancelarEdicaoMapaSolicitado;
/// <summary>
/// Disparado quando o modo desejado da parametrização muda.
///
/// O Dock usa esse evento para recalcular o modo de
/// interação do mapa sem o MapViewControl conhecer
/// ParametrizacaoViewModel ou estado global da aplicação.
/// </summary>
public event Action<AgroBase.Models.Enums.ModoOperacao>? ModoOperacaoAlterado;
public ICommand LerParametrosCommand { get; }
public ICommand SalvarParametrosCommand { get; }
public ICommand CarregarParametrosPadraoCommand { get; }
public ICommand ReiniciarOperacaoCommand { get; }
public ICommand EditarMapaCommand { get; }
public ICommand CancelarEdicaoMapaCommand { get; }
public Array ModosOperacaoDisponiveis { get; } = Enum.GetValues(typeof(AgroBase.Models.Enums.ModoOperacao));
private void CarregarParametrosPadrao(AgroBase.Models.Enums.ModoOperacao modo)
{
var new_op = OperacaoModel.CarregarParametrosOperacaoPadrao(modo, false);
modoOperacaoAtual = new_op.Parametros.Modo;
new_op.Parametros.Descricao = $"{new_op.Parametros.Modo} - Padrão";
Parametros = new_op.Parametros;
if (VariaveisControleOperacao.RoverEmFoco != null)
VariaveisControleOperacao.RoverEmFoco.Modo = modo;
}
private void AtualizarModoOperacaoAtual(AgroBase.Models.Enums.ModoOperacao modo)
{
if (
modoOperacaoAtual.HasValue &&
modoOperacaoAtual.Value == modo
)
{
return;
}
modoOperacaoAtual =
modo;
ModoOperacaoAlterado?
.Invoke(
modo
);
}
private void ReiniciarOperacao()
{
VariaveisControleOperacao.EnviarComandoIniciarOperacao(false, false);
@ -75,13 +433,143 @@ namespace OperationControl.ViewModels.Views.Operacao.Monitoramento
{
if (origem == null) return;
Parametros = origem;
modoOperacaoAtual = origem.Modo;
OperacaoConcluida = (VariaveisControleOperacao.RoverEmFoco?.DadosLeitura?.Operacao?.Status ?? AgroBase.Models.Enums.StatusOperacao.NaoIniciado) == AgroBase.Models.Enums.StatusOperacao.Concluido;
}
public void ProcessarStreetMapClicked(MapViewControl.StreetMapClickedEventArgs e)
public void AtualizarEstadoMapa(
int quantidadeRuas,
int quantidadeRuasPercurso,
bool edicaoAtiva,
int quantidadeSelecionadasEdicao,
bool edicaoModificada,
int quantidadeCorredores,
double distanciaOperacaoMetros,
double areaOperacaoHectares,
double larguraMediaCorredorMetros,
double? tempoEstimadoOperacaoSegundos)
{
_quantidadeRuasMapa =
Math.Max(
0,
quantidadeRuas
);
_quantidadeRuasPercurso =
Math.Max(
0,
quantidadeRuasPercurso
);
_edicaoMapaAtiva =
edicaoAtiva;
_quantidadeRuasSelecionadasEdicao =
Math.Max(
0,
quantidadeSelecionadasEdicao
);
_edicaoMapaModificada =
edicaoModificada;
QuantidadeCorredores =
quantidadeCorredores;
DistanciaOperacaoMetros =
distanciaOperacaoMetros;
AreaOperacaoHectares =
areaOperacaoHectares;
LarguraMediaCorredorMetros =
larguraMediaCorredorMetros;
TempoEstimadoOperacaoSegundos =
tempoEstimadoOperacaoSegundos;
OnPropertyChanged(
nameof(QuantidadeRuasMapa)
);
OnPropertyChanged(
nameof(QuantidadeRuasPercurso)
);
OnPropertyChanged(
nameof(QuantidadeRuasSelecionadasEdicao)
);
OnPropertyChanged(
nameof(EdicaoMapaAtiva)
);
OnPropertyChanged(
nameof(EdicaoMapaModificada)
);
OnPropertyChanged(
nameof(PossuiMapaOperacional)
);
OnPropertyChanged(
nameof(PodeEditarMapa)
);
OnPropertyChanged(
nameof(MapaResumoTexto)
);
OnPropertyChanged(
nameof(EstadoEdicaoMapaTexto)
);
OnPropertyChanged(
nameof(SelecaoEdicaoMapaTexto)
);
OnPropertyChanged(
nameof(
PossuiEstimativaMapa
)
);
OnPropertyChanged(
nameof(
MapaCorredoresTexto
)
);
OnPropertyChanged(
nameof(
MapaDistanciaTexto
)
);
OnPropertyChanged(
nameof(
MapaAreaTexto
)
);
OnPropertyChanged(
nameof(
MapaTempoEstimadoTexto
)
);
CommandManager
.InvalidateRequerySuggested();
}

View File

@ -118,30 +118,6 @@ namespace OperationControl.ViewModels.Views.Operacao
}
public void ProcessarMarkerClicked(MapMarkerManager.MarkerClickedEventArgs e)
{
if (string.IsNullOrEmpty(e.MarkerId))
{
Variaveis.MostrarLog("Sem rover em foco");
RoverSelecionadoChanged?.Invoke(null);
}
else
{
Variaveis.MostrarLog($"Rover em foco: {e.MarkerId}");
RoverSelecionadoChanged?.Invoke(e.MarkerId);
}
}
public void ProcessarStreetMapClicked(MapViewControl.StreetMapClickedEventArgs e)
{
Variaveis.MostrarLog($"Rua clicada: {e.StreetId}. Ruas selecionadas: {string.Join(",", e.SelectedStreetsIds)}");
}
}
public enum OperacaoCenterConteudo

View File

@ -339,31 +339,178 @@ namespace OperationControl.ViewModels.Views.Operacao
}
else
{
if (((dadosRover?.Operacao?.Modo ?? AgroBase.Models.Enums.ModoOperacao.NaoDefinido) == AgroBase.Models.Enums.ModoOperacao.MapaGPS) && !(VariaveisControleOperacao.RoverEmFoco?.RuasPercorrer?.Any() ?? false))
/*
* =========================================================
* 1. VALIDAÇÃO DA PARAMETRIZAÇÃO
* =========================================================
*/
if (
(
dadosRover?.Operacao?.Modo ??
AgroBase.Models.Enums.ModoOperacao.NaoDefinido
)
==
AgroBase.Models.Enums.ModoOperacao.MapaGPS
&&
!(
VariaveisControleOperacao
.RoverEmFoco?
.RuasPercorrer?
.Any()
??
false
)
)
{
System.Windows.MessageBox.Show("Para iniciar a operação automática, é necessário carregar um mapa e selecionar as ruas onde o equipamento irá operar!", "Parametrização", MessageBoxButton.OK, MessageBoxImage.Warning);
System.Windows.MessageBox.Show(
"Para iniciar a operação automática, " +
"é necessário carregar um mapa e selecionar " +
"as ruas onde o equipamento irá operar!",
"Parametrização",
MessageBoxButton.OK,
MessageBoxImage.Warning
);
return;
}
if (System.Windows.MessageBox.Show("Deseja iniciar a operação com os parâmetros atuais?", "Iniciar Operação", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes)
/*
* =========================================================
* 2. AUTORIZAÇÃO DA ESTAÇÃO GNSS
* =========================================================
*
* O Left Bar não conhece as regras da estação.
* Ele apenas pergunta ao Dock se o início está liberado.
*/
bool inicioAutorizado =
Variaveis.Dock?
._vm?
.AutorizarInicioOperacao()
??
false;
if (!inicioAutorizado)
{
Task.Run(async () =>
{
Variaveis.Dock?._vm?._viewOperacaoCenter?.Mapa?.LimparRastrosRover(VariaveisControleOperacao.RoverEmFoco?.RoverId);
if ((dadosRover?.Operacao?.Status ?? StatusOperacao.NaoIniciado) == StatusOperacao.Concluido)
{
VariaveisControleOperacao.EnviarComandoIniciarOperacao(false, false);
await Task.Delay(2000);
}
bool simulador = AppShell.Mock || AppShell.AddRover; // || VariaveisControleOperacao.RoverEmFoco?.DadosLeitura?.Gnss?.Iniciado != true;
VariaveisControleOperacao.EnviarComandoIniciarOperacao(true, simulador);
await Task.Delay(1000);
if (!(dadosRover?.Operacao?.Liberada ?? false))
{
System.Windows.MessageBox.Show($"{VariaveisControleOperacao.RoverEmFoco?.DadosLeitura?.Operacao?.ImpedimentoErro}", "Operação iniciada com impedimento!", MessageBoxButton.OK, MessageBoxImage.Warning);
}
});
return;
}
/*
* =========================================================
* 3. CONFIRMAÇÃO HUMANA
* =========================================================
*/
if (
System.Windows.MessageBox.Show(
"Deseja iniciar a operação com os parâmetros atuais?",
"Iniciar Operação",
MessageBoxButton.YesNo,
MessageBoxImage.Question
)
!=
MessageBoxResult.Yes
)
{
return;
}
/*
* =========================================================
* 4. FLUXO DE INÍCIO JÁ EXISTENTE
* =========================================================
*/
Task.Run(
async () =>
{
Variaveis.Dock?
._vm?
.LimparMovimentoRover(
VariaveisControleOperacao
.RoverEmFoco?
.RoverId
);
/*
* Se a operação anterior terminou,
* primeiro reseta seu estado.
*/
if (
(
dadosRover?
.Operacao?
.Status
??
StatusOperacao.NaoIniciado
)
==
StatusOperacao.Concluido
)
{
VariaveisControleOperacao
.EnviarComandoIniciarOperacao(
false,
false
);
await Task.Delay(
2000
);
}
/*
* Mock / AddRover continuam funcionando
* normalmente como simulador.
*/
bool simulador =
AppShell.Mock ||
AppShell.AddRover;
VariaveisControleOperacao
.EnviarComandoIniciarOperacao(
true,
simulador
);
await Task.Delay(
1000
);
/*
* Depois do comando ainda preservamos
* a validação que já existia no rover.
*/
if (
!(
dadosRover?
.Operacao?
.Liberada
??
false
)
)
{
System.Windows.MessageBox.Show(
$"{VariaveisControleOperacao.RoverEmFoco?.DadosLeitura?.Operacao?.ImpedimentoErro}",
"Operação iniciada com impedimento!",
MessageBoxButton.OK,
MessageBoxImage.Warning
);
}
}
);
}
}
@ -386,7 +533,7 @@ namespace OperationControl.ViewModels.Views.Operacao
private void ReconfigurarGnss()
{
Variaveis.Dock._vm.CurrentStep = DockStep.PreparacaoMapa;
Variaveis.Dock?._vm?.IrParaConfiguracaoGnss();
}

View File

@ -6,6 +6,7 @@ using System.Globalization;
using System.Windows;
using System.Windows.Input;
using static OperationControl.Services.BaseFixService;
using static OperationControl.Services.GpsService;
namespace OperationControl.ViewModels
{
@ -26,28 +27,60 @@ namespace OperationControl.ViewModels
public event Action<MetodoFixacaoBase, double?, double?, double?>? FixarBaseSolicitado;
// Offset frontal cm, offset lateral cm
public event Action<double?, double?>? AplicarOffsetSolicitado;
public event Action? UsarGnssMovelSolicitado;
public event Action? ContinuarSolicitado;
public event Action? VoltarSolicitado;
public List<MetodoFixacaoItem> MetodosFixacaoDisponiveis { get; } = new()
{
new MetodoFixacaoItem { Valor = MetodoFixacaoBase.Ntrip, Nome = "Utilizando NTRIP" },
new MetodoFixacaoItem { Valor = MetodoFixacaoBase.SurveyIn, Nome = "Modo Survey-In" },
new MetodoFixacaoItem { Valor = MetodoFixacaoBase.Manual, Nome = "Preenchimento manual" }
new MetodoFixacaoItem
{
Valor =
MetodoFixacaoBase.Ntrip,
Nome =
"Correção pela rede (NTRIP)"
},
new MetodoFixacaoItem
{
Valor =
MetodoFixacaoBase.SurveyIn,
Nome =
"Determinar posição no local"
},
new MetodoFixacaoItem
{
Valor =
MetodoFixacaoBase.Manual,
Nome =
"Informar posição conhecida"
}
};
public ICommand CarregarMapaCommand { get; }
public ICommand FixarBaseCommand { get; }
public ICommand AplicarOffsetCommand { get; }
public ICommand UsarGnssMovelCommand { get; }
public ICommand ContinuarCommand { get; }
public ICommand VoltarCommand { get; }
public PreparacaoMapaBottomViewModel()
{
CarregarMapaCommand = new RelayCommand(_ => ExecutarCarregarMapa(), _ => PodeCarregarMapa);
FixarBaseCommand = new RelayCommand(_ => ExecutarFixarBase(), _ => PodeFixarBase);
AplicarOffsetCommand = new RelayCommand(_ => ExecutarAplicarOffset(), _ => PodeEditarOffsetBase);
UsarGnssMovelCommand = new RelayCommand(_ => ExecutarUsarGnssMovel(), _ => PodeUsarGnssMovel);
ContinuarCommand = new RelayCommand(_ => ContinuarSolicitado?.Invoke(), _ => PodeContinuar);
VoltarCommand = new RelayCommand(_ => VoltarSolicitado?.Invoke());
MetodoFixacaoSelecionado = MetodoFixacaoBase.Ntrip;
OffsetFrontalBaseTexto = "0";
OffsetLateralBaseTexto = "0";
StatusTexto = "Pronto para carregar o mapa e fixar a base.";
StatusTexto = "Carregue o mapa da operação e prepare o GNSS.";
}
private MetodoFixacaoBase _metodoFixacaoSelecionado = MetodoFixacaoBase.Ntrip;
@ -85,6 +118,258 @@ namespace OperationControl.ViewModels
}
}
private bool _mapaCarregado;
public bool PodeContinuar =>
_mapaCarregado &&
!FixacaoEmAndamento &&
!AlteracaoPapelGnssEmAndamento;
public bool PodeVoltar =>
!FixacaoEmAndamento &&
!AlteracaoPapelGnssEmAndamento;
public void DefinirMapaCarregado(bool carregado)
{
if (
_mapaCarregado ==
carregado
)
{
return;
}
_mapaCarregado =
carregado;
OnPropertyChanged(
nameof(PodeContinuar)
);
}
private GnssPapelEstacao _papelGnssAtual = GnssPapelEstacao.Indefinido;
public GnssPapelEstacao PapelGnssAtual
{
get =>
_papelGnssAtual;
private set
{
if (
_papelGnssAtual ==
value
)
{
return;
}
_papelGnssAtual =
value;
OnPropertyChanged(
nameof(PapelGnssAtual)
);
OnPropertyChanged(
nameof(PapelGnssTexto)
);
OnPropertyChanged(
nameof(PapelGnssDescricao)
);
OnPropertyChanged(
nameof(PapelGnssCor)
);
OnPropertyChanged(
nameof(TextoBotaoModoGnss)
);
OnPropertyChanged(
nameof(PodeUsarGnssMovel)
);
OnPropertyChanged(
nameof(TextoBotaoFixarBase)
);
}
}
private bool _alteracaoPapelGnssEmAndamento;
public bool AlteracaoPapelGnssEmAndamento {
get =>
_alteracaoPapelGnssEmAndamento;
private set
{
if (
_alteracaoPapelGnssEmAndamento ==
value
)
{
return;
}
_alteracaoPapelGnssEmAndamento =
value;
OnPropertyChanged(
nameof(
AlteracaoPapelGnssEmAndamento
)
);
OnPropertyChanged(
nameof(PapelGnssTexto)
);
OnPropertyChanged(
nameof(PapelGnssDescricao)
);
OnPropertyChanged(
nameof(PapelGnssCor)
);
OnPropertyChanged(
nameof(PodeUsarGnssMovel)
);
OnPropertyChanged(
nameof(PodeCarregarMapa)
);
OnPropertyChanged(
nameof(PodeEditarMetodoFixacao)
);
OnPropertyChanged(
nameof(PodeEditarCoordenadasManuais)
);
OnPropertyChanged(
nameof(PodeEditarOffsetBase)
);
OnPropertyChanged(
nameof(PodeFixarBase)
);
OnPropertyChanged(
nameof(PodeContinuar)
);
OnPropertyChanged(
nameof(PodeVoltar)
);
}
}
public string PapelGnssTexto
{
get
{
if (AlteracaoPapelGnssEmAndamento)
return "CONFIGURANDO...";
if (FixacaoEmAndamento)
return "FIXANDO BASE...";
return PapelGnssAtual switch
{
GnssPapelEstacao.Movel =>
"LEVANTAMENTO MÓVEL",
GnssPapelEstacao.BaseFixa =>
"BASE FIXA",
_ =>
"NÃO DEFINIDO"
};
}
}
public string PapelGnssDescricao
{
get
{
if (AlteracaoPapelGnssEmAndamento)
{
return
"Aguarde enquanto o modo do receptor GNSS é alterado.";
}
if (FixacaoEmAndamento)
{
return
"A posição da estação está sendo determinada.";
}
return PapelGnssAtual switch
{
GnssPapelEstacao.Movel =>
"A antena pode ser movimentada e usada para levantamento e edição do mapa.",
GnssPapelEstacao.BaseFixa =>
"A estação está fixa e pronta para fornecer referência à operação.",
_ =>
"Escolha o modo móvel para levantamento ou fixe a estação como base."
};
}
}
public Brush PapelGnssCor =>
AlteracaoPapelGnssEmAndamento ||
FixacaoEmAndamento
? Brushes.Gold
: PapelGnssAtual switch
{
GnssPapelEstacao.Movel =>
Brushes.DeepSkyBlue,
GnssPapelEstacao.BaseFixa =>
Brushes.LimeGreen,
_ =>
Brushes.Gray
};
public string TextoBotaoModoGnss =>
PapelGnssAtual switch
{
GnssPapelEstacao.BaseFixa =>
"Liberar para levantamento",
GnssPapelEstacao.Movel =>
"Levantamento ativo",
_ =>
"Usar para levantamento"
};
public bool PodeUsarGnssMovel =>
!FixacaoEmAndamento &&
!AlteracaoPapelGnssEmAndamento &&
PapelGnssAtual !=
GnssPapelEstacao.Movel;
private bool _fixacaoEmAndamento;
public bool FixacaoEmAndamento
{
@ -102,18 +387,45 @@ namespace OperationControl.ViewModels
OnPropertyChanged(nameof(PodeEditarOffsetBase));
OnPropertyChanged(nameof(PodeFixarBase));
OnPropertyChanged(nameof(TextoBotaoFixarBase));
OnPropertyChanged(
nameof(PapelGnssTexto)
);
OnPropertyChanged(
nameof(PapelGnssDescricao)
);
OnPropertyChanged(
nameof(PapelGnssCor)
);
OnPropertyChanged(
nameof(PodeUsarGnssMovel)
);
OnPropertyChanged(
nameof(PodeContinuar)
);
OnPropertyChanged(
nameof(PodeVoltar)
);
}
}
}
public bool PodeCarregarMapa => !FixacaoEmAndamento;
public bool PodeCarregarMapa =>
!FixacaoEmAndamento &&
!AlteracaoPapelGnssEmAndamento;
public bool PodeEditarMetodoFixacao => !FixacaoEmAndamento;
public bool PodeEditarMetodoFixacao =>
!FixacaoEmAndamento &&
!AlteracaoPapelGnssEmAndamento;
public bool PodeEditarCoordenadasManuais =>
!FixacaoEmAndamento && MetodoFixacaoSelecionado == MetodoFixacaoBase.Manual;
!FixacaoEmAndamento &&
!AlteracaoPapelGnssEmAndamento &&
MetodoFixacaoSelecionado ==
MetodoFixacaoBase.Manual;
public bool PodeEditarOffsetBase => !FixacaoEmAndamento;
public bool PodeEditarOffsetBase =>
!FixacaoEmAndamento &&
!AlteracaoPapelGnssEmAndamento;
public bool ExibirCamposManuais => MetodoFixacaoSelecionado == MetodoFixacaoBase.Manual;
@ -134,6 +446,9 @@ namespace OperationControl.ViewModels
{
get
{
if (AlteracaoPapelGnssEmAndamento)
return false;
if (FixacaoEmAndamento)
return true; // botão vira "Parar"
@ -153,9 +468,23 @@ namespace OperationControl.ViewModels
if (FixacaoEmAndamento)
return "Parar";
return MetodoFixacaoSelecionado == MetodoFixacaoBase.Manual
? "Aplicar posição manual"
: "Fixar posição da base";
if (
MetodoFixacaoSelecionado ==
MetodoFixacaoBase.Manual
)
{
return PapelGnssAtual ==
GnssPapelEstacao.BaseFixa
? "Reposicionar base"
: "Aplicar posição da base";
}
return PapelGnssAtual ==
GnssPapelEstacao.BaseFixa
? "Reposicionar base"
: "Fixar como base";
}
}
@ -452,6 +781,68 @@ namespace OperationControl.ViewModels
AltitudeManual = null;
}
private void ExecutarUsarGnssMovel()
{
if (!PodeUsarGnssMovel)
return;
UsarGnssMovelSolicitado?.Invoke();
}
public void IniciarAlteracaoPapelGnss()
{
AlteracaoPapelGnssEmAndamento =
true;
StatusTexto =
"Configurando o GNSS para levantamento...";
}
public void FinalizarAlteracaoPapelGnss(bool sucesso, GnssPapelEstacao papel, string mensagem)
{
PapelGnssAtual =
papel;
AlteracaoPapelGnssEmAndamento =
false;
if (
papel !=
GnssPapelEstacao.BaseFixa
)
{
ExibirOffsets =
false;
}
StatusTexto =
mensagem;
}
public void AtualizarPapelGnss(GnssPapelEstacao papel)
{
PapelGnssAtual =
papel;
if (
papel !=
GnssPapelEstacao.BaseFixa
)
{
ExibirOffsets =
false;
}
}
private double? TryParseDouble(string? texto)
{
if (string.IsNullOrWhiteSpace(texto))

View File

@ -36,7 +36,7 @@ namespace OperationControl.ViewModels
set { _headingTexto = value; OnPropertyChanged(nameof(HeadingTexto)); }
}
private string _fixTexto = "Sem fix";
private string _fixTexto = "Sem leitura";
public string FixTexto
{
get => _fixTexto;
@ -74,7 +74,7 @@ namespace OperationControl.ViewModels
public PreparacaoMapaTopViewModel()
{
// Mock inicial para visualização
LatitudeTexto = "-22.172657";
/*LatitudeTexto = "-22.172657";
LongitudeTexto = "-47.395216";
AltitudeTexto = "612.4 m";
HeadingTexto = "183°";
@ -82,7 +82,7 @@ namespace OperationControl.ViewModels
FixCor = Brushes.LimeGreen;
PrecisaoTexto = "1.2 cm";
SatelitesTexto = "24";
IdadeCorrecaoTexto = "0.8 s";
IdadeCorrecaoTexto = "0.8 s";*/
}
public event PropertyChangedEventHandler PropertyChanged;

View File

@ -145,7 +145,7 @@
<!-- Área do mapa com overlays -->
<Grid Grid.Row="1">
<controls:MapViewControl x:Name="MapaMonitoramento" MarkerClicked="MAP_MarkerClicked" StreetMapClicked="MAP_StreetMapClicked" Latitude="-22.1726572492617" Longitude="-47.3952163870556" Scale="5000" />
<controls:MapViewControl x:Name="MapaMonitoramento" Latitude="-22.1726572492617" Longitude="-47.3952163870556" Scale="5000" />
<controls:HeadingIndicator x:Name="areaHeading" Width="130" Height="130" HorizontalAlignment="Left" VerticalAlignment="Top" Margin="2"/>

View File

@ -30,19 +30,6 @@ namespace OperationControl.Views.Operacao.Monitoramento
_vm.CameraErvasFrameTipoAlterado += _vm_CameraErvasFrameTipoAlterado;
}
#region MAPA
private void MAP_MarkerClicked(object? sender, MapMarkerManager.MarkerClickedEventArgs e)
{
}
private void MAP_StreetMapClicked(object? sender, MapViewControl.StreetMapClickedEventArgs e)
{
}
#endregion
#region CAMERA FRONTAL

View File

@ -68,6 +68,14 @@
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
<Setter Property="Cursor" Value="Arrow"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="ParamCheckBoxStyle" TargetType="CheckBox">
@ -204,6 +212,145 @@
</StackPanel>
</Border>
<!-- MAPA OPERACIONAL -->
<Border Style="{StaticResource SectionCardStyle}">
<StackPanel>
<TextBlock Text="Mapa operacional" Foreground="White" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,10"/>
<TextBlock Text="{Binding MapaResumoTexto}" Foreground="#CFCFCF" FontSize="12" TextWrapping="Wrap" Margin="0,0,0,10"/>
<!-- ESTIMATIVA DA OPERAÇÃO -->
<Border Background="#1B2620" BorderBrush="#385A43" BorderThickness="1" CornerRadius="7" Padding="9" Margin="0,0,0,10">
<StackPanel>
<TextBlock
Text="{Binding MapaCorredoresTexto}"
Foreground="#BFD8C6"
FontSize="11"
TextWrapping="Wrap"/>
<Grid
Margin="0,8,0,0"
Visibility="{Binding PossuiEstimativaMapa,
Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock
Grid.Row="0"
Grid.Column="0"
Text="Percurso"
Foreground="#9A9A9A"
FontSize="11"/>
<TextBlock
Grid.Row="0"
Grid.Column="1"
Text="{Binding MapaDistanciaTexto}"
Foreground="#7EE89D"
FontSize="12"
FontWeight="SemiBold"/>
<TextBlock
Grid.Row="1"
Grid.Column="0"
Text="Área"
Foreground="#9A9A9A"
FontSize="11"
Margin="0,4,0,0"/>
<TextBlock
Grid.Row="1"
Grid.Column="1"
Text="{Binding MapaAreaTexto}"
Foreground="#7EE89D"
FontSize="12"
FontWeight="SemiBold"
Margin="0,4,0,0"/>
<TextBlock
Grid.Row="2"
Grid.Column="0"
Text="Tempo estimado"
Foreground="#9A9A9A"
FontSize="11"
Margin="0,4,0,0"/>
<TextBlock
Grid.Row="2"
Grid.Column="1"
Text="{Binding MapaTempoEstimadoTexto}"
Foreground="#7EE89D"
FontSize="12"
FontWeight="SemiBold"
Margin="0,4,0,0"/>
</Grid>
</StackPanel>
</Border>
<!-- STATUS -->
<Border Background="#202020" BorderBrush="#444444" BorderThickness="1" CornerRadius="7" Padding="9" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="{Binding EstadoEdicaoMapaTexto}" Foreground="#DADADA" FontSize="12" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="{Binding SelecaoEdicaoMapaTexto}" Foreground="#FF70CA" FontSize="11" Margin="0,5,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding EdicaoMapaAtiva}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</Border>
<!-- ENTRAR NA EDIÇÃO -->
<Button Content="✎ Editar mapa" Command="{Binding EditarMapaCommand}" Background="#2F5D3A" BorderBrush="#4A8A5B">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource ActionButtonStyle}">
<Style.Triggers>
<DataTrigger Binding="{Binding EdicaoMapaAtiva}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<!-- SAÍDA TEMPORÁRIA / SEGURA -->
<Button Content="✕ Cancelar edição" Command="{Binding CancelarEdicaoMapaCommand}" Background="#5B3030" BorderBrush="#8B4A4A" Margin="0">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource ActionButtonStyle}">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding EdicaoMapaAtiva}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
</Border>
<!-- AÇÕES -->
<Border Style="{StaticResource SectionCardStyle}">
<StackPanel>

View File

@ -19,7 +19,7 @@
<!-- MAPA -->
<Grid Visibility="{Binding ExibirMapa, Converter={StaticResource BoolToVisibilityConverter}}">
<Border Margin="12" Padding="0" CornerRadius="10" Background="#202020" BorderBrush="#353535" BorderThickness="1" ClipToBounds="True">
<controls:MapViewControl x:Name="areaMapa" MarkerClicked="MAP_MarkerClicked" StreetMapClicked="MAP_StreetMapClicked" Latitude="-22.1726572492617" Longitude="-47.3952163870556" Scale="5000" />
<controls:MapViewControl x:Name="areaMapa" Latitude="-22.1726572492617" Longitude="-47.3952163870556" Scale="5000" />
</Border>
</Grid>

View File

@ -23,16 +23,5 @@ namespace OperationControl.Views.Operacao
DataContext = _vm;
}
private void MAP_MarkerClicked(object? sender, MapMarkerManager.MarkerClickedEventArgs e)
{
_vm.ProcessarMarkerClicked(e);
}
private void MAP_StreetMapClicked(object? sender, MapViewControl.StreetMapClickedEventArgs e)
{
_vm.ProcessarStreetMapClicked(e);
((App)System.Windows.Application.Current).Shell.Dock?._vm?._viewOperacaoRight?.areaParametrizacao?._vm?.ProcessarStreetMapClicked(e);
}
}
}

View File

@ -1,132 +1,786 @@
<UserControl x:Class="OperationControl.Views.PreparacaoMapaBottomView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter"/>
<Style x:Key="BottomLabelStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="#AAAAAA"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Margin" Value="0,0,6,0"/>
<BooleanToVisibilityConverter
x:Key="BoolToVisibilityConverter"/>
<!-- ===================================================== -->
<!-- SCROLL BAR -->
<!-- ===================================================== -->
<Style x:Key="ThinScrollBar"
TargetType="ScrollBar">
<Setter Property="Width"
Value="8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate
TargetType="ScrollBar">
<Grid
Width="8"
Background="Transparent">
<Border
Margin="2,0,2,0"
Background="#2F2F2F"
CornerRadius="3"/>
<Track
x:Name="PART_Track"
Margin="2,0,2,0"
IsDirectionReversed="True">
<Track.DecreaseRepeatButton>
<RepeatButton
Opacity="0"
IsHitTestVisible="False"/>
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate
TargetType="Thumb">
<Border
Background="#6B7280"
CornerRadius="3"
MinHeight="30"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton
Opacity="0"
IsHitTestVisible="False"/>
</Track.IncreaseRepeatButton>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="BottomTextBoxStyle" TargetType="TextBox">
<Setter Property="Height" Value="30"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Padding" Value="8,2"/>
<Setter Property="Margin" Value="0,0,10,0"/>
<Setter Property="MinWidth" Value="110"/>
<!-- ===================================================== -->
<!-- CARDS -->
<!-- ===================================================== -->
<Style x:Key="SectionCardStyle"
TargetType="Border">
<Setter Property="Background"
Value="#242424"/>
<Setter Property="BorderBrush"
Value="#3A3A3A"/>
<Setter Property="BorderThickness"
Value="1"/>
<Setter Property="CornerRadius"
Value="10"/>
<Setter Property="Padding"
Value="14"/>
<Setter Property="Margin"
Value="0,0,0,12"/>
</Style>
<Style x:Key="SectionTitleStyle"
TargetType="TextBlock">
<Setter Property="Foreground"
Value="White"/>
<Setter Property="FontSize"
Value="14"/>
<Setter Property="FontWeight"
Value="SemiBold"/>
</Style>
<Style x:Key="SectionDescriptionStyle"
TargetType="TextBlock">
<Setter Property="Foreground"
Value="#8F8F8F"/>
<Setter Property="FontSize"
Value="11"/>
<Setter Property="TextWrapping"
Value="Wrap"/>
<Setter Property="Margin"
Value="0,4,0,12"/>
</Style>
<Style x:Key="FieldLabelStyle"
TargetType="TextBlock">
<Setter Property="Foreground"
Value="#A8A8A8"/>
<Setter Property="FontSize"
Value="11"/>
<Setter Property="Margin"
Value="0,0,0,5"/>
</Style>
<Style x:Key="FieldTextBoxStyle"
TargetType="TextBox">
<Setter Property="Height"
Value="34"/>
<Setter Property="Background"
Value="#1C1C1C"/>
<Setter Property="Foreground"
Value="White"/>
<Setter Property="BorderBrush"
Value="#484848"/>
<Setter Property="BorderThickness"
Value="1"/>
<Setter Property="Padding"
Value="8,4"/>
<Setter Property="VerticalContentAlignment"
Value="Center"/>
</Style>
<!-- ===================================================== -->
<!-- BOTÕES -->
<!-- ===================================================== -->
<Style x:Key="PanelButtonStyle"
TargetType="Button">
<Setter Property="Height"
Value="40"/>
<Setter Property="Foreground"
Value="White"/>
<Setter Property="Background"
Value="#303030"/>
<Setter Property="BorderBrush"
Value="#505050"/>
<Setter Property="BorderThickness"
Value="1"/>
<Setter Property="FontSize"
Value="12"/>
<Setter Property="FontWeight"
Value="SemiBold"/>
<Setter Property="Cursor"
Value="Hand"/>
<Setter Property="HorizontalContentAlignment"
Value="Center"/>
<Setter Property="VerticalContentAlignment"
Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate
TargetType="Button">
<Border
x:Name="ButtonBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="7">
<ContentPresenter
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger
Property="IsMouseOver"
Value="True">
<Setter
TargetName="ButtonBorder"
Property="Background"
Value="#393939"/>
</Trigger>
<Trigger
Property="IsEnabled"
Value="False">
<Setter
TargetName="ButtonBorder"
Property="Opacity"
Value="0.38"/>
<Setter
Property="Cursor"
Value="Arrow"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="PrimaryButtonStyle"
TargetType="Button"
BasedOn="{StaticResource PanelButtonStyle}">
<Setter Property="Background"
Value="#235B3A"/>
<Setter Property="BorderBrush"
Value="#3E8B5E"/>
</Style>
<Style x:Key="ContinueButtonStyle"
TargetType="Button"
BasedOn="{StaticResource PanelButtonStyle}">
<Setter Property="Height"
Value="44"/>
<Setter Property="Background"
Value="#174D35"/>
<Setter Property="BorderBrush"
Value="#43A36A"/>
<Setter Property="FontSize"
Value="13"/>
</Style>
</UserControl.Resources>
<Grid Background="#181818" Margin="0">
<Grid Background="#1C1C1C">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="64"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- LINHA 1 -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="240"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Botão carregar mapa -->
<Button Grid.Column="0" Content="Carregar mapa" Margin="12,12,8,6" Padding="18,10" MinWidth="150" Command="{Binding CarregarMapaCommand}" IsEnabled="{Binding PodeCarregarMapa}"/>
<!-- ===================================================== -->
<!-- CABEÇALHO -->
<!-- ===================================================== -->
<!-- Combo método -->
<ComboBox Grid.Column="1" Margin="8,12,8,6" VerticalContentAlignment="Center" ItemsSource="{Binding MetodosFixacaoDisponiveis}" DisplayMemberPath="Nome" SelectedValuePath="Valor" SelectedValue="{Binding MetodoFixacaoSelecionado, Mode=TwoWay}" IsEnabled="{Binding PodeEditarMetodoFixacao}"/>
<Border
Grid.Row="0"
Background="#222222"
BorderBrush="#343434"
BorderThickness="0,0,0,1">
<!-- Botão fixar -->
<Button Grid.Column="2" Content="{Binding TextoBotaoFixarBase}" Margin="8,12,12,6" Padding="18,10" MinWidth="180" Command="{Binding FixarBaseCommand}" IsEnabled="{Binding PodeFixarBase}"/>
<Grid Margin="12,0">
<!-- ÁREA DINÂMICA -->
<Grid Grid.Column="3" Margin="0,12,12,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Progresso normal -->
<Grid Grid.Column="0" Visibility="{Binding ExibirProgresso, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="320"/>
<ColumnDefinition Width="50"/>
</Grid.ColumnDefinitions>
<ProgressBar Grid.Column="0" Margin="0,8,8,0" Height="22" Minimum="0" Maximum="100" Value="{Binding ProgressoFixacao}"/>
<!-- ============================================= -->
<!-- VOLTAR -->
<!-- ============================================= -->
<TextBlock Grid.Column="1" Text="{Binding ProgressoFixacaoTexto}" VerticalAlignment="Center" HorizontalAlignment="Center" Foreground="White" FontWeight="SemiBold" FontSize="14" Margin="0,0,8,0"/>
</Grid>
<Button
Grid.Column="0"
Width="76"
Height="32"
Command="{Binding VoltarCommand}"
IsEnabled="{Binding PodeVoltar}"
VerticalAlignment="Center"
Background="#292929"
Foreground="#D8D8D8"
BorderBrush="#454545"
BorderThickness="1"
Cursor="Hand"
FontSize="11"
FontWeight="SemiBold"
Content="‹ Voltar">
<!-- Campos manuais -->
<Grid Grid.Column="0" Visibility="{Binding ExibirCamposManuais, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="125"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="125"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="110"/>
</Grid.ColumnDefinitions>
<Button.Template>
<TextBlock Grid.Column="0" Text="Latitude (º):" Style="{StaticResource BottomLabelStyle}"/>
<ControlTemplate TargetType="Button">
<TextBox Grid.Column="1" Text="{Binding LatitudeManualTexto, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource BottomTextBoxStyle}" IsEnabled="{Binding PodeEditarCoordenadasManuais}"/>
<Border
x:Name="Border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6">
<TextBlock Grid.Column="2" Text="Longitude (º):" Style="{StaticResource BottomLabelStyle}"/>
<ContentPresenter
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<TextBox Grid.Column="3" Text="{Binding LongitudeManualTexto, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource BottomTextBoxStyle}" IsEnabled="{Binding PodeEditarCoordenadasManuais}"/>
</Border>
<TextBlock Grid.Column="4" Text="Altitude elipsoidal (m):" Style="{StaticResource BottomLabelStyle}"/>
<TextBox Grid.Column="5" Text="{Binding AltitudeManualTexto, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource BottomTextBoxStyle}" IsEnabled="{Binding PodeEditarCoordenadasManuais}"/>
</Grid>
<ControlTemplate.Triggers>
<!-- offsets -->
<Grid Grid.Column="1" Visibility="{Binding ExibirOffsets, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="150"/>
</Grid.ColumnDefinitions>
<Trigger
Property="IsMouseOver"
Value="True">
<TextBlock Grid.Column="0" Text="Offset frontal (cm):" Style="{StaticResource BottomLabelStyle}"/>
<Setter
TargetName="Border"
Property="Background"
Value="#353535"/>
<TextBox Grid.Column="1" Text="{Binding OffsetFrontalBaseTexto, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource BottomTextBoxStyle}" IsEnabled="{Binding PodeEditarOffsetBase}"/>
<Setter
TargetName="Border"
Property="BorderBrush"
Value="#5A5A5A"/>
<TextBlock Grid.Column="2" Text="Offset lateral (cm):" Style="{StaticResource BottomLabelStyle}"/>
</Trigger>
<TextBox Grid.Column="3" Text="{Binding OffsetLateralBaseTexto, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource BottomTextBoxStyle}" IsEnabled="{Binding PodeEditarOffsetBase}"/>
<Button Grid.Column="4" Content="Aplicar Offset" Margin="12,0,12,0" Padding="5,5" MinWidth="120" Command="{Binding AplicarOffsetCommand}" />
<Trigger
Property="IsEnabled"
Value="False">
</Grid>
</Grid>
</Grid>
<Setter
TargetName="Border"
Property="Opacity"
Value="0.35"/>
<!-- LINHA 2 -->
<Grid Grid.Row="1" Margin="12,4,12,10">
</Trigger>
<Border Grid.Row="0" Padding="2" Background="#222" CornerRadius="6">
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
<!-- ============================================= -->
<!-- CONTEXTO -->
<!-- ============================================= -->
<StackPanel
Grid.Column="1"
Margin="12,0,0,0"
VerticalAlignment="Center">
<TextBlock
Text="Preparação"
Foreground="White"
FontSize="17"
FontWeight="SemiBold"/>
<TextBlock
Text="Mapa e posicionamento da estação"
Foreground="#858585"
FontSize="11"
Margin="0,2,0,0"/>
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding StatusTexto}" Foreground="White" FontSize="14" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</Border>
<!-- ===================================================== -->
<!-- CONTEÚDO -->
<!-- ===================================================== -->
<ScrollViewer
Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled"
CanContentScroll="False"
PanningMode="VerticalOnly">
<ScrollViewer.Resources>
<Style
TargetType="ScrollBar"
BasedOn="{StaticResource ThinScrollBar}"/>
</ScrollViewer.Resources>
<StackPanel
Margin="14,14,8,14">
<!-- ============================================= -->
<!-- MAPA -->
<!-- ============================================= -->
<Border
Style="{StaticResource SectionCardStyle}">
<StackPanel>
<TextBlock
Text="Mapa da operação"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock
Text="Carregue o arquivo que representa as ruas da área onde o equipamento irá operar."
Style="{StaticResource SectionDescriptionStyle}"/>
<Button
Content="▣ Carregar ou trocar mapa"
Style="{StaticResource PanelButtonStyle}"
Command="{Binding CarregarMapaCommand}"
IsEnabled="{Binding PodeCarregarMapa}"/>
</StackPanel>
</Border>
<!-- ===================================================== -->
<!-- PAPEL DO GNSS -->
<!-- ===================================================== -->
<Border Style="{StaticResource SectionCardStyle}">
<StackPanel>
<TextBlock
Text="Uso atual do GNSS"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock
Text="Defina se a antena será movimentada para levantamento ou utilizada como referência fixa."
Style="{StaticResource SectionDescriptionStyle}"/>
<Border
Background="#1D1D1D"
BorderBrush="#3D3D3D"
BorderThickness="1"
CornerRadius="7"
Padding="10"
Margin="0,0,0,12">
<StackPanel>
<TextBlock
Text="{Binding PapelGnssTexto}"
Foreground="{Binding PapelGnssCor}"
FontSize="12"
FontWeight="Bold"/>
<TextBlock
Text="{Binding PapelGnssDescricao}"
Foreground="#A0A0A0"
FontSize="10"
TextWrapping="Wrap"
Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Button
Content="{Binding TextoBotaoModoGnss}"
Style="{StaticResource PanelButtonStyle}"
Command="{Binding UsarGnssMovelCommand}"
IsEnabled="{Binding PodeUsarGnssMovel}"/>
</StackPanel>
</Border>
<!-- ============================================= -->
<!-- GNSS / BASE -->
<!-- ============================================= -->
<Border
Style="{StaticResource SectionCardStyle}">
<StackPanel>
<TextBlock
Text="Posicionamento da estação"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock
Text="Defina como a posição da estação será estabelecida quando ela for utilizada como base."
Style="{StaticResource SectionDescriptionStyle}"/>
<TextBlock
Text="Método de posicionamento"
Style="{StaticResource FieldLabelStyle}"/>
<ComboBox
Height="36"
Margin="0,0,0,12"
VerticalContentAlignment="Center"
ItemsSource="{Binding MetodosFixacaoDisponiveis}"
DisplayMemberPath="Nome"
SelectedValuePath="Valor"
SelectedValue="{Binding MetodoFixacaoSelecionado, Mode=TwoWay}"
IsEnabled="{Binding PodeEditarMetodoFixacao}"/>
<!-- ===================================== -->
<!-- MANUAL -->
<!-- ===================================== -->
<StackPanel
Visibility="{Binding ExibirCamposManuais,
Converter={StaticResource BoolToVisibilityConverter}}"
Margin="0,0,0,12">
<TextBlock
Text="Latitude"
Style="{StaticResource FieldLabelStyle}"/>
<TextBox
Text="{Binding LatitudeManualTexto,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource FieldTextBoxStyle}"
IsEnabled="{Binding PodeEditarCoordenadasManuais}"
Margin="0,0,0,10"/>
<TextBlock
Text="Longitude"
Style="{StaticResource FieldLabelStyle}"/>
<TextBox
Text="{Binding LongitudeManualTexto,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource FieldTextBoxStyle}"
IsEnabled="{Binding PodeEditarCoordenadasManuais}"
Margin="0,0,0,10"/>
<TextBlock
Text="Altitude elipsoidal (m)"
Style="{StaticResource FieldLabelStyle}"/>
<TextBox
Text="{Binding AltitudeManualTexto,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource FieldTextBoxStyle}"
IsEnabled="{Binding PodeEditarCoordenadasManuais}"/>
</StackPanel>
<!-- ===================================== -->
<!-- PROGRESSO -->
<!-- ===================================== -->
<Grid
Visibility="{Binding FixacaoEmAndamento,
Converter={StaticResource BoolToVisibilityConverter}}"
Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="48"/>
</Grid.ColumnDefinitions>
<ProgressBar
Grid.Column="0"
Height="18"
Minimum="0"
Maximum="100"
Value="{Binding ProgressoFixacao}"
Margin="0,0,8,0"/>
<TextBlock
Grid.Column="1"
Text="{Binding ProgressoFixacaoTexto}"
Foreground="#D0D0D0"
FontSize="11"
FontWeight="SemiBold"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Grid>
<Button
Content="{Binding TextoBotaoFixarBase}"
Style="{StaticResource PrimaryButtonStyle}"
Command="{Binding FixarBaseCommand}"
IsEnabled="{Binding PodeFixarBase}"/>
</StackPanel>
</Border>
<!-- ============================================= -->
<!-- OFFSET -->
<!-- ============================================= -->
<Border
Style="{StaticResource SectionCardStyle}"
Visibility="{Binding ExibirOffsets,
Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel>
<TextBlock
Text="Ajuste da antena"
Style="{StaticResource SectionTitleStyle}"/>
<TextBlock
Text="Aplique o deslocamento entre o ponto de referência da base e a posição física da antena."
Style="{StaticResource SectionDescriptionStyle}"/>
<TextBlock
Text="Offset frontal (cm)"
Style="{StaticResource FieldLabelStyle}"/>
<TextBox
Text="{Binding OffsetFrontalBaseTexto,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource FieldTextBoxStyle}"
IsEnabled="{Binding PodeEditarOffsetBase}"
Margin="0,0,0,10"/>
<TextBlock
Text="Offset lateral (cm)"
Style="{StaticResource FieldLabelStyle}"/>
<TextBox
Text="{Binding OffsetLateralBaseTexto,
Mode=TwoWay,
UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource FieldTextBoxStyle}"
IsEnabled="{Binding PodeEditarOffsetBase}"
Margin="0,0,0,12"/>
<Button
Content="Aplicar ajuste"
Style="{StaticResource PanelButtonStyle}"
Command="{Binding AplicarOffsetCommand}"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<!-- ===================================================== -->
<!-- AVANÇAR -->
<!-- ===================================================== -->
<Border
Grid.Row="2"
Background="#202020"
BorderBrush="#343434"
BorderThickness="0,1,0,0"
Padding="14">
<Button
Content="Continuar para parametrização ❯"
Style="{StaticResource ContinueButtonStyle}"
Command="{Binding ContinuarCommand}"/>
</Border>
</Grid>
</UserControl>

View File

@ -0,0 +1,46 @@
<UserControl x:Class="OperationControl.Views.PreparacaoMapaStatusView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid Background="#181818">
<Border
Margin="8,5"
Padding="12,7"
Background="#202020"
BorderBrush="#343434"
BorderThickness="1"
CornerRadius="7">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Ellipse
Grid.Column="0"
Width="8"
Height="8"
Fill="#56B97A"
VerticalAlignment="Center"
Margin="0,0,10,0"/>
<TextBlock
Grid.Column="1"
Text="{Binding StatusTexto}"
Foreground="#D0D0D0"
FontSize="12"
TextWrapping="Wrap"
VerticalAlignment="Center"/>
</Grid>
</Border>
</Grid>
</UserControl>

View File

@ -0,0 +1,14 @@
using System.Windows.Controls;
using UserControl = System.Windows.Controls.UserControl;
namespace OperationControl.Views
{
public partial class PreparacaoMapaStatusView : UserControl
{
public PreparacaoMapaStatusView()
{
InitializeComponent();
}
}
}

View File

@ -1,73 +1,254 @@
<UserControl x:Class="OperationControl.Views.PreparacaoMapaTopView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:OperationControl.Views"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
<Grid Background="#222222" Margin="0">
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" Margin="8,6">
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d">
<Border Background="#2E2E2E" CornerRadius="8" Padding="12" Margin="4,0">
<StackPanel>
<TextBlock Text="Latitude" Foreground="#BFBFBF" FontSize="11"/>
<TextBlock Text="{Binding LatitudeTexto}" Foreground="White" FontSize="16" FontWeight="SemiBold"/>
</StackPanel>
</Border>
<UserControl.Resources>
<Border Background="#2E2E2E" CornerRadius="8" Padding="12" Margin="4,0">
<StackPanel>
<TextBlock Text="Longitude" Foreground="#BFBFBF" FontSize="11"/>
<TextBlock Text="{Binding LongitudeTexto}" Foreground="White" FontSize="16" FontWeight="SemiBold"/>
</StackPanel>
</Border>
<Style x:Key="MetricCardStyle"
TargetType="Border">
<Border Background="#2E2E2E" CornerRadius="8" Padding="12" Margin="4,0">
<StackPanel>
<TextBlock Text="Altitude" Foreground="#BFBFBF" FontSize="11"/>
<TextBlock Text="{Binding AltitudeTexto}" Foreground="White" FontSize="16" FontWeight="SemiBold"/>
</StackPanel>
</Border>
<Setter Property="Background"
Value="#292929"/>
<Border Background="#2E2E2E" CornerRadius="8" Padding="12" Margin="4,0">
<StackPanel>
<TextBlock Text="Heading" Foreground="#BFBFBF" FontSize="11"/>
<TextBlock Text="{Binding HeadingTexto}" Foreground="White" FontSize="16" FontWeight="SemiBold"/>
</StackPanel>
</Border>
<Setter Property="BorderBrush"
Value="#3A3A3A"/>
<Border Background="#2E2E2E" CornerRadius="8" Padding="12" Margin="4,0">
<StackPanel>
<TextBlock Text="Fix" Foreground="#BFBFBF" FontSize="11"/>
<TextBlock Text="{Binding FixTexto}" Foreground="{Binding FixCor}" FontSize="16" FontWeight="Bold"/>
</StackPanel>
</Border>
<Setter Property="BorderThickness"
Value="1"/>
<Border Background="#2E2E2E" CornerRadius="8" Padding="12" Margin="4,0">
<StackPanel>
<TextBlock Text="Precisão" Foreground="#BFBFBF" FontSize="11"/>
<TextBlock Text="{Binding PrecisaoTexto}" Foreground="White" FontSize="16" FontWeight="SemiBold"/>
</StackPanel>
</Border>
<Setter Property="CornerRadius"
Value="8"/>
<Border Background="#2E2E2E" CornerRadius="8" Padding="12" Margin="4,0">
<StackPanel>
<TextBlock Text="Satélites" Foreground="#BFBFBF" FontSize="11"/>
<TextBlock Text="{Binding SatelitesTexto}" Foreground="White" FontSize="16" FontWeight="SemiBold"/>
</StackPanel>
</Border>
<Setter Property="Padding"
Value="12,8"/>
<Border Background="#2E2E2E" CornerRadius="8" Padding="12" Margin="4,0">
<StackPanel>
<TextBlock Text="Idade Correção" Foreground="#BFBFBF" FontSize="11"/>
<TextBlock Text="{Binding IdadeCorrecaoTexto}" Foreground="White" FontSize="16" FontWeight="SemiBold"/>
</StackPanel>
</Border>
<Setter Property="Margin"
Value="4,0"/>
<Setter Property="MinWidth"
Value="105"/>
</Style>
<Style x:Key="MetricLabelStyle"
TargetType="TextBlock">
<Setter Property="Foreground"
Value="#8E8E8E"/>
<Setter Property="FontSize"
Value="10"/>
<Setter Property="FontWeight"
Value="SemiBold"/>
</Style>
<Style x:Key="MetricValueStyle"
TargetType="TextBlock">
<Setter Property="Foreground"
Value="White"/>
<Setter Property="FontSize"
Value="15"/>
<Setter Property="FontWeight"
Value="SemiBold"/>
<Setter Property="Margin"
Value="0,3,0,0"/>
</Style>
<Style x:Key="CoordinateLabelStyle"
TargetType="TextBlock">
<Setter Property="Foreground"
Value="#707070"/>
<Setter Property="FontSize"
Value="11"/>
<Setter Property="Margin"
Value="0,0,4,0"/>
</Style>
<Style x:Key="CoordinateValueStyle"
TargetType="TextBlock">
<Setter Property="Foreground"
Value="#BDBDBD"/>
<Setter Property="FontSize"
Value="11"/>
<Setter Property="FontWeight"
Value="SemiBold"/>
</Style>
</UserControl.Resources>
<Grid Background="#1C1C1C"
MinHeight="92">
<Grid Margin="16,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- ================================================= -->
<!-- CONTEXTO -->
<!-- ================================================= -->
<StackPanel Grid.Column="0"
VerticalAlignment="Center">
<TextBlock
Text="PREPARAÇÃO DO MAPA E GNSS"
Foreground="White"
FontSize="17"
FontWeight="Bold"/>
<TextBlock
Text="Carregue o mapa da operação e prepare o posicionamento da estação GNSS."
Foreground="#A0A0A0"
FontSize="12"
Margin="0,3,0,8"/>
<WrapPanel>
<TextBlock
Text="LAT"
Style="{StaticResource CoordinateLabelStyle}"/>
<TextBlock
Text="{Binding LatitudeTexto}"
Style="{StaticResource CoordinateValueStyle}"/>
<TextBlock
Text=" • LON"
Style="{StaticResource CoordinateLabelStyle}"
Margin="12,0,4,0"/>
<TextBlock
Text="{Binding LongitudeTexto}"
Style="{StaticResource CoordinateValueStyle}"/>
<TextBlock
Text=" • ALT"
Style="{StaticResource CoordinateLabelStyle}"
Margin="12,0,4,0"/>
<TextBlock
Text="{Binding AltitudeTexto}"
Style="{StaticResource CoordinateValueStyle}"/>
<TextBlock
Text=" • DIREÇÃO"
Style="{StaticResource CoordinateLabelStyle}"
Margin="12,0,4,0"/>
<TextBlock
Text="{Binding HeadingTexto}"
Style="{StaticResource CoordinateValueStyle}"/>
</WrapPanel>
</StackPanel>
</ScrollViewer>
<!-- ================================================= -->
<!-- SAÚDE / QUALIDADE GNSS -->
<!-- ================================================= -->
<UniformGrid
Grid.Column="1"
Rows="1"
Columns="4"
VerticalAlignment="Center">
<Border Style="{StaticResource MetricCardStyle}">
<StackPanel>
<TextBlock
Text="CORREÇÃO"
Style="{StaticResource MetricLabelStyle}"/>
<TextBlock
Text="{Binding FixTexto}"
Style="{StaticResource MetricValueStyle}"
Foreground="{Binding FixCor}"/>
</StackPanel>
</Border>
<Border Style="{StaticResource MetricCardStyle}">
<StackPanel>
<TextBlock
Text="PRECISÃO"
Style="{StaticResource MetricLabelStyle}"/>
<TextBlock
Text="{Binding PrecisaoTexto}"
Style="{StaticResource MetricValueStyle}"/>
</StackPanel>
</Border>
<Border Style="{StaticResource MetricCardStyle}">
<StackPanel>
<TextBlock
Text="SATÉLITES"
Style="{StaticResource MetricLabelStyle}"/>
<TextBlock
Text="{Binding SatelitesTexto}"
Style="{StaticResource MetricValueStyle}"/>
</StackPanel>
</Border>
<Border Style="{StaticResource MetricCardStyle}">
<StackPanel>
<TextBlock
Text="IDADE"
Style="{StaticResource MetricLabelStyle}"/>
<TextBlock
Text="{Binding IdadeCorrecaoTexto}"
Style="{StaticResource MetricValueStyle}"/>
</StackPanel>
</Border>
</UniformGrid>
</Grid>
</Grid>
</UserControl>
</UserControl>

View File

@ -805,7 +805,7 @@
<Border x:Name="brdMapa" BorderBrush="Black" BorderThickness="1" Margin="465,71,475,116">
<Canvas>
<controls:MapViewControl x:Name="MAP" MarkerClicked="MAP_MarkerClicked" StreetMapClicked="MAP_StreetMapClicked" MbTilesPath="Data/basemap.mbtiles" Latitude="-22.1726572492617" Longitude="-47.3952163870556" Scale="5000" Height="820" Width="978" HorizontalAlignment="Center" VerticalAlignment="Top" />
<controls:MapViewControl x:Name="MAP" MbTilesPath="Data/basemap.mbtiles" Latitude="-22.1726572492617" Longitude="-47.3952163870556" Scale="5000" Height="820" Width="978" HorizontalAlignment="Center" VerticalAlignment="Top" />
<controls:HeadingIndicator x:Name="HDG" Width="130" Height="130" Canvas.Left="10" Canvas.Top="10" HorizontalAlignment="Left" VerticalAlignment="Center"/>
<controls:AttitudeIndicator x:Name="IMU" Width="160" Height="180" Canvas.Left="10" Canvas.Bottom="10" CriticalLimit="20" WarningLimit="10" HorizontalAlignment="Left" VerticalAlignment="Center"/>
</Canvas>

View File

@ -99,52 +99,52 @@ namespace OperationControl.Windows
public void AtualizarListaDispositivos(List<string> dispositivos)
{
Application.Current.Dispatcher.BeginInvoke(new Action(() =>
{
try
{
cmbDispositivo.Items.Clear();
foreach (var d in dispositivos)
{
cmbDispositivo.Items.Add(d);
if (!MAP.markers.Added(d))
{
if (d == VariaveisControleOperacao.BaseMarkerID)
{
MAP.markers.AddMarker(
d,
true,
lat: Variaveis.GpsService?.UltimaLeitura?.Latitude ?? 0,
lon: Variaveis.GpsService?.UltimaLeitura?.Longitude ?? 0,
heading: Variaveis.GpsService?.UltimaLeitura?.OrientacaoReal ?? 0,
label: "Base"
);
}
else
{
var rover = VariaveisControleOperacao.RoversNaRede.FirstOrDefault(x => x.RoverId == d);
if (rover != null)
{
MAP.markers.AddMarker(
d,
false,
lat: rover.DadosLeitura?.Gnss?.Latitude ?? 0,
lon: rover.DadosLeitura?.Gnss?.Longitude ?? 0,
heading: rover.DadosLeitura?.Gnss?.OrientacaoReal ?? 0
);
}
}
}
}
int idx_selected = dispositivos.IndexOf(VariaveisControleOperacao.SelectedRoverId);
if (idx_selected > -1)
cmbDispositivo.SelectedIndex = idx_selected;
}
catch (Exception exUi)
{
Variaveis.MostrarLog($"Erro ao atualizar UI lista dispositivos: {exUi.Message}");
}
}));
//Application.Current.Dispatcher.BeginInvoke(new Action(() =>
//{
// try
// {
// cmbDispositivo.Items.Clear();
// foreach (var d in dispositivos)
// {
// cmbDispositivo.Items.Add(d);
// if (!MAP.markers.Added(d))
// {
// if (d == VariaveisControleOperacao.BaseMarkerID)
// {
// MAP.markers.AddMarker(
// d,
// true,
// lat: Variaveis.GpsService?.UltimaLeitura?.Latitude ?? 0,
// lon: Variaveis.GpsService?.UltimaLeitura?.Longitude ?? 0,
// heading: Variaveis.GpsService?.UltimaLeitura?.OrientacaoReal ?? 0,
// label: "Base"
// );
// }
// else
// {
// var rover = VariaveisControleOperacao.RoversNaRede.FirstOrDefault(x => x.RoverId == d);
// if (rover != null)
// {
// MAP.markers.AddMarker(
// d,
// false,
// lat: rover.DadosLeitura?.Gnss?.Latitude ?? 0,
// lon: rover.DadosLeitura?.Gnss?.Longitude ?? 0,
// heading: rover.DadosLeitura?.Gnss?.OrientacaoReal ?? 0
// );
// }
// }
// }
// }
// int idx_selected = dispositivos.IndexOf(VariaveisControleOperacao.SelectedRoverId);
// if (idx_selected > -1)
// cmbDispositivo.SelectedIndex = idx_selected;
// }
// catch (Exception exUi)
// {
// Variaveis.MostrarLog($"Erro ao atualizar UI lista dispositivos: {exUi.Message}");
// }
//}));
}
private void cmbDispositivo_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
@ -160,7 +160,7 @@ namespace OperationControl.Windows
}
VariaveisControleOperacao.SelectedRoverId = selecionado.ToString();
MAP.markers.SetMarkerFocused(selecionado.ToString(), true);
//MAP.markers.SetMarkerFocused(selecionado.ToString(), true);
AtualizarDadosTela_Telemetria(selecionado.ToString());
brdResumo.IsEnabled = !dados_base;
@ -207,13 +207,13 @@ namespace OperationControl.Windows
var dados_leitura = rover?.DadosLeitura;
if (dados_base)
{
MAP.markers.UpdateMarkerPosition(rover_id, lat: Variaveis.GpsService?.UltimaLeitura?.Latitude, lon: Variaveis.GpsService?.UltimaLeitura?.Longitude, heading: Variaveis.GpsService?.UltimaLeitura?.OrientacaoReal);
//MAP.markers.UpdateMarkerPosition(rover_id, lat: Variaveis.GpsService?.UltimaLeitura?.Latitude, lon: Variaveis.GpsService?.UltimaLeitura?.Longitude, heading: Variaveis.GpsService?.UltimaLeitura?.OrientacaoReal);
}
else if (dados_leitura != null)
{
var simulacao = dados_leitura.Controle?.SimulacaoMPC?.Select(x => new double[] { x.longitude, x.latitude })?.ToList();
MAP.markers.UpdateMarkerPosition(rover_id, lat: dados_leitura.Gnss?.Latitude, lon: dados_leitura.Gnss?.Longitude, heading: dados_leitura.Gnss?.OrientacaoReal, predict: simulacao);
MAP.markers.UpdateMarkerInfo(rover_id, status: dados_leitura.Operacao?.Status ?? AgroBase.Models.Enums.StatusOperacao.NaoIniciado);
//var simulacao = dados_leitura.Controle?.SimulacaoMPC?.Select(x => new double[] { x.longitude, x.latitude })?.ToList();
//MAP.markers.UpdateMarkerPosition(rover_id, lat: dados_leitura.Gnss?.Latitude, lon: dados_leitura.Gnss?.Longitude, heading: dados_leitura.Gnss?.OrientacaoReal, predict: simulacao);
//MAP.markers.UpdateMarkerInfo(rover_id, status: dados_leitura.Operacao?.Status ?? AgroBase.Models.Enums.StatusOperacao.NaoIniciado);
}
if (rover != null && dados_leitura?.ModulosSaude != null)
@ -1797,54 +1797,54 @@ namespace OperationControl.Windows
private void btnParametros_Salvar_Click(object sender, RoutedEventArgs e)
{
var modo = (CTRL.TgManual.IsChecked ?? false) ? AgroBase.Models.Enums.ModoOperacao.Manual : AgroBase.Models.Enums.ModoOperacao.MapaGPS;
var rover = VariaveisControleOperacao.RoverEmFoco;
VariaveisControleOperacao.EnviarParametrosOperacao(new AgroBase.Models.Operacoes.OperacaoParametrosModel()
{
Modo = modo,
Descricao = "Operacao da base",
QtdCamerasSolo = 1,
QtdBicos = 4,
CapacidadeReservatorio = 45,
Controle = new AgroBase.Models.Operacoes.OperacaoParametrosControleModel()
{
MovimentoAutomatico = modo != AgroBase.Models.Enums.ModoOperacao.Manual,
DirecionalAutomatico = modo != AgroBase.Models.Enums.ModoOperacao.Manual,
FrenagemAutomaticaAoParar = chbParametros_Frenagem.IsChecked ?? false,
MovVelocidadeCErvasPercent = int.Parse(txtParametros_VelCErvas.Text),
MovVelocidadeSErvasPercent = int.Parse(txtParametros_VelSErvas.Text),
ImuParadaPorInclinacao = false,
MpcHorizonte = 4,
DirAuxilioSonar = false,
MovAuxilioSonar = false,
AnteciparManobraCorredorM = 10,
OakParadaPorObstaculo = chbParametros_ParadaObstaculo.IsChecked ?? false,
RegistrarDadosPosProcessamento = false,
DirAnguloMaximo = int.Parse(txtParametros_AngMax.Text),
DirTipoMovimento = AgroBase.Models.Enums.TiposControladorDirecional.MPC,
DirVelocidadeMovimento = 50,
PulverizadorAutomatico = chbParametros_Pulverizador.IsChecked ?? false,
AtuPercentualErvasBicoOn = double.Parse(txtParametros_PctErvaOn.Text),
AtuPercentualErvasBicoOff = double.Parse(txtParametros_PctErvaOff.Text),
AtuDuracaoAtuacao = int.Parse(txtParametros_TempoOn.Text),
AtuAlturaAreaPulverizacao = double.Parse(txtParametros_AreaAtuacao.Text),
AtuPercentualInicioPulverizacao = double.Parse(txtParametros_RoiAtuacao.Text),
AtuPressaoLinha = double.Parse(txtParametros_PressaoLiha.Text),
},
ModulosMandatorios = rover?.ModulosMandatorios,
ParametrosMandatorios = rover?.ParametrosMandatorios,
RuasPercorrer = MAP.RuasMapaCarregado.Where(x => x.Selected).Select(x => x.Id).ToList(),
Mapa = MAP.CriarDadosMapa()
});
//var modo = (CTRL.TgManual.IsChecked ?? false) ? AgroBase.Models.Enums.ModoOperacao.Manual : AgroBase.Models.Enums.ModoOperacao.MapaGPS;
//
//var rover = VariaveisControleOperacao.RoverEmFoco;
//
//VariaveisControleOperacao.EnviarParametrosOperacao(new AgroBase.Models.Operacoes.OperacaoParametrosModel()
//{
// Modo = modo,
// Descricao = "Operacao da base",
// QtdCamerasSolo = 1,
// QtdBicos = 4,
// CapacidadeReservatorio = 45,
//
// Controle = new AgroBase.Models.Operacoes.OperacaoParametrosControleModel()
// {
// MovimentoAutomatico = modo != AgroBase.Models.Enums.ModoOperacao.Manual,
// DirecionalAutomatico = modo != AgroBase.Models.Enums.ModoOperacao.Manual,
//
// FrenagemAutomaticaAoParar = chbParametros_Frenagem.IsChecked ?? false,
// MovVelocidadeCErvasPercent = int.Parse(txtParametros_VelCErvas.Text),
// MovVelocidadeSErvasPercent = int.Parse(txtParametros_VelSErvas.Text),
//
// ImuParadaPorInclinacao = false,
// MpcHorizonte = 4,
// DirAuxilioSonar = false,
// MovAuxilioSonar = false,
// AnteciparManobraCorredorM = 10,
// OakParadaPorObstaculo = chbParametros_ParadaObstaculo.IsChecked ?? false,
// RegistrarDadosPosProcessamento = false,
//
// DirAnguloMaximo = int.Parse(txtParametros_AngMax.Text),
// DirTipoMovimento = AgroBase.Models.Enums.TiposControladorDirecional.MPC,
// DirVelocidadeMovimento = 50,
//
// PulverizadorAutomatico = chbParametros_Pulverizador.IsChecked ?? false,
// AtuPercentualErvasBicoOn = double.Parse(txtParametros_PctErvaOn.Text),
// AtuPercentualErvasBicoOff = double.Parse(txtParametros_PctErvaOff.Text),
// AtuDuracaoAtuacao = int.Parse(txtParametros_TempoOn.Text),
// AtuAlturaAreaPulverizacao = double.Parse(txtParametros_AreaAtuacao.Text),
// AtuPercentualInicioPulverizacao = double.Parse(txtParametros_RoiAtuacao.Text),
// AtuPressaoLinha = double.Parse(txtParametros_PressaoLiha.Text),
// },
//
// ModulosMandatorios = rover?.ModulosMandatorios,
// ParametrosMandatorios = rover?.ParametrosMandatorios,
//
// RuasPercorrer = MAP.RuasMapaCarregado.Where(x => x.Selected).Select(x => x.Id).ToList(),
// Mapa = MAP.CriarDadosMapa()
//});
}
private void btnParametros_Atualizar_Click(object sender, RoutedEventArgs e)
@ -1856,26 +1856,6 @@ namespace OperationControl.Windows
#region MAPA
private void MAP_MarkerClicked(object? sender, MapMarkerManager.MarkerClickedEventArgs e)
{
if (string.IsNullOrEmpty(e.MarkerId))
{
Variaveis.MostrarLog($"Sem rover em foco");
LimparDadosTela_Telemetria(e.MarkerId);
}
else
{
Variaveis.MostrarLog($"Rover em foco: {e.MarkerId}");
int idx = cmbDispositivo.Items.IndexOf(e.MarkerId);
cmbDispositivo.SelectedIndex = idx;
}
}
private void MAP_StreetMapClicked(object? sender, MapViewControl.StreetMapClickedEventArgs e)
{
Variaveis.MostrarLog($"Rua clicada: {e.StreetId}. Ruas selecionadas: {string.Join(",", e.SelectedStreetsIds)}");
}
private void AtualizarDadosTela_Mapa(AgroBase.Models.Operacoes.OperacaoParametrosDadosTrajetoriaModel Trajetoria)
{
lblMapaInfo_Debug.Content = $"" +

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,8 @@ arquivo que só deve existir ao final da linha de calibração.
Responsabilidades:
- selecionar e descobrir o dispositivo DepthAI;
- validar CAM_A RGB + CAM_B RE + CAM_C NIR;
- resolver/confirmar Bayer da RGB usando preview RAW10;
- resolver/confirmar Bayer da RGB usando o RawProcessorPreview oficial;
- confirmar a orientação física/canônica das três câmeras;
- registrar o domínio de decode RGB escolhido;
- criar a pasta oficial do perfil (ex.: calibration/mp_ar0234);
- declarar caminhos oficiais para todas as calibrações seguintes;
@ -32,6 +33,11 @@ Exemplos:
Teclas no preview:
B alterna Bayer RGB
1/2/3 seleciona RGB/RE/NIR
R gira a câmera selecionada 90 graus no sentido horário
H alterna flip horizontal da câmera selecionada
V alterna flip vertical da câmera selecionada
D restaura orientação padrão da câmera selecionada
S salva snapshot
ENTER confirma e promove o profile
Q/ESC cancela sem alterar o profile ativo
@ -55,6 +61,8 @@ import cv2
import depthai as dai
import numpy as np
from core.raw_processor_preview import RawProcessorPreview
SCHEMA = "multispec_module_profile_v1"
SELECTOR_SCHEMA = "multispec_active_module_profile_v1"
@ -256,6 +264,8 @@ class CameraSpec:
resolution_name: str
is_color: bool
preview_rotate_deg: int
preview_flip_horizontal: bool
preview_flip_vertical: bool
stream_name: str
@ -302,6 +312,8 @@ def validate_and_build_specs(rows: list[dict]) -> Dict[str, CameraSpec]:
resolution_name=str(cfg["resolution_enum"]),
is_color=(role == "rgb"),
preview_rotate_deg=int(cfg["preview_rotate_deg"]),
preview_flip_horizontal=False,
preview_flip_vertical=False,
stream_name=f"profile_{role}",
)
@ -364,77 +376,12 @@ def build_preview_pipeline(specs: Dict[str, CameraSpec], fps: float):
return pipeline
def unpack_raw10(data, width: int, height: int, stride: Optional[int] = None):
width = int(width)
height = int(height)
if width <= 0 or height <= 0 or width % 4:
raise ValueError(f"Dimensão RAW10 inválida: {width}x{height}")
raw = np.asarray(data, dtype=np.uint8).reshape(-1)
payload_bytes = (width // 4) * 5
candidates = []
if stride is not None:
try:
candidates.append(int(stride))
except Exception:
pass
if height > 0 and raw.size % height == 0:
candidates.append(raw.size // height)
candidates.append(payload_bytes)
row_stride = next(
(x for x in candidates if x >= payload_bytes and x * height <= raw.size),
None,
)
if row_stride is None:
raise ValueError(
f"RAW10 curto: bytes={raw.size}, payload mínimo={payload_bytes*height}"
)
rows = raw[: row_stride * height].reshape(height, row_stride)
groups = rows[:, :payload_bytes].reshape(height, width // 4, 5)
out = np.empty((height, width // 4, 4), dtype=np.uint16)
out[:, :, 0] = (
groups[:, :, 0].astype(np.uint16) << 2
) | (groups[:, :, 4] & 0x03)
out[:, :, 1] = (
groups[:, :, 1].astype(np.uint16) << 2
) | ((groups[:, :, 4] >> 2) & 0x03)
out[:, :, 2] = (
groups[:, :, 2].astype(np.uint16) << 2
) | ((groups[:, :, 4] >> 4) & 0x03)
out[:, :, 3] = (
groups[:, :, 3].astype(np.uint16) << 2
) | ((groups[:, :, 4] >> 6) & 0x03)
return np.ascontiguousarray(out.reshape(height, width))
def bayer_code(pattern: str, algorithm: str):
pattern = str(pattern).upper()
algorithm = str(algorithm).lower()
if algorithm == "ea":
table = {
"BGGR": cv2.COLOR_BayerRG2RGB_EA,
"RGGB": cv2.COLOR_BayerBG2RGB_EA,
"GRBG": cv2.COLOR_BayerGR2RGB_EA,
"GBRG": cv2.COLOR_BayerGB2RGB_EA,
}
else:
table = {
"BGGR": cv2.COLOR_BayerRG2RGB,
"RGGB": cv2.COLOR_BayerBG2RGB,
"GRBG": cv2.COLOR_BayerGR2RGB,
"GBRG": cv2.COLOR_BayerGB2RGB,
}
return table[pattern]
def rgb_raw_preview(pkt, spec: CameraSpec, pattern: str, algorithm: str):
def rgb_raw_preview(
pkt,
spec: CameraSpec,
processor: RawProcessorPreview,
):
"""Preview RGB oficial; não mantém um segundo mini-ISP neste script."""
raw_type = str(pkt.getType()).upper()
if "PACK10" not in raw_type and "RAW10" not in raw_type:
raise RuntimeError(
@ -454,31 +401,50 @@ def rgb_raw_preview(pkt, spec: CameraSpec, pattern: str, algorithm: str):
except Exception:
stride = None
raw16 = unpack_raw10(pkt.getData(), width, height, stride)
rgb16 = cv2.cvtColor(raw16, bayer_code(pattern, algorithm))
rgb = np.clip(rgb16.astype(np.float32) / 1023.0, 0.0, 1.0)
packed_data = np.asarray(pkt.getData(), dtype=np.uint8).reshape(-1)
payload_per_row = (width // 4) * 5
if (
stride is None
or stride < payload_per_row
or stride * height > packed_data.size
):
# Algumas versões do DepthAI reportam getStride() no domínio dos
# pixels, não dos bytes PACK10. Nesse caso o tamanho real vence.
stride = None
# Stretch comum aos canais: melhora a inspeção sem alterar o balanço RGB.
sample = rgb[::8, ::8]
lo = float(np.percentile(sample, 0.5))
hi = float(np.percentile(sample, 99.5))
if hi <= lo + 1e-6:
hi = lo + 1e-6
rgb = np.clip((rgb - lo) / (hi - lo), 0.0, 1.0)
# OpenCV exibe BGR.
return np.ascontiguousarray((rgb[:, :, ::-1] * 255.0).astype(np.uint8))
raw16 = processor.unpack_raw10_packed(
packed_data,
stride=stride,
)
return processor.raw16_to_preview_bgr(raw16)
def apply_preview_orientation(img: np.ndarray, rotate_deg: int):
rotate_deg = int(rotate_deg) % 360
def normalize_orientation(cfg: dict) -> dict:
rotate_deg = int((cfg or {}).get("rotate_deg", 0)) % 360
if rotate_deg not in (0, 90, 180, 270):
raise ValueError(f"Rotação inválida: {rotate_deg}")
return {
"rotate_deg": rotate_deg,
"flip_horizontal": bool((cfg or {}).get("flip_horizontal", False)),
"flip_vertical": bool((cfg or {}).get("flip_vertical", False)),
"source": str((cfg or {}).get("source") or "unknown"),
}
def apply_preview_orientation(img: np.ndarray, cfg: dict):
cfg = normalize_orientation(cfg)
rotate_deg = cfg["rotate_deg"]
if rotate_deg == 90:
return cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
if rotate_deg == 180:
return cv2.rotate(img, cv2.ROTATE_180)
if rotate_deg == 270:
return cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)
return img
img = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
elif rotate_deg == 180:
img = cv2.rotate(img, cv2.ROTATE_180)
elif rotate_deg == 270:
img = cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)
if cfg["flip_horizontal"]:
img = cv2.flip(img, 1)
if cfg["flip_vertical"]:
img = cv2.flip(img, 0)
return np.ascontiguousarray(img)
def overlay(img, lines, x=12, y=24, scale=0.48, step=21):
@ -499,6 +465,7 @@ def preview_and_confirm(
dev_info,
specs: Dict[str, CameraSpec],
initial_bayer: str,
initial_orientation: Dict[str, dict],
algorithm: str,
fps: float,
panel_width: int,
@ -506,8 +473,25 @@ def preview_and_confirm(
snapshots_dir: Path,
):
pattern = initial_bayer
orientation = {
role: normalize_orientation(initial_orientation[role])
for role in ROLES
}
orientation_defaults = {
role: dict(orientation[role])
for role in ROLES
}
selected_role = "rgb"
preview_processors = {
candidate: RawProcessorPreview(
sensor_width=specs["rgb"].width,
sensor_height=specs["rgb"].height,
bayer_pattern=candidate,
)
for candidate in BAYER_PATTERNS
}
pipeline = build_preview_pipeline(specs, fps)
window = "Module Profile - Bayer Confirmation"
window = "Module Profile - Physical Sanity Check"
cv2.namedWindow(window, cv2.WINDOW_NORMAL)
try:
@ -538,7 +522,11 @@ def preview_and_confirm(
spec = specs[role]
pkt = latest[role]
if role == "rgb":
view = rgb_raw_preview(pkt, spec, pattern, algorithm)
view = rgb_raw_preview(
pkt,
spec,
preview_processors[pattern],
)
else:
view = pkt.getCvFrame()
if view.ndim == 2:
@ -546,7 +534,7 @@ def preview_and_confirm(
view = apply_preview_orientation(
view,
spec.preview_rotate_deg,
orientation[role],
)
panel = cv2.resize(
view,
@ -554,16 +542,33 @@ def preview_and_confirm(
interpolation=cv2.INTER_AREA,
)
lines = [
f"{role.upper()} | {spec.socket} | {spec.sensor}",
(
f"> {role.upper()} | {spec.socket} | {spec.sensor}"
if role == selected_role
else f" {role.upper()} | {spec.socket} | {spec.sensor}"
),
f"native={spec.width}x{spec.height}",
f"packet={latest_type[role]}",
(
f"ROT={orientation[role]['rotate_deg']} "
f"FH={int(orientation[role]['flip_horizontal'])} "
f"FV={int(orientation[role]['flip_vertical'])}"
),
]
if role == "rgb":
lines += [
f"BAYER={pattern} | DEMOSAIC={algorithm}",
f"BAYER={pattern} | PREVIEW=RawProcessorPreview",
"Confira vermelho/azul em objeto conhecido",
]
overlay(panel, lines)
if role == selected_role:
cv2.rectangle(
panel,
(2, 2),
(panel_width - 3, panel_height - 3),
(0, 255, 255),
4,
)
panels.append(panel)
info = np.zeros(
@ -575,14 +580,19 @@ def preview_and_confirm(
"",
f"RGB Bayer selecionado: {pattern}",
f"RGB processing: linear_demosaic / {algorithm}",
f"Camera selecionada: {selected_role.upper()}",
"",
"B = alternar Bayer",
"1/2/3 = selecionar RGB/RE/NIR",
"R = girar 90 graus horario",
"H/V = flip horizontal/vertical",
"D = restaurar orientacao padrao",
"S = snapshot",
"ENTER = confirmar e promover",
"Q/ESC = cancelar",
"",
"Use um objeto vermelho e outro azul",
"para confirmar que R/B não estão trocados.",
"Confirme cores e o mesmo sentido fisico",
"nas tres cameras antes de pressionar ENTER.",
])
last_board = np.vstack([
@ -597,21 +607,48 @@ def preview_and_confirm(
key = cv2.waitKey(1) & 0xFF
if key in (ord("q"), ord("Q"), 27):
raise KeyboardInterrupt("Cancelado durante confirmação do Bayer.")
raise KeyboardInterrupt("Cancelado durante sanity check físico.")
if key == ord("1"):
selected_role = "rgb"
elif key == ord("2"):
selected_role = "re"
elif key == ord("3"):
selected_role = "nir"
if key in (ord("b"), ord("B")):
idx = (BAYER_PATTERNS.index(pattern) + 1) % len(BAYER_PATTERNS)
pattern = BAYER_PATTERNS[idx]
if key in (ord("r"), ord("R")):
orientation[selected_role]["rotate_deg"] = (
int(orientation[selected_role]["rotate_deg"]) + 90
) % 360
orientation[selected_role]["source"] = "raw_preview_confirmed"
if key in (ord("h"), ord("H")):
orientation[selected_role]["flip_horizontal"] = not bool(
orientation[selected_role]["flip_horizontal"]
)
orientation[selected_role]["source"] = "raw_preview_confirmed"
if key in (ord("v"), ord("V")):
orientation[selected_role]["flip_vertical"] = not bool(
orientation[selected_role]["flip_vertical"]
)
orientation[selected_role]["source"] = "raw_preview_confirmed"
if key in (ord("d"), ord("D")):
orientation[selected_role] = dict(
orientation_defaults[selected_role]
)
if key in (ord("s"), ord("S")) and last_board is not None:
ensure_dir(snapshots_dir)
cv2.imwrite(
str(snapshots_dir / f"bayer_{pattern}_{stamp()}.png"),
str(snapshots_dir / f"physical_check_{pattern}_{stamp()}.png"),
last_board,
)
if key in (10, 13) and last_board is not None:
ensure_dir(snapshots_dir)
confirmed_path = snapshots_dir / "bayer_confirmed.png"
for role in ROLES:
orientation[role]["source"] = "raw_preview_confirmed"
confirmed_path = snapshots_dir / "physical_sanity_confirmed.png"
cv2.imwrite(str(confirmed_path), last_board)
return pattern, confirmed_path
return pattern, orientation, confirmed_path
time.sleep(0.002)
finally:
@ -625,9 +662,9 @@ def artifact_paths(profile_dir: Path) -> dict:
"intrinsics_json": norm_path(profile_dir / "intrinsics_calibration_v1.json"),
"flatfield_npz": norm_path(profile_dir / "flatfield_maps_v1.npz"),
"flatfield_json": norm_path(profile_dir / "flatfield_maps_v1.json"),
"radiometry_json": norm_path(profile_dir / "radiometry_calibration_v1.json"),
"camera_startup_json": norm_path(profile_dir / "camera_startup_profile_v1.json"),
"homography_json": norm_path(profile_dir / "homography_calibration_v1.json"),
"radiometry_json": norm_path(profile_dir / "radiometry_calibration_v5.json"),
"camera_startup_json": norm_path(profile_dir / "camera_startup_profile_v3.json"),
"homography_json": norm_path(profile_dir / "homography_calibration_v4.json"),
"module_params_json": norm_path(profile_dir / "module_params.json"),
"module_params_assembly_report_json": norm_path(
profile_dir / "module_params_assembly_report.json"
@ -652,19 +689,27 @@ def build_profile(
profile_name: str,
profile_dir: Path,
bayer: str,
preview_orientation: Dict[str, dict],
confirmed_with_raw_preview: bool,
confirmation_snapshot: Optional[Path],
):
orientation = {
role: {
"rotate_deg": int(specs[role].preview_rotate_deg),
"flip_horizontal": False,
"flip_vertical": False,
"source": "sensor_default",
}
role: normalize_orientation(preview_orientation[role])
for role in ROLES
}
resolved_setup = {}
for role in ROLES:
item = asdict(specs[role])
item["preview_rotate_deg"] = int(orientation[role]["rotate_deg"])
item["preview_flip_horizontal"] = bool(
orientation[role]["flip_horizontal"]
)
item["preview_flip_vertical"] = bool(
orientation[role]["flip_vertical"]
)
resolved_setup[role] = item
hardware_signature = {
role: {
"socket": specs[role].socket,
@ -686,10 +731,7 @@ def build_profile(
"depthai_version": getattr(dai, "__version__", "unknown"),
"usb_speed": usb_speed,
"hardware_signature": hardware_signature,
"resolved_setup": {
role: asdict(specs[role])
for role in ROLES
},
"resolved_setup": resolved_setup,
"camera_inventory": rows,
"rgb_decode": {
"bayer_pattern": bayer,
@ -708,9 +750,23 @@ def build_profile(
),
},
"preview_orientation_defaults": orientation,
"mounting_sanity_check": {
"confirmed_with_live_preview": bool(confirmed_with_raw_preview),
"preview_engine": "core.raw_processor_preview.RawProcessorPreview",
"orientation_by_role": orientation,
"confirmation_snapshot": (
norm_path(confirmation_snapshot)
if confirmation_snapshot is not None
else None
),
},
"geometry_bootstrap": {
"native_space": "native_stream_no_external_undistort",
"orientation_status": "preview_default_until_homography",
"orientation_status": (
"raw_preview_confirmed_until_homography"
if confirmed_with_raw_preview
else "default_or_cli_headless_until_homography"
),
"note": (
"A Homography homologada será a autoridade final da orientação "
"científica e do espaço canônico."
@ -730,6 +786,15 @@ def build_profile(
"mode": args.rgb_processing_mode,
"demosaic_algorithm": args.demosaic_algorithm,
},
"camera_orientation_bootstrap": {
"enabled": True,
"input_space": "native_stream_no_external_undistort",
"output_space": (
"canonical_oriented_stream_no_external_undistort"
),
"by_role": orientation,
"authority": "module_profile_until_homography",
},
},
"artifact_paths": artifact_paths(profile_dir),
"traceability": {
@ -740,6 +805,13 @@ def build_profile(
},
"notes": args.notes or "",
},
"capture_synchronization": {
"hardware_sync_enabled": bool(args.hardware_sync_enabled),
"frame_sync_master": args.frame_sync_master,
"software_sync_mode": "best",
"sync_tolerance_ms": 12.0,
"buffer_size": 8,
},
}
@ -789,6 +861,29 @@ def backup_if_exists(path: Path):
return None
def resolve_initial_orientation(
specs: Dict[str, CameraSpec],
args,
) -> Dict[str, dict]:
result = {}
for role in ROLES:
rotate_override = getattr(args, f"{role}_preview_rotate_deg")
flip_h = bool(getattr(args, f"{role}_preview_flip_horizontal"))
flip_v = bool(getattr(args, f"{role}_preview_flip_vertical"))
has_cli_override = rotate_override is not None or flip_h or flip_v
result[role] = normalize_orientation({
"rotate_deg": (
specs[role].preview_rotate_deg
if rotate_override is None
else rotate_override
),
"flip_horizontal": flip_h,
"flip_vertical": flip_v,
"source": "cli" if has_cli_override else "sensor_default",
})
return result
def main():
parser = argparse.ArgumentParser(
description="Bootstrap do profile físico/decode do módulo multiespectral.",
@ -826,6 +921,25 @@ def main():
parser.add_argument("--fps", type=float, default=10.0)
parser.add_argument("--panel-width", type=int, default=640)
parser.add_argument("--panel-height", type=int, default=400)
for role in ROLES:
parser.add_argument(
f"--{role}-preview-rotate-deg",
type=int,
default=None,
choices=[0, 90, 180, 270],
help=(
f"Orientação inicial de {role.upper()}; pode ser ajustada "
"interativamente no preview."
),
)
parser.add_argument(
f"--{role}-preview-flip-horizontal",
action="store_true",
)
parser.add_argument(
f"--{role}-preview-flip-vertical",
action="store_true",
)
parser.add_argument(
"--no-preview",
action="store_true",
@ -841,6 +955,16 @@ def main():
parser.add_argument("--lens-re", default="")
parser.add_argument("--lens-nir", default="")
parser.add_argument("--notes", default="")
parser.add_argument(
"--hardware-sync-enabled",
action=argparse.BooleanOptionalAction,
default=False,
)
parser.add_argument(
"--frame-sync-master",
default="CAM_A",
choices=["CAM_A", "CAM_B", "CAM_C"],
)
args = parser.parse_args()
if args.no_preview and not args.yes:
@ -849,6 +973,7 @@ def main():
dev_info = select_device_info(args.mx_id)
rows, actual_mx, usb_speed = discover(dev_info)
specs = validate_and_build_specs(rows)
selected_orientation = resolve_initial_orientation(specs, args)
rgb_sensor = specs["rgb"].sensor
default_profile_name = f"mp_{rgb_sensor.lower()}"
@ -898,6 +1023,13 @@ def main():
f"RGB decode : Bayer={selected_bayer} ({bayer_initial_source}) | "
f"mode={args.rgb_processing_mode} | algo={args.demosaic_algorithm}"
)
for role in ROLES:
orient = selected_orientation[role]
print(
f"{role.upper():3s} orient. : rotate={orient['rotate_deg']:3d} | "
f"flip_h={orient['flip_horizontal']} | "
f"flip_v={orient['flip_vertical']} | source={orient['source']}"
)
print("=" * 88)
confirmed_with_raw_preview = False
@ -905,10 +1037,15 @@ def main():
try:
if not args.no_preview:
selected_bayer, confirmation_snapshot = preview_and_confirm(
(
selected_bayer,
selected_orientation,
confirmation_snapshot,
) = preview_and_confirm(
dev_info=dev_info,
specs=specs,
initial_bayer=selected_bayer,
initial_orientation=selected_orientation,
algorithm=args.demosaic_algorithm,
fps=args.fps,
panel_width=args.panel_width,
@ -926,6 +1063,7 @@ def main():
profile_name=profile_name,
profile_dir=profile_dir,
bayer=selected_bayer,
preview_orientation=selected_orientation,
confirmed_with_raw_preview=confirmed_with_raw_preview,
confirmation_snapshot=confirmation_snapshot,
)
@ -965,6 +1103,14 @@ def main():
print(f"[SELECTOR] {active_selector}")
print(f"[SHA256] {selector['profile_sha256']}")
print(f"[BAYER] {selected_bayer}")
for role in ROLES:
orient = selected_orientation[role]
print(
f"[ORIENTATION] {role.upper():3s} | "
f"rotate={orient['rotate_deg']} | "
f"flip_h={orient['flip_horizontal']} | "
f"flip_v={orient['flip_vertical']}"
)
if backup_profile:
print(f"[BACKUP PROFILE] {backup_profile}")
if backup_selector:

View File

@ -108,6 +108,7 @@ from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import shutil
import zipfile
@ -536,6 +537,78 @@ def resolve_output_path(value: str | Path) -> Path:
return Path(value).expanduser()
def normalize_capture_synchronization(
value: dict,
hardware_signature: dict,
) -> dict:
"""Valida e normaliza o contrato de sincronismo vindo do Module Profile."""
if not isinstance(value, dict):
raise RuntimeError(
"Module Profile sem capture_synchronization. "
"Execute novamente o Script 0 revisado."
)
enabled = value.get("hardware_sync_enabled")
if not isinstance(enabled, bool):
raise RuntimeError(
"capture_synchronization.hardware_sync_enabled deve ser bool."
)
master = str(value.get("frame_sync_master") or "").upper().strip()
valid_sockets = {
str(item["socket"]).upper()
for item in normalize_signature(hardware_signature).values()
}
if master not in valid_sockets:
raise RuntimeError(
"capture_synchronization.frame_sync_master inválido: "
f"{master!r}. Disponíveis={sorted(valid_sockets)}"
)
software_mode = str(
value.get("software_sync_mode") or ""
).lower().strip()
if not software_mode:
raise RuntimeError(
"capture_synchronization.software_sync_mode ausente."
)
try:
tolerance_ms = float(value.get("sync_tolerance_ms"))
except Exception as exc:
raise RuntimeError(
"capture_synchronization.sync_tolerance_ms inválido."
) from exc
if not math.isfinite(tolerance_ms) or tolerance_ms <= 0.0:
raise RuntimeError(
"capture_synchronization.sync_tolerance_ms deve ser finito e > 0."
)
buffer_size = value.get("buffer_size")
if isinstance(buffer_size, bool):
raise RuntimeError(
"capture_synchronization.buffer_size deve ser inteiro positivo."
)
try:
buffer_size = int(buffer_size)
except Exception as exc:
raise RuntimeError(
"capture_synchronization.buffer_size inválido."
) from exc
if buffer_size <= 0:
raise RuntimeError(
"capture_synchronization.buffer_size deve ser > 0."
)
return {
"hardware_sync_enabled": enabled,
"frame_sync_master": master,
"software_sync_mode": software_mode,
"sync_tolerance_ms": tolerance_ms,
"buffer_size": buffer_size,
}
def validate_module_profile(profile: dict, profile_path: Path):
if profile.get("schema") != MODULE_PROFILE_SCHEMA:
raise RuntimeError(
@ -602,6 +675,11 @@ def validate_module_profile(profile: dict, profile_path: Path):
if not artifacts.get(key):
raise RuntimeError(f"Module Profile sem artifact_paths.{key}.")
normalize_capture_synchronization(
profile.get("capture_synchronization"),
signature,
)
def load_module_profile_contract(
module_profile_arg: Optional[str],
@ -2602,6 +2680,10 @@ def runtime_policy_from_module_profile(profile: dict) -> dict:
sensor nem herança de um module_params anterior.
"""
decode = profile["rgb_decode"]
capture_sync = normalize_capture_synchronization(
profile.get("capture_synchronization"),
profile.get("hardware_signature"),
)
declared = profile.get("runtime_policy")
if declared is None:
declared = {}
@ -2614,6 +2696,7 @@ def runtime_policy_from_module_profile(profile: dict) -> dict:
"capture_mode_requested": "AUTO",
"capture_mode_effective": "AUTO",
"raw_policy": "allow_single",
"capture_synchronization": capture_sync,
"rgb_processing": {
"mode": str(decode["mode"]).lower(),
"demosaic_algorithm": str(decode["demosaic_algorithm"]).lower(),
@ -2781,6 +2864,9 @@ def build_final_module_params(
"raw_policy": runtime_policy[
"raw_policy"
],
"capture_synchronization": deepcopy(
runtime_policy["capture_synchronization"]
),
# Compatibilidade root: RGB reference camera
"sensor_width": int(
@ -2958,6 +3044,7 @@ def validate_final_module_params(
"bayer_pattern",
"sensor_size_by_role",
"camera_hardware",
"capture_synchronization",
"camera_orientation",
"rgb_processing",
"camera_settings",
@ -3013,6 +3100,15 @@ def validate_final_module_params(
if str(mp.get("bayer_pattern") or "").upper() not in BAYER_PATTERNS:
raise RuntimeError("bayer_pattern final inválido.")
normalized_sync = normalize_capture_synchronization(
mp.get("capture_synchronization"),
mp.get("camera_hardware"),
)
if mp.get("capture_synchronization") != normalized_sync:
raise RuntimeError(
"capture_synchronization final não está normalizado."
)
rgb_processing = mp.get("rgb_processing")
if not isinstance(rgb_processing, dict):
raise RuntimeError("rgb_processing final inválido.")
@ -3698,6 +3794,9 @@ def main():
provenance = {
"module_profile_contract": module_profile_provenance(profile_contract),
"configuration_source": "module_profile_only",
"capture_synchronization": deepcopy(
runtime_policy["capture_synchronization"]
),
"hardware_signature": deepcopy(
common_signature
),
@ -3832,6 +3931,9 @@ def main():
"camera_orientation": deepcopy(
camera_orientation
),
"capture_synchronization": deepcopy(
runtime_policy["capture_synchronization"]
),
"runtime_policy": runtime_policy,
"calibration_provenance": provenance,
"final_checks": {
@ -3847,6 +3949,7 @@ def main():
"hardware_consistency": "pass",
"geometry_consistency": "pass",
"camera_orientation": "pass",
"capture_synchronization": "pass",
"orientation_bootstrap_consistency": "pass",
"startup_radiometry_consistency": "pass",
"flatfield_npz_integrity": "pass",
@ -3874,6 +3977,15 @@ def main():
f"{common_signature['nir']['size'][0]}x{common_signature['nir']['size'][1]}"
)
print(f"Bayer : {bayer}")
capture_sync = runtime_policy["capture_synchronization"]
print(
"Capture sync: "
f"hardware={'ON' if capture_sync['hardware_sync_enabled'] else 'OFF'} | "
f"master={capture_sync['frame_sync_master']} | "
f"software={capture_sync['software_sync_mode']} | "
f"tolerance={capture_sync['sync_tolerance_ms']:.1f}ms | "
f"buffer={capture_sync['buffer_size']}"
)
print(
f"RGB process : "
f"{runtime_policy['rgb_processing'].get('mode')}"

View File

@ -41,12 +41,17 @@ class OakFcc3Client:
capture_mode="AUTO",
raw_policy="allow_single",
module_calibration_json=None,
sync_mode="best",
sync_tolerance_ms=25.0,
mx_id=None,
imu_modo="rotation_vector",
imu_freq_hz=200,
evaluate_quality=True,
hardware_sync_enabled=None,
frame_sync_master=None,
sync_mode=None,
sync_tolerance_ms=None,
buffer_size=None,
**kwargs,
):
self.width = width
@ -85,14 +90,18 @@ class OakFcc3Client:
output_dtype=output_dtype,
capture_mode=capture_mode,
raw_policy=raw_policy,
sync_mode=sync_mode,
sync_tolerance_ms=sync_tolerance_ms,
mx_id=self.mx_id,
module_calibration_json=module_calibration_json,
imu_modo=self.imu_modo,
imu_freq_hz=self.imu_freq_hz,
hardware_sync_enabled=hardware_sync_enabled,
frame_sync_master=frame_sync_master,
sync_mode=sync_mode,
sync_tolerance_ms=sync_tolerance_ms,
buffer_size=buffer_size,
**kwargs,
)

View File

@ -44,11 +44,11 @@ class OakFcc3Manager:
capture_mode="AUTO",
raw_policy="allow_single",
roles=None,
sync_mode="best",
hardware_sync_enabled=True,
frame_sync_master="CAM_A",
sync_tolerance_ms=12.0,
buffer_size=8,
sync_mode=None,
hardware_sync_enabled=None,
frame_sync_master=None,
sync_tolerance_ms=None,
buffer_size=None,
only_camera=None,
mx_id=None,
module_calibration_json=None,
@ -56,9 +56,6 @@ class OakFcc3Manager:
imu_modo="rotation_vector",
imu_freq_hz=200,
):
self.hardware_sync_enabled = hardware_sync_enabled
self.frame_sync_master = frame_sync_master
self.fps = fps
# Para compatibilidade, mantemos width/height.
@ -83,10 +80,6 @@ class OakFcc3Manager:
"CAM_C": "nir",
}
self.sync_mode = sync_mode
self.sync_tolerance_ms = sync_tolerance_ms
self.buffer_size = buffer_size
self.mx_id = str(mx_id) if mx_id else None
self.dev_info = None
self.device = None
@ -127,6 +120,18 @@ class OakFcc3Manager:
self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {}
self.aligned_geometry = None
sync_cfg = (self.module_params.get("capture_synchronization", {}) if isinstance(self.module_params, dict) else {})
if hardware_sync_enabled is None: hardware_sync_enabled = sync_cfg.get("hardware_sync_enabled", False)
if frame_sync_master is None: frame_sync_master = sync_cfg.get("frame_sync_master", "CAM_A")
if sync_mode is None: sync_mode = sync_cfg.get("software_sync_mode", "best")
if sync_tolerance_ms is None: sync_tolerance_ms = sync_cfg.get("sync_tolerance_ms", 12.0)
if buffer_size is None: buffer_size = sync_cfg.get("buffer_size", 8)
self.hardware_sync_enabled = bool(hardware_sync_enabled)
self.frame_sync_master = str(frame_sync_master)
self.sync_mode = str(sync_mode)
self.sync_tolerance_ms = float(sync_tolerance_ms)
self.buffer_size = int(buffer_size)
self.async_capture_enabled = True
self.async_capture_mode = "latest" # latest | queue
self.async_capture_max_queue = 2

View File

@ -1,126 +1,689 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
RawProcessorPreview
===================
Mini-ISP exclusivamente visual para gerar previews de anotação a partir do
RAW Bayer da câmera RGB.
Contrato:
- entrada científica permanece imutável;
- saída sempre BGR uint8 para OpenCV;
- preserva exatamente HxW do raster RGB;
- não aplica resize, crop, rotate/flip, undistort, Flat-Field ou Homography;
- não deve ser usado para formar o tensor de treino/inferência.
Pipeline visual padrão:
RAW10/RAW16 -> níveis robustos -> gamma -> demosaico EA/BGR ->
WB por regiões claras/neutras -> CLAHE suave em luminância ->
saturação leve -> contraste -> unsharp suave.
As APIs antigas foram preservadas. Chamadas que antes usavam
raw16_to_preview_bgr() ou raw16_to_preview_jpg_bytes() continuam válidas.
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any, Optional
import cv2
import numpy as np
from typing import Optional
class RawProcessorPreview:
def __init__(self, sensor_width: int, sensor_height: int, bayer_pattern: str = "GBRG"):
self.sensor_width = sensor_width
self.sensor_height = sensor_height
self.bayer_pattern = bayer_pattern.upper()
VERSION = "raw_processor_preview_v2_2026_09_09"
CONTRACT_SCHEMA = "rgb_raw_annotation_preview_v2"
BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG")
def raw16_to_vis8(
self, raw16: np.ndarray,
black_level: Optional[int] = None,
white_level: Optional[int] = None,
gamma: float = 2.2,
bit_depth: int = 10
) -> np.ndarray:
"""
Conversão para visualização:
- auto-level
- gamma
"""
max_val = float((1 << bit_depth) - 1)
DEFAULT_CONFIG = {
"bit_depth": 10,
"levels": {
"mode": "robust_percentile",
"black_percentile": 0.20,
"white_percentile": 99.80,
"sample_max_pixels": 500_000,
"minimum_span_codes": 16.0,
},
"gamma": 2.20,
"demosaic": {
"algorithm": "edge_aware",
"output_color_order": "BGR",
},
"white_balance": {
"enabled": True,
"method": "neutral_bright_with_gray_world_fallback",
"strength": 0.65,
"gain_min": 0.60,
"gain_max": 1.70,
"bright_percentile": 55.0,
"neutral_chroma_percentile": 40.0,
"min_neutral_pixels": 256,
},
"clahe": {
"enabled": True,
"clip_limit": 1.55,
"tile_grid_size": 8,
},
"saturation": {
"enabled": True,
"factor": 1.04,
},
"contrast": {
"enabled": True,
"alpha": 1.04,
"beta": 0.0,
},
"sharpen": {
"enabled": True,
"sigma": 0.85,
"amount": 0.55,
"threshold": 2.0,
},
}
raw = raw16.astype(np.float32)
def __init__(
self,
sensor_width: int,
sensor_height: int,
bayer_pattern: str = "GBRG",
config: Optional[dict] = None,
):
self.sensor_width = int(sensor_width)
self.sensor_height = int(sensor_height)
self.bayer_pattern = str(bayer_pattern).upper()
self.config = self._deep_merge(self.DEFAULT_CONFIG, config or {})
self.last_preview_report: dict[str, Any] = {}
self._validate_contract()
if black_level is None:
black_level = float(raw.min())
if white_level is None:
white_level = float(raw.max())
@staticmethod
def _deep_merge(base: dict, override: dict) -> dict:
result = deepcopy(base)
if not isinstance(override, dict):
raise TypeError("config do RawProcessorPreview deve ser dict.")
for key, value in override.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = RawProcessorPreview._deep_merge(result[key], value)
else:
result[key] = deepcopy(value)
return result
if white_level <= black_level:
norm = raw / max_val
else:
norm = (raw - black_level) / (white_level - black_level)
norm = np.clip(norm, 0.0, 1.0)
if gamma is not None and gamma > 0:
norm = np.power(norm, 1.0 / gamma)
return (norm * 255.0).clip(0, 255).astype(np.uint8)
def _debayer_code(self):
mapping = {
"RGGB": cv2.COLOR_BayerRG2RGB_EA,
"BGGR": cv2.COLOR_BayerBG2RGB_EA,
"GRBG": cv2.COLOR_BayerGR2RGB_EA,
"GBRG": cv2.COLOR_BayerGB2RGB_EA,
}
if self.bayer_pattern not in mapping:
def _validate_contract(self):
if self.sensor_width <= 0 or self.sensor_height <= 0:
raise ValueError(
f"Dimensão de sensor inválida: {self.sensor_width}x{self.sensor_height}"
)
if self.sensor_width % 4 != 0:
raise ValueError(
f"RAW10 packed exige largura múltipla de 4: {self.sensor_width}"
)
if self.bayer_pattern not in self.BAYER_PATTERNS:
raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}")
return mapping[self.bayer_pattern]
bit_depth = int(self.config.get("bit_depth", 10))
if bit_depth < 8 or bit_depth > 16:
raise ValueError(f"bit_depth inválido: {bit_depth}")
def apply_preview_white_balance(self, bgr: np.ndarray, strength: float = 1.0) -> np.ndarray:
levels = self.config["levels"]
low = float(levels["black_percentile"])
high = float(levels["white_percentile"])
if not (0.0 <= low < high <= 100.0):
raise ValueError(f"Percentis de níveis inválidos: {low}, {high}")
gamma = float(self.config["gamma"])
if not np.isfinite(gamma) or gamma <= 0.0:
raise ValueError(f"Gamma inválido: {gamma}")
wb = self.config["white_balance"]
if float(wb["gain_min"]) <= 0.0 or float(wb["gain_max"]) < float(wb["gain_min"]):
raise ValueError("Limites de ganho do white balance inválidos.")
def get_preview_contract(self) -> dict:
"""Contrato serializável para registrar no meta do dataset."""
return {
"schema": self.CONTRACT_SCHEMA,
"version": self.VERSION,
"purpose": "human_annotation_only",
"geometry": "rgb_sensor_native_no_geometric_transform",
"sensor_size": [self.sensor_width, self.sensor_height],
"bayer_pattern": self.bayer_pattern,
"output": {
"layout": "HWC",
"color_order": "BGR",
"dtype": "uint8",
"range": [0, 255],
},
"calibrations_applied": [],
"config": deepcopy(self.config),
}
def get_last_preview_report(self) -> dict:
return deepcopy(self.last_preview_report)
# Nome explícito e amigável para integrações que só precisam registrar
# a configuração, sem necessariamente gerar um frame antes.
def describe_config(self) -> dict:
return self.get_preview_contract()
@staticmethod
def _finite_float(value, name: str) -> float:
result = float(value)
if not np.isfinite(result):
raise ValueError(f"{name} deve ser finito: {value!r}")
return result
@staticmethod
def _sample_for_stats(raw: np.ndarray, max_pixels: int) -> np.ndarray:
total = int(raw.size)
if total <= max_pixels:
return raw.reshape(-1)
stride = max(1, int(np.ceil(np.sqrt(total / float(max_pixels)))))
return raw[::stride, ::stride].reshape(-1)
def _resolve_levels(
self,
raw16: np.ndarray,
black_level: Optional[float],
white_level: Optional[float],
bit_depth: int,
black_percentile: Optional[float],
white_percentile: Optional[float],
) -> tuple[float, float, dict]:
max_code = float((1 << int(bit_depth)) - 1)
levels = self.config["levels"]
low_pct = float(
levels["black_percentile"]
if black_percentile is None
else black_percentile
)
high_pct = float(
levels["white_percentile"]
if white_percentile is None
else white_percentile
)
if not (0.0 <= low_pct < high_pct <= 100.0):
raise ValueError(f"Percentis inválidos: {low_pct}, {high_pct}")
sample = self._sample_for_stats(
raw16,
max(1, int(levels["sample_max_pixels"])),
).astype(np.float32, copy=False)
sample = sample[np.isfinite(sample)]
if sample.size == 0:
raise RuntimeError("RAW não contém pixels finitos para calcular níveis.")
black_source = "explicit"
white_source = "explicit"
if black_level is None:
black = float(np.percentile(sample, low_pct))
black_source = "percentile"
else:
black = self._finite_float(black_level, "black_level")
if white_level is None:
white = float(np.percentile(sample, high_pct))
white_source = "percentile"
else:
white = self._finite_float(white_level, "white_level")
black = float(np.clip(black, 0.0, max_code))
white = float(np.clip(white, 0.0, max_code))
minimum_span = float(levels["minimum_span_codes"])
fallback = False
if white <= black + minimum_span:
black = float(np.clip(np.min(sample), 0.0, max_code))
white = float(np.clip(np.max(sample), 0.0, max_code))
fallback = True
if white <= black:
black, white = 0.0, max_code
fallback = True
return black, white, {
"black_level": black,
"white_level": white,
"black_source": black_source,
"white_source": white_source,
"black_percentile": low_pct,
"white_percentile": high_pct,
"fallback_to_range": fallback,
"sample_pixels": int(sample.size),
}
def raw16_to_vis8(
self,
raw16: np.ndarray,
black_level: Optional[int] = None,
white_level: Optional[int] = None,
gamma: Optional[float] = None,
bit_depth: Optional[int] = None,
black_percentile: Optional[float] = None,
white_percentile: Optional[float] = None,
) -> np.ndarray:
"""Converte mosaico RAW16 para mosaico uint8 de visualização."""
raw = np.asarray(raw16)
if raw.ndim != 2:
raise ValueError(f"RAW16 deve ser 2D; shape={raw.shape}")
if raw.shape != (self.sensor_height, self.sensor_width):
raise ValueError(
f"RAW16 shape={raw.shape}; esperado="
f"{(self.sensor_height, self.sensor_width)}"
)
if not np.issubdtype(raw.dtype, np.integer):
raise TypeError(f"RAW16 deve possuir dtype inteiro; recebido={raw.dtype}")
bit_depth = int(
self.config["bit_depth"] if bit_depth is None else bit_depth
)
gamma_value = self._finite_float(
self.config["gamma"] if gamma is None else gamma,
"gamma",
)
if gamma_value <= 0.0:
raise ValueError("gamma deve ser > 0.")
black, white, level_report = self._resolve_levels(
raw,
black_level,
white_level,
bit_depth,
black_percentile,
white_percentile,
)
norm = (raw.astype(np.float32) - black) / max(white - black, 1.0)
np.clip(norm, 0.0, 1.0, out=norm)
if gamma_value != 1.0:
norm = np.power(norm, 1.0 / gamma_value)
self.last_preview_report = {
"stage": "raw16_to_vis8",
"bit_depth": bit_depth,
"gamma": gamma_value,
"levels": level_report,
}
return np.clip(norm * 255.0 + 0.5, 0, 255).astype(np.uint8)
def _debayer_code(self):
# A API desta classe promete BGR para cv2.imshow/cv2.imwrite.
mapping = {
"RGGB": ("COLOR_BayerRG2BGR_EA", "COLOR_BayerRG2BGR"),
"BGGR": ("COLOR_BayerBG2BGR_EA", "COLOR_BayerBG2BGR"),
"GRBG": ("COLOR_BayerGR2BGR_EA", "COLOR_BayerGR2BGR"),
"GBRG": ("COLOR_BayerGB2BGR_EA", "COLOR_BayerGB2BGR"),
}
preferred, fallback = mapping[self.bayer_pattern]
return getattr(cv2, preferred, getattr(cv2, fallback))
def apply_preview_white_balance(
self,
bgr: np.ndarray,
strength: float = 1.0,
method: Optional[str] = None,
gain_min: Optional[float] = None,
gain_max: Optional[float] = None,
return_report: bool = False,
):
"""
Gray-world simples para deixar o preview mais agradável.
Não usar no raw de treino.
WB visual robusto para campo agrícola.
Primeiro procura pixels claros e de baixo croma, reduzindo o risco de
o gray-world neutralizar toda a vegetação verde. Se não houver amostra
neutra suficiente, usa gray-world com ganhos limitados.
"""
img = bgr.astype(np.float32)
image = np.asarray(bgr)
if image.ndim != 3 or image.shape[2] != 3:
raise ValueError(f"WB requer BGR HWC; shape={image.shape}")
mean_b = float(img[:, :, 0].mean())
mean_g = float(img[:, :, 1].mean())
mean_r = float(img[:, :, 2].mean())
cfg = self.config["white_balance"]
method = str(method or cfg["method"]).lower()
strength = float(np.clip(self._finite_float(strength, "wb_strength"), 0.0, 1.0))
gain_min = float(cfg["gain_min"] if gain_min is None else gain_min)
gain_max = float(cfg["gain_max"] if gain_max is None else gain_max)
if gain_min <= 0.0 or gain_max < gain_min:
raise ValueError("Limites de ganho WB inválidos.")
mean_gray = (mean_b + mean_g + mean_r) / 3.0
sample = image[::4, ::4].astype(np.float32)
flat = sample.reshape(-1, 3)
intensity = flat.mean(axis=1)
chroma = (flat.max(axis=1) - flat.min(axis=1)) / np.maximum(intensity, 1.0)
eps = 1e-6
gain_b = mean_gray / max(mean_b, eps)
gain_g = mean_gray / max(mean_g, eps)
gain_r = mean_gray / max(mean_r, eps)
selected = flat
source = "gray_world"
if method.startswith("neutral_bright") and flat.shape[0] > 0:
bright_limit = float(np.percentile(intensity, float(cfg["bright_percentile"])))
bright_mask = intensity >= bright_limit
bright_chroma = chroma[bright_mask]
if bright_chroma.size:
neutral_limit = float(
np.percentile(
bright_chroma,
float(cfg["neutral_chroma_percentile"]),
)
)
neutral_mask = bright_mask & (chroma <= neutral_limit)
neutral = flat[neutral_mask]
if neutral.shape[0] >= int(cfg["min_neutral_pixels"]):
selected = neutral
source = "neutral_bright"
# strength=1 aplica total, strength=0 não aplica
gain_b = 1.0 + (gain_b - 1.0) * strength
gain_g = 1.0 + (gain_g - 1.0) * strength
gain_r = 1.0 + (gain_r - 1.0) * strength
means = selected.mean(axis=0)
target = float(means.mean())
gains = target / np.maximum(means, 1e-6)
gains = np.clip(gains, gain_min, gain_max)
gains = 1.0 + (gains - 1.0) * strength
img[:, :, 0] *= gain_b
img[:, :, 1] *= gain_g
img[:, :, 2] *= gain_r
out = np.clip(
image.astype(np.float32) * gains[None, None, :],
0,
255,
).astype(np.uint8)
report = {
"method_requested": method,
"source_used": source,
"strength": strength,
"means_bgr": [float(x) for x in means],
"gains_bgr": [float(x) for x in gains],
"selected_pixels": int(selected.shape[0]),
}
return (out, report) if return_report else out
return np.clip(img, 0, 255).astype(np.uint8)
def apply_preview_contrast(
self,
bgr: np.ndarray,
alpha: float = 1.08,
beta: float = 0.0,
) -> np.ndarray:
"""Compatibilidade: contraste/brilho visual sem alterar geometria."""
alpha = self._finite_float(alpha, "contrast_alpha")
beta = self._finite_float(beta, "contrast_beta")
return cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta)
def apply_preview_contrast(self, bgr: np.ndarray, alpha: float = 1.08, beta: float = 0.0) -> np.ndarray:
"""
Ajuste leve de contraste/brilho para preview.
"""
out = cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta)
return out
@staticmethod
def _apply_clahe_luminance(
bgr: np.ndarray,
clip_limit: float,
tile_grid_size: int,
) -> np.ndarray:
lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB)
luma, a_ch, b_ch = cv2.split(lab)
clahe = cv2.createCLAHE(
clipLimit=float(clip_limit),
tileGridSize=(int(tile_grid_size), int(tile_grid_size)),
)
return cv2.cvtColor(
cv2.merge([clahe.apply(luma), a_ch, b_ch]),
cv2.COLOR_LAB2BGR,
)
@staticmethod
def _apply_saturation(bgr: np.ndarray, factor: float) -> np.ndarray:
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
sat = hsv[:, :, 1].astype(np.float32) * float(factor)
hsv[:, :, 1] = np.clip(sat, 0, 255).astype(np.uint8)
return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
@staticmethod
def _apply_unsharp(
bgr: np.ndarray,
sigma: float,
amount: float,
threshold: float,
) -> np.ndarray:
image = bgr.astype(np.float32)
blurred = cv2.GaussianBlur(
image,
(0, 0),
sigmaX=float(sigma),
sigmaY=float(sigma),
)
sharpened = image + float(amount) * (image - blurred)
if threshold > 0.0:
mask = np.max(np.abs(image - blurred), axis=2) >= float(threshold)
result = image.copy()
result[mask] = sharpened[mask]
else:
result = sharpened
return np.clip(result, 0, 255).astype(np.uint8)
def raw16_to_preview_bgr(
self,
raw16: np.ndarray,
gamma: float = 2.2,
wb_strength: float = 0.8,
gamma: Optional[float] = None,
wb_strength: Optional[float] = None,
apply_wb: bool = True,
apply_contrast: bool = True,
bit_depth: int = 10,
) -> np.ndarray:
"""
Pipeline de preview bonito:
1. auto-level + gamma no mosaico
2. demosaic
3. white balance simples
4. leve contraste final
"""
vis8 = self.raw16_to_vis8(raw16, gamma=gamma, bit_depth=bit_depth)
bit_depth: Optional[int] = None,
*,
black_level: Optional[float] = None,
white_level: Optional[float] = None,
black_percentile: Optional[float] = None,
white_percentile: Optional[float] = None,
apply_clahe: Optional[bool] = None,
apply_saturation: Optional[bool] = None,
apply_sharpen: Optional[bool] = None,
return_report: bool = False,
):
"""Gera preview BGR bonito preservando exatamente o raster de entrada."""
source = np.asarray(raw16)
expected_shape = (self.sensor_height, self.sensor_width)
if source.shape != expected_shape:
raise ValueError(f"RAW16 shape={source.shape}; esperado={expected_shape}")
effective_gamma = float(
self.config["gamma"] if gamma is None else gamma
)
effective_bit_depth = int(
self.config["bit_depth"] if bit_depth is None else bit_depth
)
effective_wb_strength = float(
self.config["white_balance"]["strength"]
if wb_strength is None
else wb_strength
)
vis8 = self.raw16_to_vis8(
source,
black_level=black_level,
white_level=white_level,
gamma=effective_gamma,
bit_depth=effective_bit_depth,
black_percentile=black_percentile,
white_percentile=white_percentile,
)
level_report = deepcopy(self.last_preview_report.get("levels", {}))
bgr = cv2.cvtColor(vis8, self._debayer_code())
wb_report = {"enabled": False}
if apply_wb:
bgr = self.apply_preview_white_balance(bgr, strength=wb_strength)
bgr, wb_report = self.apply_preview_white_balance(
bgr,
strength=effective_wb_strength,
return_report=True,
)
wb_report["enabled"] = True
clahe_cfg = self.config["clahe"]
clahe_enabled = bool(clahe_cfg["enabled"] if apply_clahe is None else apply_clahe)
if clahe_enabled:
bgr = self._apply_clahe_luminance(
bgr,
float(clahe_cfg["clip_limit"]),
int(clahe_cfg["tile_grid_size"]),
)
saturation_cfg = self.config["saturation"]
saturation_enabled = bool(
saturation_cfg["enabled"]
if apply_saturation is None
else apply_saturation
)
if saturation_enabled:
bgr = self._apply_saturation(bgr, float(saturation_cfg["factor"]))
contrast_cfg = self.config["contrast"]
if apply_contrast:
bgr = self.apply_preview_contrast(bgr, alpha=1.08, beta=0.0)
bgr = self.apply_preview_contrast(
bgr,
alpha=float(contrast_cfg["alpha"]),
beta=float(contrast_cfg["beta"]),
)
return bgr
sharpen_cfg = self.config["sharpen"]
sharpen_enabled = bool(
sharpen_cfg["enabled"] if apply_sharpen is None else apply_sharpen
)
if sharpen_enabled:
bgr = self._apply_unsharp(
bgr,
sigma=float(sharpen_cfg["sigma"]),
amount=float(sharpen_cfg["amount"]),
threshold=float(sharpen_cfg["threshold"]),
)
def raw16_to_preview_jpg_bytes(self, raw16: np.ndarray, jpeg_quality: int = 95) -> bytes:
if bgr.shape != (self.sensor_height, self.sensor_width, 3):
raise RuntimeError(
f"Preview alterou geometria: {bgr.shape}; esperado="
f"{(self.sensor_height, self.sensor_width, 3)}"
)
if bgr.dtype != np.uint8:
raise RuntimeError(f"Preview dtype={bgr.dtype}; esperado=uint8")
self.last_preview_report = {
**self.get_preview_contract(),
"effective": {
"bit_depth": effective_bit_depth,
"gamma": effective_gamma,
"levels": level_report,
"white_balance": wb_report,
"clahe_enabled": clahe_enabled,
"saturation_enabled": saturation_enabled,
"contrast_enabled": bool(apply_contrast),
"sharpen_enabled": sharpen_enabled,
},
}
result = np.ascontiguousarray(bgr)
return (result, self.get_last_preview_report()) if return_report else result
def unpack_raw10_packed(
self,
packed_frame,
sensor_width: Optional[int] = None,
sensor_height: Optional[int] = None,
stride: Optional[int] = None,
) -> np.ndarray:
"""
Desempacota RAW10 MIPI (4 pixels/5 bytes), aceitando ndarray 1D/2D,
bytes e stride/padding por linha.
"""
width = self.sensor_width if sensor_width is None else int(sensor_width)
height = self.sensor_height if sensor_height is None else int(sensor_height)
if width != self.sensor_width or height != self.sensor_height:
raise ValueError(
"RawProcessorPreview preserva o raster configurado; dimensão "
f"solicitada={width}x{height}, configurada="
f"{self.sensor_width}x{self.sensor_height}."
)
if width % 4 != 0:
raise ValueError(f"RAW10 exige largura múltipla de 4: {width}")
if isinstance(packed_frame, (bytes, bytearray, memoryview)):
packed = np.frombuffer(packed_frame, dtype=np.uint8)
else:
packed = np.asarray(packed_frame)
if packed.ndim == 3 and packed.shape[2] == 1:
packed = packed[:, :, 0]
useful_width = (width // 4) * 5
if packed.dtype != np.uint8:
raise TypeError(
f"RAW10 packed deve ser uint8; shape={packed.shape}, dtype={packed.dtype}"
)
if packed.ndim == 1:
if stride is None:
if packed.size % height != 0:
raise ValueError(
f"RAW10 1D possui {packed.size} bytes, não divisível por "
f"height={height}; informe stride."
)
stride = packed.size // height
stride = int(stride)
expected_bytes = stride * height
if stride < useful_width or packed.size < expected_bytes:
raise ValueError(
f"RAW10 1D curto/inválido: bytes={packed.size}, stride={stride}, "
f"esperado>={expected_bytes}, payload/linha={useful_width}."
)
packed = packed[:expected_bytes].reshape(height, stride)
elif packed.ndim == 2:
if stride is not None and int(stride) != packed.shape[1]:
raise ValueError(
f"stride={stride} diverge da largura do array={packed.shape[1]}."
)
else:
raise TypeError(
f"RAW10 packed deve ser 1D ou 2D; shape={packed.shape}."
)
if packed.shape[0] != height or packed.shape[1] < useful_width:
raise ValueError(
f"RAW10 packed shape={packed.shape}; esperado >=({height}, {useful_width})"
)
padding = int(packed.shape[1] - useful_width)
if padding > max(4096, useful_width):
raise ValueError(
f"Padding RAW10 implausível: {padding} bytes/linha; "
"verifique width/height/stride."
)
payload = np.ascontiguousarray(packed[:, :useful_width])
groups = payload.reshape(height, width // 4, 5)
high = groups[:, :, :4].astype(np.uint16)
low = groups[:, :, 4].astype(np.uint16)
out = np.empty((height, width // 4, 4), dtype=np.uint16)
out[:, :, 0] = (high[:, :, 0] << 2) | (low & 0x03)
out[:, :, 1] = (high[:, :, 1] << 2) | ((low >> 2) & 0x03)
out[:, :, 2] = (high[:, :, 2] << 2) | ((low >> 4) & 0x03)
out[:, :, 3] = (high[:, :, 3] << 2) | ((low >> 6) & 0x03)
return np.ascontiguousarray(out.reshape(height, width))
def packed_raw10_to_preview_bgr(self, packed_frame: np.ndarray, **kwargs):
"""Atalho oficial RAW10 packed -> preview BGR."""
raw16 = self.unpack_raw10_packed(packed_frame)
return self.raw16_to_preview_bgr(raw16, **kwargs)
def raw16_to_preview_jpg_bytes(
self,
raw16: np.ndarray,
jpeg_quality: int = 95,
) -> bytes:
quality = int(np.clip(int(jpeg_quality), 1, 100))
bgr = self.raw16_to_preview_bgr(raw16)
ok, enc = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(jpeg_quality)])
ok, encoded = cv2.imencode(
".jpg",
bgr,
[int(cv2.IMWRITE_JPEG_QUALITY), quality],
)
if not ok:
raise RuntimeError("Falha ao codificar preview JPG")
return enc.tobytes()
return encoded.tobytes()
def raw16_to_preview_png_bytes(
self,
raw16: np.ndarray,
compression: int = 3,
) -> bytes:
level = int(np.clip(int(compression), 0, 9))
bgr = self.raw16_to_preview_bgr(raw16)
ok, encoded = cv2.imencode(
".png",
bgr,
[int(cv2.IMWRITE_PNG_COMPRESSION), level],
)
if not ok:
raise RuntimeError("Falha ao codificar preview PNG")
return encoded.tobytes()